diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml
index e92a37fb4e..cbd2cab0c7 100644
--- a/.github/workflows/api-ci.yml
+++ b/.github/workflows/api-ci.yml
@@ -17,9 +17,9 @@ jobs:
api-lint:
name: Run Lint (API)
if: ${{ inputs.skip == false }}
- uses: codecov/gha-workflows/.github/workflows/lint.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/lint.yml@matt/make-target-prefix
with:
- working_directory: apps/codecov-api
+ make_target_prefix: api.
api-mypy:
name: Patch typing (API)
@@ -28,72 +28,98 @@ jobs:
with:
working_directory: apps/codecov-api
+ # Once we cut over to umbrella, we can just replace the default cache key in build-app.yml
+ # and self-hosted.yml and get rid of this step.
+ api-compute-reqs-cache-key:
+ name: Compute cache key for requirements image
+ runs-on: ubuntu-latest
+ outputs:
+ reqs-cache-key: ${{ steps.compute.outputs.cache-key }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: 'recursive'
+ - id: compute
+ run: |
+ echo cache-key=${{ hashFiles('apps/codecov-api/uv.lock') }}-${{ hashFiles('docker/Dockerfile.requirements') }}-${{ hashFiles('libs/shared/**') }} >> "$GITHUB_OUTPUT"
+
api-build:
name: Build App (API)
if: ${{ inputs.skip == false }}
- uses: codecov/gha-workflows/.github/workflows/build-app.yml@v1.2.31
+ needs: [api-compute-reqs-cache-key]
+ uses: codecov/gha-workflows/.github/workflows/build-app.yml@matt/make-target-prefix
secrets: inherit
with:
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
+ make_target_prefix: api.
+ reqs_cache_key: ${{ needs.api-compute-reqs-cache-key.outputs.reqs-cache-key }}
api-test:
name: Test (API)
if: ${{ inputs.skip == false }}
needs: [api-build]
- uses: codecov/gha-workflows/.github/workflows/run-tests.yml@v1.2.35
+ uses: codecov/gha-workflows/.github/workflows/run-tests.yml@matt/make-target-prefix
secrets: inherit
with:
run_integration: false
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
flag_prefix: api
pytest_rootdir: /app
+ make_target_prefix: api.
api-build-self-hosted:
name: Build Self Hosted (API)
if: ${{ inputs.skip == false }}
needs: [api-build, api-test]
- uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@matt/make-target-prefix
secrets: inherit
with:
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
+ make_target_prefix: api.
+ reqs_cache_key: ${{ needs.api-compute-reqs-cache-key.outputs.reqs-cache-key }}
api-staging:
name: Push Staging Image (API)
needs: [api-build, api-test]
if: ${{ inputs.skip == false && github.event_name == 'push' && github.event.ref == 'refs/heads/staging' && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@matt/make-target-prefix
secrets: inherit
with:
environment: staging
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
sentry_project: api
+ make_target_prefix: api.
api-production:
name: Push Production Image (API)
needs: [api-build, api-test]
if: ${{ inputs.skip == false && github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@matt/make-target-prefix
secrets: inherit
with:
environment: production
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
sentry_project: api
+ make_target_prefix: api.
api-self-hosted:
name: Push Self Hosted Image (API)
needs: [api-build-self-hosted, api-test]
secrets: inherit
if: ${{ inputs.skip == false && github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@matt/make-target-prefix
with:
push_rolling: true
repo: ${{ vars.CODECOV_API_IMAGE_V2 || vars.CODECOV_API_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-api' }}
- working_directory: apps/codecov-api
+ output_directory: apps/codecov-api
+ make_target_prefix: api.
+ reqs_cache_key: ${{ needs.api-compute-reqs-cache-key.outputs.reqs-cache-key }}
# This job works around a strange interaction between reusable workflows and
# path filters.
diff --git a/.github/workflows/shared-ci.yml b/.github/workflows/shared-ci.yml
index 1a4981cb46..9603e1bc85 100644
--- a/.github/workflows/shared-ci.yml
+++ b/.github/workflows/shared-ci.yml
@@ -12,9 +12,9 @@ jobs:
shared-lint:
name: Run Lint (Shared)
if: ${{ inputs.skip == false }}
- uses: codecov/gha-workflows/.github/workflows/lint.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/lint.yml@matt/make-target-prefix
with:
- working_directory: libs/shared
+ make_target_prefix: shared.
shared-benchmark:
name: Benchmarks (Shared)
@@ -138,7 +138,6 @@ jobs:
# The coverage action will have installed codecovcli with pip. The
# actual binary will be found in $PATH.
binary: codecovcli
- recurse_submodules: true
# This job works around a strange interaction between reusable workflows and
# path filters.
diff --git a/.github/workflows/worker-ci.yml b/.github/workflows/worker-ci.yml
index adbcf7e9bd..a9a5206f78 100644
--- a/.github/workflows/worker-ci.yml
+++ b/.github/workflows/worker-ci.yml
@@ -2,7 +2,7 @@ name: Worker CI
on:
workflow_call:
- inputs:
+ inputs:
skip:
type: boolean
default: false
@@ -17,9 +17,9 @@ jobs:
worker-lint:
name: Run Lint (Worker)
if: ${{ inputs.skip == false }}
- uses: codecov/gha-workflows/.github/workflows/lint.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/lint.yml@matt/make-target-prefix
with:
- working_directory: apps/worker
+ make_target_prefix: worker.
worker-mypy:
name: Patch typing (Worker)
@@ -28,71 +28,97 @@ jobs:
with:
working_directory: apps/worker
+ # Once we cut over to umbrella, we can just replace the default cache key in build-app.yml
+ # and self-hosted.yml and get rid of this step.
+ worker-compute-reqs-cache-key:
+ name: Compute cache key for requirements image
+ runs-on: ubuntu-latest
+ outputs:
+ reqs-cache-key: ${{ steps.compute.outputs.cache-key }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: 'recursive'
+ - id: compute
+ run: |
+ echo cache-key=${{ hashFiles('apps/worker/uv.lock') }}-${{ hashFiles('docker/Dockerfile.requirements') }}-${{ hashFiles('libs/shared/**') }} >> "$GITHUB_OUTPUT"
+
worker-build:
name: Build App (Worker)
if: ${{ inputs.skip == false }}
- uses: codecov/gha-workflows/.github/workflows/build-app.yml@v1.2.31
+ needs: [worker-compute-reqs-cache-key]
+ uses: codecov/gha-workflows/.github/workflows/build-app.yml@matt/make-target-prefix
secrets: inherit
with:
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
+ make_target_prefix: worker.
+ reqs_cache_key: ${{ needs.worker-compute-reqs-cache-key.outputs.reqs-cache-key }}
worker-test:
name: Test (Worker)
if: ${{ inputs.skip == false }}
needs: [worker-build]
- uses: codecov/gha-workflows/.github/workflows/run-tests.yml@v1.2.35
+ uses: codecov/gha-workflows/.github/workflows/run-tests.yml@matt/make-target-prefix
secrets: inherit
with:
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
flag_prefix: worker
pytest_rootdir: /app
+ make_target_prefix: worker.
worker-build-self-hosted:
name: Build Self Hosted (Worker)
if: ${{ inputs.skip == false }}
needs: [worker-build, worker-test]
- uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@matt/make-target-prefix
secrets: inherit
with:
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
+ make_target_prefix: worker.
+ reqs_cache_key: ${{ needs.worker-compute-reqs-cache-key.outputs.reqs-cache-key }}
worker-staging:
name: Push Staging Image (Worker)
needs: [worker-build, worker-test]
if: ${{ inputs.skip == false && github.event_name == 'push' && (github.event.ref == 'refs/heads/main' || github.event.ref == 'refs/heads/staging') && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@matt/make-target-prefix
secrets: inherit
with:
environment: staging
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
sentry_project: worker
+ make_target_prefix: worker.
worker-production:
name: Push Production Image (Worker)
needs: [worker-build, worker-test]
if: ${{ inputs.skip == false && github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@matt/make-target-prefix
secrets: inherit
with:
environment: production
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
sentry_project: worker
+ make_target_prefix: worker.
worker-self-hosted:
name: Push Self Hosted Image (Worker)
- needs: [worker-build-self-hosted, worker-test]
+ needs: [worker-compute-reqs-cache-key, worker-build-self-hosted, worker-test]
secrets: inherit
if: ${{ inputs.skip == false && github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
- uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.31
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@matt/make-target-prefix
with:
push_rolling: true
repo: ${{ vars.CODECOV_WORKER_IMAGE_V2 || vars.CODECOV_WORKER_IMAGE_V2_SELF_HOSTED || 'codecov/self-hosted-worker' }}
- working_directory: apps/worker
+ output_directory: apps/worker
+ make_target_prefix: worker.
+ reqs_cache_key: ${{ needs.worker-compute-reqs-cache-key.outputs.reqs-cache-key }}
# This job works around a strange interaction between reusable workflows and
# path filters.
diff --git a/.gitmodules b/.gitmodules
index f64a466b33..e69de29bb2 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,9 +0,0 @@
-[submodule "apps/worker"]
- path = apps/worker
- url = git@github.com:codecov/worker.git
-[submodule "apps/codecov-api"]
- path = apps/codecov-api
- url = git@github.com:codecov/codecov-api.git
-[submodule "libs/shared"]
- path = libs/shared
- url = git@github.com:codecov/shared.git
diff --git a/Makefile b/Makefile
index 61b7e65ddf..94d85ce54b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,280 +1,102 @@
-sha := $(shell git rev-parse --short=7 HEAD)
-long_sha := $(shell git rev-parse HEAD)
-release_version := `cat VERSION`
-build_date ?= $(shell git show -s --date=iso8601-strict --pretty=format:%cd $$sha)
-branch = $(shell git branch | grep \* | cut -f2 -d' ')
-epoch := $(shell date +"%s")
-AR_REPO ?= codecov/umbrella
-DOCKERHUB_REPO ?= codecov/self-hosted-api
-REQUIREMENTS_TAG := requirements-v1-$(shell sha1sum requirements.txt | cut -d ' ' -f 1)-$(shell sha1sum docker/Dockerfile.requirements | cut -d ' ' -f 1)
-VERSION := release-${sha}
-CODECOV_UPLOAD_TOKEN ?= "notset"
-CODECOV_STATIC_TOKEN ?= "notset"
-TIMESERIES_ENABLED ?= "true"
-CODECOV_URL ?= "https://api.codecov.io"
-export DOCKER_BUILDKIT=1
-export API_DOCKER_REPO=${AR_REPO}
-export API_DOCKER_VERSION=${VERSION}
-export CODECOV_TOKEN=${CODECOV_UPLOAD_TOKEN}
-API_DOMAIN ?= api
-PROXY_NETWORK ?= api_default
-
-# Codecov CLI version to use
-CODECOV_CLI_VERSION := 0.5.1
-
-include tools/devenv/Makefile.devenv
-
-update-reqs:
- cd codecov-api && git pull
- cd worker && git pull
- cd shared && git pull
- cat worker/requirements.in codecov-api/requirements.in | sort -u > requirements.in
- pip-compile requirements.in
-
-build:
- make build.requirements
- make build.app
-
-check-for-migration-conflicts:
- python manage.py check_for_migration_conflicts
+export sha := $(shell git rev-parse --short=7 HEAD)
+export full_sha := $(shell git rev-parse HEAD)
+export long_sha := ${full_sha}
+export merge_sha := $(shell git merge-base HEAD^ origin/main)
+export VERSION := release-${sha}
-test:
- COVERAGE_CORE=sysmon python -m pytest --cov=./ --junitxml=junit.xml
+export build_date ?= $(shell git show -s --date=iso8601-strict --pretty=format:%cd $$sha)
+export branch := $(shell git branch | grep \* | cut -f2 -d' ')
-test.unit:
- COVERAGE_CORE=sysmon python -m pytest --cov=./ -m "not integration" --cov-report=xml:unit.coverage.xml --junitxml=unit.junit.xml
-
-test.integration:
- COVERAGE_CORE=sysmon python -m pytest --cov=./ -m "integration" --cov-report=xml:integration.coverage.xml --junitxml=integration.junit.xml
-
-lint:
- make lint.install
- make lint.run
-
-lint.install:
- echo "Installing..."
- pip install -Iv ruff
-
-lint.run:
- ruff check
- ruff format
+export DOCKER_BUILDKIT=1
-lint.check:
- echo "Linting..."
- ruff check
- echo "Formatting..."
- ruff format --check
+export SHARED_SHA := $(shell git archive --format=tar HEAD libs/shared | sha1sum | head -c 40)
+export DOCKER_REQS_SHA := $(shell sha1sum docker/Dockerfile.requirements | head -c 40)
-build.requirements:
- # if docker pull succeeds, we have already build this version of
- # requirements.txt. Otherwise, build and push a version tagged
- # with the hash of this requirements.txt
- touch .testenv
+# Generic target for building a requirements image. You probably want
+# `worker.build.requirements` or `api.build.requirements`.
+_build.requirements:
docker pull ${AR_REPO}:${REQUIREMENTS_TAG} || docker build \
- -f docker/Dockerfile.requirements . \
- -t ${AR_REPO}:${REQUIREMENTS_TAG} \
- -t codecov/umbrella-ci-requirements:${REQUIREMENTS_TAG}
-
-build.local:
- docker build -f docker/Dockerfile . \
- -t ${AR_REPO}:latest \
- -t ${AR_REPO}:${VERSION} \
- --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
- --build-arg BUILD_ENV=local
-
-build.app:
- docker build -f docker/Dockerfile . \
- -t ${AR_REPO}:latest \
- -t ${AR_REPO}:${VERSION} \
- --label "org.label-schema.vendor"="Codecov" \
- --label "org.label-schema.version"="${release_version}-${sha}" \
- --label "org.opencontainers.image.revision"="$(long_sha)" \
- --label "org.opencontainers.image.source"="github.com/codecov/umbrella" \
- --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
- --build-arg RELEASE_VERSION=${VERSION} \
- --build-arg BUILD_ENV=cloud
-
-build.self-hosted:
- make build.self-hosted-base
- make build.self-hosted-runtime
-
-build.self-hosted-base:
- docker build -f docker/Dockerfile . \
- -t ${DOCKERHUB_REPO}:latest-no-dependencies \
- -t ${DOCKERHUB_REPO}:${VERSION}-no-dependencies \
- --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
- --build-arg RELEASE_VERSION=${VERSION} \
- --build-arg BUILD_ENV=self-hosted
-
-build.self-hosted-runtime:
- docker build -f docker/Dockerfile . \
- -t ${DOCKERHUB_REPO}:latest \
- -t ${DOCKERHUB_REPO}:${VERSION} \
- --label "org.label-schema.vendor"="Codecov" \
- --label "org.label-schema.version"="${release_version}-${sha}" \
- --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
- --build-arg RELEASE_VERSION=${VERSION} \
- --build-arg BUILD_ENV=self-hosted-runtime
-
-tag.latest:
- docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:latest
-
-tag.staging:
- docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:staging-${VERSION}
-
-tag.production:
- docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:production-${VERSION}
-
-tag.self-hosted-rolling:
- docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:rolling_no_dependencies
- docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:rolling
-
-tag.self-hosted-release:
- docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:${release_version}_no_dependencies
- docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_calver_no_dependencies
- docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_stable_no_dependencies
- docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:${release_version}
- docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-stable
- docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-calver
-
-load.requirements:
- docker load --input requirements.tar
- docker tag codecov/umbrella-ci-requirements:${REQUIREMENTS_TAG} ${AR_REPO}:${REQUIREMENTS_TAG}
-
-load.self-hosted:
- docker load --input self-hosted-runtime.tar
- docker load --input self-hosted.tar
-
-save.app:
- docker save -o app.tar ${AR_REPO}:${VERSION}
-
-save.requirements:
- docker tag ${AR_REPO}:${REQUIREMENTS_TAG} codecov/umbrella-ci-requirements:${REQUIREMENTS_TAG}
- docker save -o requirements.tar codecov/umbrella-ci-requirements:${REQUIREMENTS_TAG}
-
-save.self-hosted:
- make save.self-hosted-base
- make save.self-hosted-runtime
-
-save.self-hosted-base:
- docker save -o self-hosted.tar ${DOCKERHUB_REPO}:${VERSION}-no-dependencies
-
-save.self-hosted-runtime:
- docker save -o self-hosted-runtime.tar ${DOCKERHUB_REPO}:${VERSION}
-
-push.latest:
- docker push ${AR_REPO}:latest
-
-push.staging:
- docker push ${AR_REPO}:staging-${VERSION}
-
-push.production:
- docker push ${AR_REPO}:production-${VERSION}
-
-push.requirements:
- docker push ${AR_REPO}:${REQUIREMENTS_TAG}
-
-push.self-hosted-release:
- docker push ${DOCKERHUB_REPO}:${release_version}_no_dependencies
- docker push ${DOCKERHUB_REPO}:latest_calver_no_dependencies
- docker push ${DOCKERHUB_REPO}:latest_stable_no_dependencies
- docker push ${DOCKERHUB_REPO}:${release_version}
- docker push ${DOCKERHUB_REPO}:latest-stable
- docker push ${DOCKERHUB_REPO}:latest-calver
-
-push.self-hosted-rolling:
- docker push ${DOCKERHUB_REPO}:rolling_no_dependencies
- docker push ${DOCKERHUB_REPO}:rolling
-
-test_env.up:
- env | grep GITHUB > .testenv; true
- TIMESERIES_ENABLED=${TIMESERIES_ENABLED} docker-compose up -d
-
-test_env.prepare:
- docker-compose exec api make test_env.container_prepare
-
-test_env.check_db:
- docker-compose exec umbrella make test_env.container_check_db
- make test_env.check-for-migration-conflicts
-
-test_env.install_cli:
- pip install codecov-cli==$(CODECOV_CLI_VERSION)
-
-test_env.container_prepare:
- apt-get -y install git build-essential netcat-traditional
- make test_env.install_cli
- git config --global --add safe.directory /app
-
-test_env.container_check_db:
- while ! nc -vz postgres 5432; do sleep 1; echo "waiting for postgres"; done
- while ! nc -vz timescale 5432; do sleep 1; echo "waiting for timescale"; done
-
-test_env.run_unit:
- docker-compose exec umbrella make test.unit
-
-test_env.run_integration:
- #docker-compose exec umbrella make test.integration
- echo "Skipping. No Tests"
-
-test_env.check-for-migration-conflicts:
- docker-compose exec umbrella python manage.py check_for_migration_conflicts
-
-test_env.upload:
- docker-compose exec umbrella make test_env.container_upload CODECOV_UPLOAD_TOKEN=${CODECOV_UPLOAD_TOKEN} CODECOV_URL=${CODECOV_URL}
- docker-compose exec umbrella make test_env.container_upload_test_results CODECOV_UPLOAD_TOKEN=${CODECOV_UPLOAD_TOKEN} CODECOV_URL=${CODECOV_URL}
-
-test_env.container_upload:
- codecovcli -u ${CODECOV_URL} upload-process --flag unit-latest-uploader --flag unit \
- --coverage-files-search-exclude-folder=graphql_api/types/** \
- --coverage-files-search-exclude-folder=api/internal/tests/unit/views/cassetes/**
-
-test_env.container_upload_test_results:
- codecovcli -u ${CODECOV_URL} do-upload --report-type "test_results" \
- --files-search-exclude-folder=graphql_api/types/** \
- --files-search-exclude-folder=api/internal/tests/unit/views/cassetes/** || true
-
-test_env:
- make test_env.up
- make test_env.prepare
- make test_env.check_db
- make test_env.run_unit
- make test_env.check-for-migration-conflicts
-
-
-### START Proxy Commands
-.PHONY: proxy.build
-proxy.build: # Used to build the proxy
-proxy.build:
- docker build -f docker/Dockerfile-proxy . -t ${API_DOCKER_REPO}/proxy:latest -t ${API_DOCKER_REPO}/proxy:${release_version}-${sha} \
- --label "org.label-schema.build-date"="$(build_date)" \
- --label "org.label-schema.name"="API Proxy" \
- --label "org.label-schema.vendor"="api" \
- --label "org.label-schema.version"="${release_version}"
-
-.PHONY: proxy.run
-proxy.run: # Used to run the proxy
-proxy.run:
- make proxy.build
- make proxy.down
- docker run --rm --network ${PROXY_NETWORK} -e FRP_TOKEN=${FRP_TOKEN} -e DOMAIN=${API_DOMAIN} --name api-proxy ${API_DOCKER_REPO}/proxy:latest
- sleep 3
- make proxy.logs
- # You should see "[api] start proxy success"
- # If no logs then proxy failed to start. Check if you are on VPN. If you get a 404, check if you are on VPN
-
-.PHONY: proxy.logs
-proxy.logs: # Used to logs the proxy
-proxy.logs:
- docker logs api-proxy
-
-.PHONY: proxy.shell
-proxy.shell: # Used to shell the proxy
-proxy.shell:
- docker exec -it api-proxy sh
-
-.PHONY: proxy.down
-proxy.down: # Used to down the proxy
-proxy.down:
- docker kill api-proxy || true
-
-### END PROXY Commands
+ -f docker/Dockerfile.requirements . \
+ --build-arg APP_DIR=${APP_DIR} \
+ -t ${AR_REPO}:${REQUIREMENTS_TAG} \
+ -t ${CI_REQS_REPO}:${REQUIREMENTS_TAG}
+
+######
+# codecov-api targets
+######
+API_UV_LOCK_SHA := $(shell sha1sum apps/codecov-api/uv.lock | head -c 40)
+API_REQS_TAG := reqs-${API_UV_LOCK_SHA}-${DOCKER_REQS_SHA}-${SHARED_SHA}
+
+define api_rule_prefix
+.PHONY: $(1)
+$(1): export APP_DIR := apps/codecov-api
+$(1): export REQUIREMENTS_TAG := ${API_REQS_TAG}
+$(1): export AR_REPO ?= codecov/api-umbrella
+$(1): export DOCKERHUB_REPO ?= codecov/self-hosted-api-umbrella
+$(1): export CI_REQS_REPO ?= codecov/api-ci-requirements
+endef
+
+# umbrella builds a special requirements image for api that installs shared properly.
+$(eval $(call api_rule_prefix,api.build.requirements))
+api.build.requirements:
+ $(MAKE) _build.requirements
+
+# This target calls `make build.requirements` for api so we have to make sure it calls our
+# custom `build.requirements` instead.
+$(eval $(call api_rule_prefix,api.build))
+api.build:
+ $(MAKE) api.build.requirements
+ $(MAKE) -C apps/codecov-api build.local
+
+# Any other target starting with `api.` should be forwarded to `apps/codecov-api`.
+# The `$*` variable is the string caught by the `%` in the rule pattern.
+$(eval $(call api_rule_prefix,api.%))
+api.%:
+ $(MAKE) -C apps/codecov-api $*
+
+######
+# worker targets
+######
+WORKER_UV_LOCK_SHA := $(shell sha1sum apps/worker/uv.lock | head -c 40)
+WORKER_REQS_TAG := reqs-${WORKER_UV_LOCK_SHA}-${DOCKER_REQS_SHA}-${SHARED_SHA}
+
+define worker_rule_prefix
+.PHONY: $(1)
+$(1): export APP_DIR := apps/worker
+$(1): export REQUIREMENTS_TAG := ${WORKER_REQS_TAG}
+$(1): export AR_REPO ?= codecov/worker-umbrella
+$(1): export DOCKERHUB_REPO ?= codecov/self-hosted-worker-umbrella
+$(1): export CI_REQS_REPO ?= codecov/worker-ci-requirements
+endef
+
+# umbrella builds a special requirements image for worker that installs shared properly.
+$(eval $(call worker_rule_prefix,worker.build.requirements))
+worker.build.requirements:
+ $(MAKE) _build.requirements
+
+# This target calls `make build.requirements` for worker so we have to make sure it calls our
+# custom `build.requirements` instead.
+$(eval $(call worker_rule_prefix,worker.build))
+worker.build:
+ $(MAKE) worker.build.requirements
+ $(MAKE) -C apps/worker build.local
+
+# Any other target starting with `worker.` should be forwarded to `apps/worker`.
+# The `$*` variable is the string caught by the `%` in the rule pattern.
+$(eval $(call worker_rule_prefix,worker.%))
+worker.%:
+ $(MAKE) -C apps/worker $*
+
+######
+# shared targets
+######
+
+# No need to override any of shared's targets. Just run make in `libs/shared`.
+.PHONY: shared.%
+shared.%:
+ $(MAKE) -C libs/shared $*
+
+######
+# Development environment targets
+######
+include tools/devenv/Makefile.devenv
diff --git a/apps/codecov-api b/apps/codecov-api
deleted file mode 160000
index defc6a5acf..0000000000
--- a/apps/codecov-api
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit defc6a5acf7c03dda8ef757c47b42daa3468aeb1
diff --git a/apps/codecov-api/.deep-dive.yaml b/apps/codecov-api/.deep-dive.yaml
new file mode 100644
index 0000000000..b232e532f9
--- /dev/null
+++ b/apps/codecov-api/.deep-dive.yaml
@@ -0,0 +1,6 @@
+search:
+ exclude_files:
+ python3libs: '/usr/lib/python3\.9.*'
+ files:
+ python: '.*\.pyc?'
+ json: '.*\.json'
\ No newline at end of file
diff --git a/apps/codecov-api/.dockerignore b/apps/codecov-api/.dockerignore
new file mode 100644
index 0000000000..ab95e851c5
--- /dev/null
+++ b/apps/codecov-api/.dockerignore
@@ -0,0 +1,21 @@
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.Python
+env
+pip-log.txt
+pip-delete-this-directory.txt
+.tox
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+*.log
+.git
+service.json
+.github
+.circleci
+gha-creds-*.json
diff --git a/apps/codecov-api/.git-blame-ignore-revs b/apps/codecov-api/.git-blame-ignore-revs
new file mode 100644
index 0000000000..5c3a1becab
--- /dev/null
+++ b/apps/codecov-api/.git-blame-ignore-revs
@@ -0,0 +1,15 @@
+# Since git version 2.23, git-blame has a feature to ignore
+# certain commits.
+#
+# This file contains a list of commits that are not likely what
+# you are looking for in `git blame`. You can set this file as
+# a default ignore file for blame by running the following
+# command.
+#
+# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
+
+# Run black on entire codebase
+714d1f8da36b57b59d2a40d66c0a9a2cefa60b87
+
+# Linting the whole codecov codebase
+899fe2bc94c1d4e6c07c5b04d3d353b889a37229
diff --git a/apps/codecov-api/.github/CODEOWNERS b/apps/codecov-api/.github/CODEOWNERS
new file mode 100644
index 0000000000..d2fd6267c8
--- /dev/null
+++ b/apps/codecov-api/.github/CODEOWNERS
@@ -0,0 +1,7 @@
+upload/ @codecov/Platform
+graphql_api/ @codecov/API
+**/migrations/ @codecov/database-migration-reviewers
+
+codecov_auth/models.py @adrian-codecov
+core/models.py @adrian-codecov
+reports/models.py @adrian-codecov
diff --git a/apps/codecov-api/.github/ISSUE_TEMPLATE/bug_report.md b/apps/codecov-api/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000..07f3be3e12
--- /dev/null
+++ b/apps/codecov-api/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,16 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+# 📣 Feedback / 🐛 Bugs
+
+Do you want to file a bug report and/or feature request for Codecov? [Please use our feedback repo instead](https://github.com/codecov/feedback/issues).
diff --git a/apps/codecov-api/.github/ISSUE_TEMPLATE/feature_request.md b/apps/codecov-api/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000..03313301fe
--- /dev/null
+++ b/apps/codecov-api/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,16 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+# 📣 Feedback / 🐛 Bugs
+
+Do you want to file a bug report and/or feature request for Codecov? [Please use our feedback repo instead](https://github.com/codecov/feedback/issues).
diff --git a/apps/codecov-api/.github/pull_request_template.md b/apps/codecov-api/.github/pull_request_template.md
new file mode 100644
index 0000000000..6bc2b10270
--- /dev/null
+++ b/apps/codecov-api/.github/pull_request_template.md
@@ -0,0 +1,19 @@
+### Purpose/Motivation
+What is the feature? Why is this being done?
+
+### Links to relevant tickets
+
+### What does this PR do?
+Include a brief description of the changes in this PR. Bullet points are your friend.
+
+### Notes to Reviewer
+Anything to note to the team? Any tips on how to review, or where to start?
+
+
+### Legal Boilerplate
+
+Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. In 2022 this entity acquired Codecov and as result Sentry is going to need some rights from me in order to utilize my contributions in this PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
diff --git a/apps/codecov-api/.github/workflows/cache_cleanup.yml b/apps/codecov-api/.github/workflows/cache_cleanup.yml
new file mode 100644
index 0000000000..a8da8d01a7
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/cache_cleanup.yml
@@ -0,0 +1,33 @@
+name: cleanup caches by a branch
+on:
+ pull_request:
+ types:
+ - closed
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Cleanup
+ run: |
+ gh extension install actions/gh-actions-cache
+
+ REPO=${{ github.repository }}
+ BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
+
+ echo "Fetching list of cache key"
+ cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
+
+ ## Setting this to not fail the workflow while deleting cache keys.
+ set +e
+ echo "Deleting caches..."
+ for cacheKey in $cacheKeysForPR
+ do
+ gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
+ done
+ echo "Done"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/apps/codecov-api/.github/workflows/ci.yml b/apps/codecov-api/.github/workflows/ci.yml
new file mode 100644
index 0000000000..ff00f3ffc6
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: API CI
+
+on:
+ push:
+ branches:
+ - main
+ - staging
+ pull_request:
+ merge_group:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+permissions:
+ contents: "read"
+ id-token: "write"
+ issues: "write"
+ pull-requests: "write"
+
+jobs:
+ lint:
+ name: Run Lint
+ uses: codecov/gha-workflows/.github/workflows/lint.yml@v1.2.33
+
+ build:
+ name: Build API
+ uses: codecov/gha-workflows/.github/workflows/build-app.yml@v1.2.33
+ secrets: inherit
+ with:
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+ cache_file: "uv.lock"
+
+ codecovstartup:
+ name: Codecov Startup
+ needs: build
+ uses: codecov/gha-workflows/.github/workflows/codecov-startup.yml@v1.2.33
+ secrets: inherit
+
+ test:
+ name: Test
+ needs: [build]
+ uses: codecov/gha-workflows/.github/workflows/run-tests.yml@v1.2.33
+ secrets: inherit
+ with:
+ run_integration: false
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+
+ build-self-hosted:
+ name: Build Self Hosted API
+ needs: [build, test]
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ secrets: inherit
+ with:
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+ cache_file: "uv.lock"
+
+ staging:
+ name: Push Staging Image
+ needs: [build, test]
+ if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/staging' && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.33
+ secrets: inherit
+ with:
+ environment: staging
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+
+ production:
+ name: Push Production Image
+ needs: [build, test]
+ if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.33
+ secrets: inherit
+ with:
+ environment: production
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+
+ self-hosted:
+ name: Push Self Hosted Image
+ needs: [build-self-hosted, test]
+ secrets: inherit
+ if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ with:
+ push_rolling: true
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+ cache_file: "uv.lock"
diff --git a/apps/codecov-api/.github/workflows/enforce-license-compliance.yml b/apps/codecov-api/.github/workflows/enforce-license-compliance.yml
new file mode 100644
index 0000000000..86be74100e
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/enforce-license-compliance.yml
@@ -0,0 +1,14 @@
+name: Enforce License Compliance
+
+on:
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ enforce-license-compliance:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Enforce License Compliance'
+ uses: getsentry/action-enforce-license-compliance@57ba820387a1a9315a46115ee276b2968da51f3d # main
+ with:
+ fossa_api_key: ${{ secrets.FOSSA_API_KEY }}
diff --git a/apps/codecov-api/.github/workflows/mypy.yml b/apps/codecov-api/.github/workflows/mypy.yml
new file mode 100644
index 0000000000..d51e19b1e5
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/mypy.yml
@@ -0,0 +1,14 @@
+name: "Patch typing check"
+
+on:
+ push:
+ branches:
+ - main
+ - staging
+ pull_request:
+ merge_group:
+
+jobs:
+ patch-typing-check:
+ name: Run Patch Type Check
+ uses: codecov/gha-workflows/.github/workflows/mypy.yml@v1.2.33
diff --git a/apps/codecov-api/.github/workflows/pr_detect_shared_changes.yml b/apps/codecov-api/.github/workflows/pr_detect_shared_changes.yml
new file mode 100644
index 0000000000..15d4e96d9f
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/pr_detect_shared_changes.yml
@@ -0,0 +1,14 @@
+name: Detect dep version changes
+
+on:
+ pull_request:
+
+permissions:
+ pull-requests: "write"
+
+jobs:
+ shared-change-checker:
+ name: See if shared changed
+ uses: codecov/gha-workflows/.github/workflows/diff-dep.yml@main
+ with:
+ dep: 'shared'
diff --git a/apps/codecov-api/.github/workflows/self-hosted-release-pr.yml b/apps/codecov-api/.github/workflows/self-hosted-release-pr.yml
new file mode 100644
index 0000000000..aa094c802d
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/self-hosted-release-pr.yml
@@ -0,0 +1,14 @@
+name: Create Self Hosted Release PR
+
+on:
+ workflow_dispatch:
+ inputs:
+ versionName:
+ description: "Name of version (ie 23.9.5)"
+ required: true
+
+jobs:
+ create-release-pr:
+ name: Create PR for Release ${{ github.event.inputs.versionName }}
+ uses: codecov/gha-workflows/.github/workflows/create-release-pr.yml@v1.2.33
+ secrets: inherit
diff --git a/apps/codecov-api/.github/workflows/self-hosted-release.yml b/apps/codecov-api/.github/workflows/self-hosted-release.yml
new file mode 100644
index 0000000000..f6c1ee6f2b
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/self-hosted-release.yml
@@ -0,0 +1,30 @@
+name: Create Self Hosted Release
+
+on:
+ pull_request:
+ branches:
+ - main
+ types: [closed]
+
+permissions:
+ contents: "read"
+ id-token: "write"
+
+jobs:
+ create-release:
+ name: Tag Release ${{ github.head_ref }} and Push Docker image to Docker Hub
+ if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/create-release.yml@v1.2.33
+ with:
+ tag_to_prepend: self-hosted-
+ secrets: inherit
+
+ push-image:
+ needs: [create-release]
+ if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ secrets: inherit
+ with:
+ push_release: true
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-api' }}
+ cache_file: "uv.lock"
diff --git a/apps/codecov-api/.github/workflows/upload-overwatch.yml b/apps/codecov-api/.github/workflows/upload-overwatch.yml
new file mode 100644
index 0000000000..c7639e5045
--- /dev/null
+++ b/apps/codecov-api/.github/workflows/upload-overwatch.yml
@@ -0,0 +1,37 @@
+name: Upload Overwatch
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+
+jobs:
+ upload-overwatch:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - name: Install UV
+ run: pip install uv
+ - name: Install Project Dependencies
+ run: |
+ uv export --format requirements-txt > requirements.txt
+ uv pip install -r requirements.txt --system
+ - name: Install Static Analysis Tools
+ run: |
+ pip install mypy==1.15.0
+ pip install ruff==0.9.6
+ - name: Install Overwatch CLI
+ run: |
+ curl -o overwatch-cli https://overwatch.codecov.dev/linux/cli
+ chmod +x overwatch-cli
+ - name: Run Overwatch CLI
+ run: |
+ ./overwatch-cli \
+ --auth-token ${{ secrets.SENTRY_AUTH_TOKEN }} \
+ --organization-slug codecov \
+ python \
+ --python-path $(which python3)
diff --git a/apps/codecov-api/.gitignore b/apps/codecov-api/.gitignore
new file mode 100644
index 0000000000..6ba5590c6c
--- /dev/null
+++ b/apps/codecov-api/.gitignore
@@ -0,0 +1,136 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+gha-creds-*.json
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+*.coverage.xml
+*junit.xml
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.env.*
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+.testenv
+.envrc
+.debug
+
+codecov/static/*
+!codecov/static/__init__.py
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# vim temp files
+*.swp
+*.swo
+.vscode/
+
+# PyCharm stuff
+.idea
+.run
+
+*.pem
diff --git a/apps/codecov-api/.gitmodules b/apps/codecov-api/.gitmodules
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/.pre-commit-config.yaml b/apps/codecov-api/.pre-commit-config.yaml
new file mode 100644
index 0000000000..8715a2e0b8
--- /dev/null
+++ b/apps/codecov-api/.pre-commit-config.yaml
@@ -0,0 +1,16 @@
+repos:
+ - repo: local
+ hooks:
+ - id: lint
+ name: lint
+ description: "Lint and sort"
+ entry: make lint.local
+ pass_filenames: false
+ require_serial: true
+ language: system
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v1.10.0"
+ hooks:
+ - id: mypy
+ verbose: true
+ entry: bash -c 'mypy "$@" || true' --
diff --git a/apps/codecov-api/LICENSE.md b/apps/codecov-api/LICENSE.md
new file mode 100644
index 0000000000..630ce64ac6
--- /dev/null
+++ b/apps/codecov-api/LICENSE.md
@@ -0,0 +1,105 @@
+# Functional Source License, Version 1.1, Apache 2.0 Future License
+
+## Abbreviation
+
+FSL-1.1-Apache-2.0
+
+## Notice
+
+Copyright 2018-2024 Functional Software, Inc. dba Sentry
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under
+these Terms and Conditions, as indicated by our inclusion of these Terms and
+Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents,
+Redistribution and Trademark clauses below, we hereby grant you the right to
+use, copy, modify, create derivative works, publicly perform, publicly display
+and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
+means making the Software available to others in a commercial product or
+service that:
+
+1. substitutes for the Software;
+
+2. substitutes for any other product or service we offer using the Software
+ that exists as of the date we make the Software available; or
+
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+
+2. for non-commercial education;
+
+3. for non-commercial research; and
+
+4. in connection with professional services that you provide to a licensee
+ using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our
+patents, the license grant above includes a license under our patents. If you
+make a claim against any party that the Software infringes or contributes to
+the infringement of any patent, then your patent license to the Software ends
+immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of
+the Software.
+
+If you redistribute any copies, modifications or derivatives of the Software,
+you must include a copy of or a link to these Terms and Conditions and not
+remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
+PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
+SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
+EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of
+the Software, you have no right under these Terms and Conditions to use our
+trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under
+the Apache License, Version 2.0 that is effective on the second anniversary of
+the date we make the Software available. On or after that date, you may use the
+Software under the Apache License, Version 2.0, in which case the following
+will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License.
+
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/apps/codecov-api/Makefile b/apps/codecov-api/Makefile
new file mode 100644
index 0000000000..d9748d3dfb
--- /dev/null
+++ b/apps/codecov-api/Makefile
@@ -0,0 +1,281 @@
+sha ?= $(shell git rev-parse --short=7 HEAD)
+long_sha ?= $(shell git rev-parse HEAD)
+merge_sha ?= $(shell git merge-base HEAD^ origin/main)
+release_version := `cat VERSION`
+build_date ?= $(shell git show -s --date=iso8601-strict --pretty=format:%cd $$sha)
+branch ?= $(shell git branch | grep \* | cut -f2 -d' ')
+epoch ?= $(shell date +"%s")
+AR_REPO ?= codecov/api
+DOCKERHUB_REPO ?= codecov/self-hosted-api
+REQUIREMENTS_TAG ?= requirements-v1-$(shell sha1sum uv.lock | cut -d ' ' -f 1)-$(shell sha1sum docker/Dockerfile.requirements | cut -d ' ' -f 1)
+VERSION ?= release-${sha}
+CODECOV_UPLOAD_TOKEN ?= "notset"
+CODECOV_STATIC_TOKEN ?= "notset"
+CODECOV_URL ?= "https://api.codecov.io"
+export DOCKER_BUILDKIT=1
+export API_DOCKER_REPO=${AR_REPO}
+export API_DOCKER_VERSION=${VERSION}
+export CODECOV_TOKEN=${CODECOV_UPLOAD_TOKEN}
+API_DOMAIN ?= api
+PROXY_NETWORK ?= api_default
+
+DEFAULT_REQS_TAG := requirements-v1-$(shell sha1sum uv.lock | cut -d ' ' -f 1)-$(shell sha1sum docker/Dockerfile.requirements | cut -d ' ' -f 1)
+REQUIREMENTS_TAG ?= ${DEFAULT_REQS_TAG}
+
+# We allow this to be overridden so that we can run `pytest` from this directory
+# but have the junit file use paths relative to a parent directory. This will
+# help us move to a monorepo.
+PYTEST_ROOTDIR ?= "."
+
+# Codecov CLI version to use
+CODECOV_CLI_VERSION := 9.0.4
+
+build:
+ make build.requirements
+ make build.app
+
+check-for-migration-conflicts:
+ python manage.py check_for_migration_conflicts
+
+test:
+ COVERAGE_CORE=sysmon pytest --cov=./ --junitxml=junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+test.unit:
+ COVERAGE_CORE=sysmon pytest --cov=./ -m "not integration" --cov-report=xml:unit.coverage.xml --junitxml=unit.junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+test.integration:
+ COVERAGE_CORE=sysmon pytest --cov=./ -m "integration" --cov-report=xml:integration.coverage.xml --junitxml=integration.junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+lint:
+ make lint.install
+ make lint.run
+
+lint.install:
+ echo "Installing..."
+ pip install -Iv ruff
+
+lint.local:
+ make lint.install.local
+ make lint.run
+
+lint.install.local:
+ echo "Installing..."
+ uv add --dev ruff
+
+lint.run:
+ ruff check
+ ruff format
+
+lint.check:
+ echo "Linting..."
+ ruff check
+ echo "Formatting..."
+ ruff format --check
+
+build.requirements:
+ # If make was given a different requirements tag, we assume a suitable image
+ # was already built (e.g. by umbrella) and don't want to build this one.
+ ifneq (${REQUIREMENTS_TAG},${DEFAULT_REQS_TAG})
+ echo "Error: building api reqs image despite another being provided"
+ exit 1
+ endif
+ # if docker pull succeeds, we have already build this version of
+ # requirements.txt. Otherwise, build and push a version tagged
+ # with the hash of this requirements.txt
+ touch .testenv
+ docker pull ${AR_REPO}:${REQUIREMENTS_TAG} || docker build \
+ -f docker/Dockerfile.requirements . \
+ -t ${AR_REPO}:${REQUIREMENTS_TAG} \
+ -t codecov/api-ci-requirements:${REQUIREMENTS_TAG}
+
+build.local:
+ docker build -f docker/Dockerfile . \
+ -t ${AR_REPO}:latest \
+ -t ${AR_REPO}:${VERSION} \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg BUILD_ENV=local
+
+build.app:
+ docker build -f docker/Dockerfile . \
+ -t ${AR_REPO}:latest \
+ -t ${AR_REPO}:${VERSION} \
+ --label "org.label-schema.vendor"="Codecov" \
+ --label "org.label-schema.version"="${release_version}-${sha}" \
+ --label "org.opencontainers.image.revision"="$(long_sha)" \
+ --label "org.opencontainers.image.source"="github.com/codecov/codecov-api" \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=cloud
+
+build.self-hosted:
+ make build.self-hosted-base
+ make build.self-hosted-runtime
+
+build.self-hosted-base:
+ docker build -f docker/Dockerfile . \
+ -t ${DOCKERHUB_REPO}:latest-no-dependencies \
+ -t ${DOCKERHUB_REPO}:${VERSION}-no-dependencies \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=self-hosted
+
+build.self-hosted-runtime:
+ docker build -f docker/Dockerfile . \
+ -t ${DOCKERHUB_REPO}:latest \
+ -t ${DOCKERHUB_REPO}:${VERSION} \
+ --label "org.label-schema.vendor"="Codecov" \
+ --label "org.label-schema.version"="${release_version}-${sha}" \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=self-hosted-runtime
+
+tag.latest:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:latest
+
+tag.staging:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:staging-${VERSION}
+
+tag.production:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:production-${VERSION}
+
+tag.self-hosted-rolling:
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:rolling_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:rolling
+
+tag.self-hosted-release:
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:${release_version}_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_calver_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_stable_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:${release_version}
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-stable
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-calver
+
+load.requirements:
+ docker load --input requirements.tar
+ docker tag codecov/api-ci-requirements:${REQUIREMENTS_TAG} ${AR_REPO}:${REQUIREMENTS_TAG}
+
+load.self-hosted:
+ docker load --input self-hosted-runtime.tar
+ docker load --input self-hosted.tar
+
+save.app:
+ docker save -o app.tar ${AR_REPO}:${VERSION}
+
+save.requirements:
+ docker tag ${AR_REPO}:${REQUIREMENTS_TAG} codecov/api-ci-requirements:${REQUIREMENTS_TAG}
+ docker save -o requirements.tar codecov/api-ci-requirements:${REQUIREMENTS_TAG}
+
+save.self-hosted:
+ make save.self-hosted-base
+ make save.self-hosted-runtime
+
+save.self-hosted-base:
+ docker save -o self-hosted.tar ${DOCKERHUB_REPO}:${VERSION}-no-dependencies
+
+save.self-hosted-runtime:
+ docker save -o self-hosted-runtime.tar ${DOCKERHUB_REPO}:${VERSION}
+
+push.latest:
+ docker push ${AR_REPO}:latest
+
+push.staging:
+ docker push ${AR_REPO}:staging-${VERSION}
+
+push.production:
+ docker push ${AR_REPO}:production-${VERSION}
+
+push.requirements:
+ docker push ${AR_REPO}:${REQUIREMENTS_TAG}
+
+push.self-hosted-release:
+ docker push ${DOCKERHUB_REPO}:${release_version}_no_dependencies
+ docker push ${DOCKERHUB_REPO}:latest_calver_no_dependencies
+ docker push ${DOCKERHUB_REPO}:latest_stable_no_dependencies
+ docker push ${DOCKERHUB_REPO}:${release_version}
+ docker push ${DOCKERHUB_REPO}:latest-stable
+ docker push ${DOCKERHUB_REPO}:latest-calver
+
+push.self-hosted-rolling:
+ docker push ${DOCKERHUB_REPO}:rolling_no_dependencies
+ docker push ${DOCKERHUB_REPO}:rolling
+
+shell:
+ docker compose exec api bash
+
+test_env.up:
+ env | grep GITHUB > .testenv; true
+ docker-compose up -d
+
+test_env.prepare:
+ docker compose exec api make test_env.container_prepare
+
+test_env.check_db:
+ docker compose exec api make test_env.container_check_db
+ make test_env.check-for-migration-conflicts
+
+test_env.install_cli:
+ pip install codecov-cli==$(CODECOV_CLI_VERSION)
+
+test_env.container_prepare:
+ apt-get -y install git build-essential netcat-traditional
+ git config --global --add safe.directory /app/apps/codecov-api || true
+
+test_env.container_check_db:
+ while ! nc -vz postgres 5432; do sleep 1; echo "waiting for postgres"; done
+ while ! nc -vz timescale 5432; do sleep 1; echo "waiting for timescale"; done
+
+test_env.run_unit:
+ docker compose exec api make test.unit PYTEST_ROOTDIR=${PYTEST_ROOTDIR}
+
+test_env.run_integration:
+ # docker compose exec api make test.integration
+ echo "Skipping. No Tests"
+
+test_env.check-for-migration-conflicts:
+ docker compose exec api python manage.py check_for_migration_conflicts
+
+test_env:
+ make test_env.up
+ make test_env.prepare
+ make test_env.check_db
+ make test_env.run_unit
+ make test_env.check-for-migration-conflicts
+
+
+### START Proxy Commands
+.PHONY: proxy.build
+proxy.build: # Used to build the proxy
+proxy.build:
+ docker build -f docker/Dockerfile-proxy . -t ${API_DOCKER_REPO}/proxy:latest -t ${API_DOCKER_REPO}/proxy:${release_version}-${sha} \
+ --label "org.label-schema.build-date"="$(build_date)" \
+ --label "org.label-schema.name"="API Proxy" \
+ --label "org.label-schema.vendor"="api" \
+ --label "org.label-schema.version"="${release_version}"
+
+.PHONY: proxy.run
+proxy.run: # Used to run the proxy
+proxy.run:
+ make proxy.build
+ make proxy.down
+ docker run --rm --network ${PROXY_NETWORK} -e FRP_TOKEN=${FRP_TOKEN} -e DOMAIN=${API_DOMAIN} --name api-proxy ${API_DOCKER_REPO}/proxy:latest
+ sleep 3
+ make proxy.logs
+ # You should see "[api] start proxy success"
+ # If no logs then proxy failed to start. Check if you are on VPN. If you get a 404, check if you are on VPN
+
+.PHONY: proxy.logs
+proxy.logs: # Used to logs the proxy
+proxy.logs:
+ docker logs api-proxy
+
+.PHONY: proxy.shell
+proxy.shell: # Used to shell the proxy
+proxy.shell:
+ docker exec -it api-proxy sh
+
+.PHONY: proxy.down
+proxy.down: # Used to down the proxy
+proxy.down:
+ docker kill api-proxy || true
+
+### END PROXY Commands
diff --git a/apps/codecov-api/README.md b/apps/codecov-api/README.md
new file mode 100644
index 0000000000..de8b96ec95
--- /dev/null
+++ b/apps/codecov-api/README.md
@@ -0,0 +1,85 @@
+## Codecov API
+
+> We believe that everyone should have access to quality software (like Sentry), that’s why we have always offered Codecov for free to open source maintainers.
+>
+> By making our code public, we’re not only joining the community that’s supported us from the start — but also want to make sure that every developer can contribute to and build on the Codecov experience.
+
+A private Django REST Framework API intended to serve Codecov's front end.
+
+## Getting Started
+
+### Building
+
+This project contains a makefile. To build the docker image:
+
+ make build
+
+`requirements.txt` is used in the base image. If you make changes to `requirements.txt` you will need to rebuild.
+
+Note, you'll need to install Rust to build `ribs` which is a dependency of `shared`. Go here for more info on how to do this: https://www.rust-lang.org/tools/install
+
+### Running Standalone
+
+This project contains a `docker-compose.yml` file that is intended to run the api standalone. In this configuration it **does not** share codecov.io's development database; so don't expect parity there.
+
+To start the service, do
+
+`docker-compose up`
+
+Utilizing its own database provides a convenient way for the REST API to provide its own helpful seeds and migrations for active development without potentially destroying/modifying your development database for codecov.io.
+
+Once running, the api will be available at `http://localhost:5100`
+
+### Running with codecov.io
+
+This service will startup when you run codecov.io normally. It is under that `api` block of codecov.io's `docker-compose.yml` file.
+
+### Testing
+
+The easiest way to run tests (that doesn't require installing postgres and other dependencies) is to run inside of docker:
+
+ docker-compose up
+ docker exec -it codecov-api_api_1 pytest -rf --no-migrations
+
+### Testing standalone
+
+If you would like to use pytest directly (Either through an IDE like PyCharm or with the CLI), you will need to change the settings file used by pytest. Run this command to have the tests executed (You will need an instance of postgres running locally):
+
+ RUN_ENV=TESTING DJANGO_SETTINGS_MODULE=codecov.settings_test pytest
+
+Make sure to have all the latest dependencies installed via `uv sync`.
+
+### Deploying
+
+All work merged into the `main` branch is immediately deployed to the production environment. More context on this strategy can be found [here](https://codecovio.atlassian.net/wiki/spaces/ENG/pages/507445249/Branching+and+Continuous+Delivery+Strategy+Proposal).
+
+### Deploying to Staging environment
+
+To deploy to our staging environment it's crucial to follow these steps:
+
+1. Check in Slack to see if anyone is currently using the staging environment
+2. If not, delete the current `staging` branch
+3. Create a new `staging` branch and merge your feature branch into it
+
+Steps 2 and 3 are important to limit interaction between features not yet merged into `main`. This approach was inspired by this document: https://codecovio.atlassian.net/wiki/spaces/ENG/pages/507445249/Branching+and+Continuous+Delivery+Strategy+Proposal
+
+### Secret and Credential Management
+
+This project should store no secrets or credentials in its source. If you need to add to / modify / setup secrets for this project, contact Eli and he'll get you started..
+
+### Adding dependencies
+
+This repository uses `uv` to manage dependencies, so make sure you've installed it with `pip install uv`. To add or update dependencies, simply run `uv add __package_name__` or `uv sync`.
+
+### Formatting
+
+This project uses `ruff` for formatting.
+You can run the linter using the command `make lint_local`.
+
+### Migrations
+
+We leverage Django's migration system to keep the state of our models in sync with the state of our database. You can read more about how we work with migrations at https://codecovio.atlassian.net/wiki/spaces/ENG/pages/1696530442/Migrations
+
+## Contributing
+
+This repository, like all of Codecov's repositories, strives to follow our general [Contributing guidlines](https://github.com/codecov/contributing). If you're considering making a contribution to this repository, we encourage review of our Contributing guidelines first.
diff --git a/apps/codecov-api/VERSION b/apps/codecov-api/VERSION
new file mode 100644
index 0000000000..86a8f0262a
--- /dev/null
+++ b/apps/codecov-api/VERSION
@@ -0,0 +1 @@
+25.4.1
\ No newline at end of file
diff --git a/apps/codecov-api/api/__init__.py b/apps/codecov-api/api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/apps.py b/apps/codecov-api/api/apps.py
new file mode 100644
index 0000000000..14b89a8298
--- /dev/null
+++ b/apps/codecov-api/api/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+ name = "api"
diff --git a/apps/codecov-api/api/gen_ai/__init__.py b/apps/codecov-api/api/gen_ai/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/gen_ai/serializers.py b/apps/codecov-api/api/gen_ai/serializers.py
new file mode 100644
index 0000000000..1b7ceec225
--- /dev/null
+++ b/apps/codecov-api/api/gen_ai/serializers.py
@@ -0,0 +1,5 @@
+from rest_framework import serializers
+
+
+class GenAIAuthSerializer(serializers.Serializer):
+ is_valid = serializers.BooleanField()
diff --git a/apps/codecov-api/api/gen_ai/tests/test_gen_ai.py b/apps/codecov-api/api/gen_ai/tests/test_gen_ai.py
new file mode 100644
index 0000000000..4b63d51d74
--- /dev/null
+++ b/apps/codecov-api/api/gen_ai/tests/test_gen_ai.py
@@ -0,0 +1,114 @@
+import hmac
+from hashlib import sha256
+from unittest.mock import patch
+
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import GithubAppInstallation
+
+PAYLOAD_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
+VIEW_URL = reverse("auth")
+
+
+def sign_payload(data: bytes, secret=PAYLOAD_SECRET):
+ signature = "sha256=" + hmac.new(secret, data, digestmod=sha256).hexdigest()
+ return signature, data
+
+
+class GenAIAuthViewTests(APITestCase):
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_missing_parameters(self, mock_config):
+ payload = b"{}"
+ sig, data = sign_payload(payload)
+ response = self.client.post(
+ VIEW_URL,
+ data=data,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("Missing required parameters", response.data)
+
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_invalid_signature(self, mock_config):
+ # Correct payload
+ payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
+ # Wrong signature based on a different payload
+ wrong_sig = "sha256=" + hmac.new(PAYLOAD_SECRET, b"{}", sha256).hexdigest()
+ response = self.client.post(
+ VIEW_URL,
+ data=payload,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=wrong_sig,
+ )
+ self.assertEqual(response.status_code, 403)
+
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_owner_not_found(self, mock_config):
+ payload = b'{"external_owner_id":"nonexistent_owner","repo_service_id":"101"}'
+ sig, data = sign_payload(payload)
+ response = self.client.post(
+ VIEW_URL,
+ data=data,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
+ )
+ self.assertEqual(response.status_code, 404)
+
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_no_installation(self, mock_config):
+ # Create a valid owner but no installation
+ OwnerFactory(service="github", service_id="owner1", username="test1")
+ payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
+ sig, data = sign_payload(payload)
+ response = self.client.post(
+ VIEW_URL,
+ data=data,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data, {"is_valid": False})
+
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_authorized(self, mock_config):
+ owner = OwnerFactory(service="github", service_id="owner2", username="test2")
+ GithubAppInstallation.objects.create(
+ installation_id=12345,
+ owner=owner,
+ name="ai-features",
+ repository_service_ids=["101", "202"],
+ )
+ payload = b'{"external_owner_id":"owner2","repo_service_id":"101"}'
+ sig, data = sign_payload(payload)
+ response = self.client.post(
+ VIEW_URL,
+ data=data,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data, {"is_valid": True})
+
+ @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
+ def test_unauthorized(self, mock_config):
+ owner = OwnerFactory(service="github", service_id="owner3", username="test3")
+ GithubAppInstallation.objects.create(
+ installation_id=2,
+ owner=owner,
+ name="ai-features",
+ repository_service_ids=["303", "404"],
+ )
+ payload = b'{"external_owner_id":"owner3","repo_service_id":"101"}'
+ sig, data = sign_payload(payload)
+ response = self.client.post(
+ VIEW_URL,
+ data=data,
+ content_type="application/json",
+ HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data, {"is_valid": False})
diff --git a/apps/codecov-api/api/gen_ai/urls.py b/apps/codecov-api/api/gen_ai/urls.py
new file mode 100644
index 0000000000..1273948008
--- /dev/null
+++ b/apps/codecov-api/api/gen_ai/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from .views import GenAIAuthView
+
+urlpatterns = [
+ path("auth/", GenAIAuthView.as_view(), name="auth"),
+]
diff --git a/apps/codecov-api/api/gen_ai/views.py b/apps/codecov-api/api/gen_ai/views.py
new file mode 100644
index 0000000000..e857c5a3a9
--- /dev/null
+++ b/apps/codecov-api/api/gen_ai/views.py
@@ -0,0 +1,61 @@
+import hmac
+import logging
+from hashlib import sha256
+
+from rest_framework.exceptions import NotFound, PermissionDenied
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from api.gen_ai.serializers import GenAIAuthSerializer
+from codecov_auth.models import GithubAppInstallation, Owner
+from graphql_api.types.owner.owner import AI_FEATURES_GH_APP_ID
+from utils.config import get_config
+
+log = logging.getLogger(__name__)
+
+
+class GenAIAuthView(APIView):
+ permission_classes = [AllowAny]
+ serializer_class = GenAIAuthSerializer
+
+ def validate_signature(self, request):
+ key = get_config("gen_ai", "auth_secret")
+ if not key:
+ raise PermissionDenied("Invalid signature")
+
+ if isinstance(key, str):
+ key = key.encode("utf-8")
+ expected_sig = request.headers.get("HTTP-X-GEN-AI-AUTH-SIGNATURE")
+ computed_sig = (
+ "sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest()
+ )
+ if not hmac.compare_digest(computed_sig, expected_sig):
+ raise PermissionDenied("Invalid signature")
+
+ def post(self, request, *args, **kwargs):
+ self.validate_signature(request)
+ external_owner_id = request.data.get("external_owner_id")
+ repo_service_id = request.data.get("repo_service_id")
+ if not external_owner_id or not repo_service_id:
+ return Response("Missing required parameters", status=400)
+ try:
+ owner = Owner.objects.get(service_id=external_owner_id)
+ except Owner.DoesNotExist:
+ raise NotFound("Owner not found")
+
+ is_authorized = True
+
+ app_install = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid, app_id=AI_FEATURES_GH_APP_ID
+ ).first()
+
+ if not app_install:
+ is_authorized = False
+
+ else:
+ repo_ids = app_install.repository_service_ids
+ if repo_ids and repo_service_id not in repo_ids:
+ is_authorized = False
+
+ return Response({"is_valid": is_authorized})
diff --git a/apps/codecov-api/api/internal/branch/__init__.py b/apps/codecov-api/api/internal/branch/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/branch/serializers.py b/apps/codecov-api/api/internal/branch/serializers.py
new file mode 100644
index 0000000000..5c0e086c71
--- /dev/null
+++ b/apps/codecov-api/api/internal/branch/serializers.py
@@ -0,0 +1,13 @@
+from rest_framework import serializers
+
+from core.models import Branch
+
+
+class BranchSerializer(serializers.ModelSerializer):
+ name = serializers.CharField()
+ most_recent_commiter = serializers.CharField()
+ updatestamp = serializers.DateTimeField()
+
+ class Meta:
+ model = Branch
+ fields = ("name", "most_recent_commiter", "updatestamp")
diff --git a/apps/codecov-api/api/internal/branch/views.py b/apps/codecov-api/api/internal/branch/views.py
new file mode 100644
index 0000000000..f3a984d009
--- /dev/null
+++ b/apps/codecov-api/api/internal/branch/views.py
@@ -0,0 +1,25 @@
+from django.db.models import OuterRef, Subquery
+from rest_framework import mixins
+
+from api.shared.branch.mixins import BranchViewSetMixin
+from core.models import Commit
+
+from .serializers import BranchSerializer
+
+
+class BranchViewSet(BranchViewSetMixin, mixins.ListModelMixin):
+ serializer_class = BranchSerializer
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .annotate(
+ most_recent_commiter=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"),
+ repository_id=OuterRef("repository__repoid"),
+ ).values("author__username")[:1]
+ )
+ )
+ )
diff --git a/apps/codecov-api/api/internal/chart/__init__.py b/apps/codecov-api/api/internal/chart/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/chart/filters.py b/apps/codecov-api/api/internal/chart/filters.py
new file mode 100644
index 0000000000..409b15e9c0
--- /dev/null
+++ b/apps/codecov-api/api/internal/chart/filters.py
@@ -0,0 +1,66 @@
+from dateutil import parser
+from django.db.models import F, Q
+
+from core.models import Repository
+from utils.services import get_long_service_name
+
+
+def apply_default_filters(queryset):
+ """
+ By default we only want to include commits with meaningful coverage values when representing charts,
+ so exclude from consideration commits where CI failed, commits that are still pending, etc.
+ """
+ return queryset.filter(state="complete", totals__isnull=False).filter(
+ Q(deleted__isnull=True) | Q(deleted=False)
+ )
+
+
+def apply_simple_filters(queryset, data, user):
+ """
+ Apply any coverage chart filtering parameters that can be construed as a simple queryset.filter call.
+ """
+
+ service = get_long_service_name(data.get("service"))
+
+ queryset = (
+ queryset.filter(
+ repository__author__username=data.get(
+ "owner_username"
+ ) # filter by the organization in the request route
+ )
+ .filter(
+ repository__author__service=service
+ # this avoids fetching the wrong user that uses the same username for different providers
+ )
+ .filter(
+ # make sure we only return repositories that are either public or that the logged-in user has permission to view.
+ # this is important because if no "repository" param was provided then the permissions check will succeed, but we still
+ # want to make sure we return only all repositories the logged-in user has permissions to view.
+ repository__in=Repository.objects.viewable_repos(user)
+ )
+ )
+
+ # Handle branch filtering
+ if data.get("branch"):
+ queryset = queryset.filter(branch=data.get("branch"))
+ else:
+ # if no branch param was provided, default to filtering commits based on the repository's default branch
+ queryset = queryset.filter(branch=F("repository__branch"))
+
+ # Optional filters
+ if data.get("repositories"):
+ queryset = queryset.filter(repository__name__in=data.get("repositories", []))
+ if data.get("start_date"):
+ # The __date cast function will case the datetime based timestamp on the commit to a date object that only
+ # contains the year, month and day. This allows us to filter through a daily granularity rather than
+ # a second granularity since this is the level of granularity we get from other parts of the API.
+ # We also have to convert the parameter to a datetime object for this to work, rather than pass a string.
+ queryset = queryset.filter(
+ timestamp__date__gte=parser.parse(data.get("start_date"))
+ )
+ if data.get("end_date"):
+ # Same as above.
+ queryset = queryset.filter(
+ timestamp__date__lte=parser.parse(data.get("end_date"))
+ )
+ return queryset
diff --git a/apps/codecov-api/api/internal/chart/helpers.py b/apps/codecov-api/api/internal/chart/helpers.py
new file mode 100644
index 0000000000..76de872e53
--- /dev/null
+++ b/apps/codecov-api/api/internal/chart/helpers.py
@@ -0,0 +1,408 @@
+from datetime import datetime
+
+from cerberus import Validator
+from dateutil import parser
+from django.db import connection
+from django.db.models import Case, FloatField, Value, When
+from django.db.models.fields.json import KeyTextTransform
+from django.db.models.functions import Cast, Trunc
+from django.utils import timezone
+from django.utils.functional import cached_property
+from rest_framework.exceptions import ValidationError
+
+from codecov_auth.models import Owner
+from core.models import Repository
+
+
+class ChartParamValidator(Validator):
+ # Custom validation rule to require "agg_value" and "agg_function" fields only when not grouping by commit.
+ # When grouping by commit, we return commits directly without applying any aggregation, so those fields aren't needed.
+ def _validate_check_aggregation_fields(
+ self, check_aggregation_fields, field, value
+ ):
+ agg_fields_present = self.document.get("agg_value") and self.document.get(
+ "agg_function"
+ )
+ if check_aggregation_fields and value != "commit" and not agg_fields_present:
+ self._error(
+ field,
+ "Must provide a value for agg_value and agg_function fields if not grouping by commit",
+ )
+
+
+def validate_params(data):
+ """
+ Explanation of parameters and how they impact the chart:
+
+ - organization: username of the owner associated with the repositories/commits we're generating the chart for
+ - repositories: indicates only commits in the list of repositories should be included. Note that the RepositoryChartHandler doesn't
+ perform any aggregation if multiple repos are provided.
+ - branch: indicates only commits in this branch should be included
+ - start_date: indicates only commits after this date should be included
+ - end_date: indicates only commits before this date should be included
+ - grouping_unit: indicates how to group the commits. if this is 'commit' we'll just return ungrouped commits, if this is a unit of time
+ (day, month, year) we'll group the commits by that time unit when applying aggregation.
+ - agg_function: indicates how to aggregate the commits over . example: if this is 'max', we'll retrieve the commit within a time window with the
+ highest value of whatever 'agg_value' is. *(See below for more explanation on this field)
+ - agg_value: indicates which value we should perform aggregation/grouping on. example: if this is 'coverage', the aggregation function
+ (min, max, etc.) will be applied to commit coverage. *(See below for more explanation on this field.)
+ - coverage_timestamp_ordering: indicates in which order the coverage entries should be ordered by. Increasing will return the latest coverage
+ at the end of the coverage array while decreasing will return the latest coverage at the beginning of the array.
+
+ Aggregation fields - when grouping by a unit of time, we need to know which commit to retrieve over that unit of time - e.g. the latest commit
+ in a given month, or the commit with the highest coverage, etc. The `agg_function` and `agg_value` parameters are used to determine this.
+ Examples: { "grouping_unit": "month", "agg_function": "min", "agg_value": "coverage" } --> get the commit with the highest coverage in a given month
+ Examples: { "grouping_unit": "week", "agg_function": "max", "agg_value": "timestmap" } --> get the most recent commit in a given week
+ """
+
+ params_schema = {
+ "owner_username": {"type": "string", "required": True},
+ "service": {"type": "string", "required": False},
+ "repositories": {"type": "list"},
+ "branch": {"type": "string"},
+ "start_date": {"type": "string"},
+ "end_date": {"type": "string"},
+ "grouping_unit": {
+ "type": "string",
+ "required": True,
+ "check_aggregation_fields": True,
+ "allowed": [
+ "commit",
+ "hour",
+ "day",
+ "week",
+ "month",
+ "quarter",
+ "year",
+ ], # must be one of the values accepted by Django's Trunc function; for more info see https://docs.djangoproject.com/en/3.0/ref/models/database-functions/#trunc
+ },
+ "agg_function": {"type": "string", "allowed": ["min", "max"]},
+ "agg_value": {"type": "string", "allowed": ["timestamp", "coverage"]},
+ "coverage_timestamp_ordering": {
+ "type": "string",
+ "allowed": ["increasing", "decreasing"],
+ },
+ }
+ v = ChartParamValidator(params_schema)
+ if not v.validate(data):
+ raise ValidationError(v.errors)
+
+
+def annotate_commits_with_totals(queryset):
+ """
+ Extract values from a commit's "totals" field and annotate the commit directly with those values.
+ This is necessary when using Django aggregation functions, and otherwise is generally more convenient than wrangling with the totals JSON field.
+ See "CommitTotalsSerializer" for reference on what the values ("c", "N", etc) represent
+ """
+ complexity = Cast(KeyTextTransform("C", "totals"), FloatField()) or 0
+ complexity_total = (
+ Cast(KeyTextTransform("N", "totals"), output_field=FloatField()) or 0
+ )
+ return queryset.annotate(
+ coverage=Cast(KeyTextTransform("c", "totals"), output_field=FloatField()),
+ complexity=complexity,
+ complexity_total=complexity_total,
+ complexity_ratio=Case(
+ When(complexity_total__gt=0, then=complexity / complexity_total),
+ default=Value(None),
+ ),
+ )
+
+
+def apply_grouping(queryset, data):
+ """
+ On the provided queryset, group commits by the time unit provided. Within each time window and for each repository represented in the
+ given queryset, retrieve the appropriate commit based on the aggregation parameters (e.g. commit with "max" timestamp which will be the latest commit)
+ See the params_schema in validate_params for info on the acceptable values here.
+ """
+ grouping_unit = data.get("grouping_unit")
+ agg_function = data.get("agg_function")
+ agg_value = data.get("agg_value")
+ commit_order = data.get("coverage_timestamp_ordering", "increasing")
+
+ # Truncate the commit's timestamp so we can group it in the appropriate time unit.
+ # For example, if we're grouping by quarter, commits in Jan/Feb/March 2020 will all share the same truncated_date
+ queryset = queryset.annotate(truncated_date=Trunc("timestamp", grouping_unit))
+ date_ordering = "" if commit_order == "increasing" else "-"
+ ordering = "" if agg_function == "min" else "-"
+ return queryset.order_by(
+ f"{date_ordering}truncated_date", "repository__name", f"{ordering}{agg_value}"
+ ).distinct(
+ "truncated_date", "repository__name"
+ ) # this will select the first row for a given date/repo combo, which since we've just ordered the commits
+ # should be the one with the min/max value we want to aggregate by
+
+
+class ChartQueryRunner:
+ """
+ Houses the SQL query that retrieves data for analytics chart, and
+ the associated parameter validation + transformation required for it.
+ """
+
+ def __init__(self, user, request_params):
+ self.user = user
+ self.request_params = request_params
+ self._validate_parameters()
+
+ def _dictfetchall(self, cursor):
+ """
+ Return all rows from a cursor as a dict
+ Copied from: https://docs.djangoproject.com/en/3.1/topics/db/sql/#executing-custom-sql-directly
+ """
+ columns = [col[0] for col in cursor.description]
+ return [dict(zip(columns, row)) for row in cursor.fetchall()]
+
+ @property
+ def start_date(self):
+ """
+ Lower bound on the date-range of commit data returned by query.
+ Returns date of first commit made in any repo of 'repoids' is
+ used if not set.
+ """
+ if "start_date" in self.request_params:
+ return datetime.date(parser.parse(self.request_params.get("start_date")))
+ return self.first_complete_commit_date
+
+ @property
+ def end_date(self):
+ """
+ Returns 'end_date' to use in date spine.
+ """
+ if "end_date" in self.request_params:
+ return datetime.date(parser.parse(self.request_params.get("end_date")))
+ return datetime.date(timezone.now())
+
+ @property
+ def interval(self):
+ """
+ Time interval between datapoints constructed in query.
+ Derived from 'grouping_unit' request parameter.
+ """
+ if self.grouping_unit == "quarter":
+ return "3 months"
+ return f"1 {self.grouping_unit}"
+
+ @property
+ def grouping_unit(self):
+ return self.request_params.get("grouping_unit")
+
+ @property
+ def ordering(self):
+ """
+ Data ordering is by ascending date, unless "decreasing" is
+ supplied as ordering param.
+ """
+ if self.request_params.get("coverage_timestamp_ordering") == "decreasing":
+ return "DESC"
+ return ""
+
+ @cached_property
+ def repoids(self):
+ """
+ Returns a string of repoids of the repositories being queried.
+ """
+ organization = Owner.objects.get(
+ service=self.request_params["service"],
+ username=self.request_params["owner_username"],
+ )
+
+ # Get list of relevant repoids
+ repos = Repository.objects.filter(
+ author=organization,
+ active=True,
+ ).viewable_repos(self.user)
+
+ if self.request_params.get("repositories", []):
+ repos = repos.filter(name__in=self.request_params.get("repositories", []))
+
+ if repos:
+ # Get repoids into a format easily plugged into raw SQL
+ return (
+ "("
+ + ",".join(map(str, list(repos.values_list("repoid", flat=True))))
+ + ")"
+ )
+
+ @cached_property
+ def first_complete_commit_date(self):
+ """
+ Date of first commit made to any repo in 'self.repoids'. Used as initial
+ date for date_spine query.
+ """
+ with connection.cursor() as cursor:
+ cursor.execute(
+ f"""
+ WITH relevant_repo_branches AS (
+ SELECT
+ r.repoid,
+ r.branch
+ FROM repos r
+ WHERE r.repoid IN {self.repoids}
+ )
+
+ SELECT
+ DATE_TRUNC('{self.grouping_unit}', c.timestamp AT TIME ZONE 'UTC') as truncated_date
+ FROM commits c
+ INNER JOIN relevant_repo_branches r ON c.repoid = r.repoid AND c.branch = r.branch
+ WHERE c.state = 'complete'
+ ORDER BY c.timestamp ASC LIMIT 1;
+ """
+ )
+ date = self._dictfetchall(cursor)
+
+ if date:
+ return datetime.date(date[0]["truncated_date"])
+
+ def _validate_parameters(self):
+ params_schema = {
+ "owner_username": {"type": "string", "required": True},
+ "service": {"type": "string", "required": True},
+ "repositories": {"type": "list", "required": False},
+ "start_date": {"type": "string", "required": False},
+ "end_date": {"type": "string", "required": False},
+ "agg_function": {"type": "string", "required": False}, # Deprecated
+ "agg_value": {"type": "string", "required": False}, # Deprecated
+ "grouping_unit": {
+ "type": "string",
+ "required": True,
+ "allowed": [
+ "day",
+ "week",
+ "month",
+ "quarter",
+ "year",
+ ], # Must be one acceptable by Postgres DATE_TRUNC
+ },
+ "coverage_timestamp_ordering": {
+ "type": "string",
+ "allowed": ["increasing", "decreasing"],
+ "required": False,
+ },
+ }
+ v = Validator(params_schema)
+ if not v.validate(self.request_params):
+ raise ValidationError(v.errors)
+
+ def run_query(self):
+ # Edge cases -- no repos or no commits
+ if not self.repoids:
+ return []
+ if not self.first_complete_commit_date:
+ return []
+
+ with connection.cursor() as cursor:
+ cursor.execute(
+ f"""
+ WITH date_series AS (
+ SELECT
+ t::date AS "date"
+ FROM generate_series(
+ timestamp '{self.first_complete_commit_date}',
+ timestamp '{self.end_date}',
+ '{self.interval}'
+ ) t
+ ), graph_repos AS (
+ SELECT
+ r.repoid,
+ r.name,
+ r.branch
+ FROM
+ repos r
+ WHERE r.repoid IN {self.repoids}
+ ), spine AS (
+ SELECT
+ ds.date,
+ r.repoid
+ FROM date_series ds
+ CROSS JOIN graph_repos r
+ ), t_ranked_commits AS (
+ SELECT
+ ROW_NUMBER() OVER (
+ PARTITION BY c.repoid, DATE_TRUNC('{self.grouping_unit}', c.timestamp)
+ ORDER BY timestamp DESC NULLS LAST
+ ) AS commit_rank,
+ DATE_TRUNC('{self.grouping_unit}', c.timestamp) AS "truncated_date",
+ c.timestamp AS commit_timestamp,
+ c.totals,
+ r.repoid
+ FROM
+ commits c
+ INNER JOIN graph_repos r ON r.repoid = c.repoid
+ AND r.branch = c.branch
+ AND c.state = 'complete'
+ ), commits_spine AS (
+ SELECT
+ s.date AS spine_date,
+ trc.truncated_date AS truncated_commit_date,
+ trc.commit_timestamp,
+ trc.totals AS totals,
+ s.repoid
+ FROM spine s
+ LEFT JOIN t_ranked_commits trc ON trc.truncated_date = s.date
+ AND trc.repoid = s.repoid
+ AND trc.commit_rank = 1
+ ), grouped AS (
+ SELECT
+ spine_date,
+ truncated_commit_date,
+ totals,
+ repoid,
+ SUM(CASE
+ WHEN totals IS NOT NULL THEN 1 END
+ ) OVER (
+ PARTITION BY repoid
+ ORDER BY spine_date
+ ) AS grp_commit
+ FROM commits_spine
+ ), corrected AS (
+ SELECT
+ spine_date,
+ FIRST_VALUE(totals) OVER (
+ PARTITION BY repoid, grp_commit
+ ORDER BY spine_date
+ ) AS corrected_totals
+ FROM
+ grouped
+ ), parsed_totals AS (
+ SELECT
+ spine_date,
+ (CASE
+ WHEN corrected_totals IS NOT NULL then (corrected_totals->>'h')::numeric
+ WHEN corrected_totals IS NULL then 0 END
+ ) as hits,
+ (CASE
+ WHEN corrected_totals IS NOT NULL then (corrected_totals->>'m')::numeric
+ WHEN corrected_totals IS NULL then 0 END
+ ) as misses,
+ (CASE
+ WHEN corrected_totals IS NOT NULL then (corrected_totals->>'p')::numeric
+ WHEN corrected_totals IS NULL then 0 END
+ ) as partials,
+ (CASE
+ WHEN corrected_totals IS NOT NULL then (corrected_totals->>'n')::numeric
+ WHEN corrected_totals IS NULL then 0 END
+ ) as lines
+ FROM
+ corrected
+ ), summed_totals AS (
+ SELECT
+ spine_date::timestamp at time zone 'UTC' AS date,
+ SUM(hits) AS total_hits,
+ SUM(misses) AS total_misses,
+ SUM(partials) AS total_partials,
+ SUM(lines) AS total_lines,
+ ROUND((SUM(hits) + SUM(partials)) / SUM(lines) * 100, 2) AS coverage
+ FROM
+ parsed_totals
+ GROUP BY spine_date
+ ORDER BY spine_date {self.ordering}
+ )
+
+ SELECT
+ *
+ FROM summed_totals
+ WHERE date >= DATE_TRUNC('{self.grouping_unit}', timestamp '{self.start_date}');
+ """
+ )
+
+ return self._dictfetchall(cursor)
diff --git a/apps/codecov-api/api/internal/chart/urls.py b/apps/codecov-api/api/internal/chart/urls.py
new file mode 100644
index 0000000000..3091df9f29
--- /dev/null
+++ b/apps/codecov-api/api/internal/chart/urls.py
@@ -0,0 +1,16 @@
+from django.urls import re_path
+
+from .views import OrganizationChartHandler, RepositoryChartHandler
+
+urlpatterns = [
+ re_path(
+ r"^(?P\w+)/(?P[\w|-]+)/coverage/repository\/?$",
+ RepositoryChartHandler.as_view(),
+ name="chart-coverage-repository",
+ ),
+ re_path(
+ r"^(?P\w+)/(?P[\w|-]+)/coverage/organization\/?$",
+ OrganizationChartHandler.as_view(),
+ name="chart-coverage-organization",
+ ),
+]
diff --git a/apps/codecov-api/api/internal/chart/views.py b/apps/codecov-api/api/internal/chart/views.py
new file mode 100644
index 0000000000..152a9e3765
--- /dev/null
+++ b/apps/codecov-api/api/internal/chart/views.py
@@ -0,0 +1,199 @@
+from rest_framework.parsers import JSONParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from api.shared.mixins import RepositoriesMixin
+from api.shared.permissions import ChartPermissions
+from core.models import Commit
+from utils import round_decimals_down
+
+from .filters import apply_default_filters, apply_simple_filters
+from .helpers import (
+ ChartQueryRunner,
+ annotate_commits_with_totals,
+ apply_grouping,
+ validate_params,
+)
+
+
+class RepositoryChartHandler(APIView, RepositoriesMixin):
+ """
+ Returns data used to populate the repository-level coverage chart. See "validate_params" for documentation on accepted parameters.
+ Can either group and aggregate commits by a unit of time, or just return latest commits from the repo within the given time frame.
+ When aggregating by coverage, will also apply aggregation based on complexity ratio and return that.
+
+ Responses take the following format (semantics of the response depend on whether we're grouping by time or not):
+ {
+ "coverage": [
+ {
+ "date": "2019-06-01 00:00:00+00:00",
+ # grouping by time: NOT the commit timestamp, the date for this time window
+ # no grouping: when returning ungrouped commits: commit timestamp
+ "coverage":
+ # grouping by time: coverage from the commit retrieved (the one with min/max coverage) for this time unit
+ # no grouping: coverage from the commit
+ "commitid":
+ # grouping by time: id of the commit retrieved (the one with min/max coverage) for this time unit
+ # no grouping: id of the commit
+ },
+ {
+ "date": "2019-07-01 00:00:00+00:00",
+ "coverage":
+ "commitid":
+ ...
+ },
+ ...
+ ],
+ "complexity": [
+ {
+ "date": "2019-07-01 00:00:00+00:00",
+ "complexity_ratio":
+ "commitid":
+ },
+ {
+ "date": "2019-07-01 00:00:00+00:00",
+ ...
+ },
+ ...
+ ]
+ }
+ """
+
+ permission_classes = [ChartPermissions]
+ parser_classes = [JSONParser]
+
+ def post(self, request, *args, **kwargs):
+ request_params = {**self.request.data, **self.kwargs}
+ validate_params(request_params)
+ coverage_ordering = (
+ ""
+ if request_params.get("coverage_timestamp_order", "increasing")
+ == "increasing"
+ else "-"
+ )
+
+ # We don't use the "report" field in this endpoint and it can be many MBs of JSON choosing not to
+ # fetch it for perf reasons
+ queryset = apply_simple_filters(
+ apply_default_filters(Commit.objects.defer("_report").all()),
+ request_params,
+ self.request.current_owner,
+ )
+
+ annotated_queryset = annotate_commits_with_totals(queryset)
+
+ # if grouping_unit doesn't specify time, return all values
+ if self.request.data.get("grouping_unit") == "commit":
+ max_num_commits = 1000
+ commits = annotated_queryset.order_by(f"{coverage_ordering}timestamp")[
+ :max_num_commits
+ ]
+ coverage = [
+ {
+ "date": commits[index].timestamp,
+ "coverage": commits[index].coverage,
+ "coverage_change": commits[index].coverage
+ - commits[max(index - 1, 0)].coverage,
+ "commitid": commits[index].commitid,
+ }
+ for index in range(len(commits))
+ ]
+
+ complexity = [
+ {
+ "date": commit.timestamp,
+ "complexity_ratio": round_decimals_down(
+ commit.complexity_ratio * 100, 2
+ ),
+ "commitid": commit.commitid,
+ }
+ for commit in commits
+ if commit.complexity_ratio is not None
+ ]
+
+ else:
+ # Coverage
+ coverage_grouped_queryset = apply_grouping(
+ annotated_queryset, self.request.data
+ )
+
+ commits = coverage_grouped_queryset
+ coverage = [
+ {
+ "date": commits[index].truncated_date,
+ "coverage": commits[index].coverage,
+ "coverage_change": commits[index].coverage
+ - commits[max(index - 1, 0)].coverage,
+ "commitid": commits[index].commitid,
+ }
+ for index in range(len(commits))
+ ]
+
+ # Complexity
+ complexity_params = self.request.data.copy()
+ complexity_params["agg_value"] = "complexity_ratio"
+ complexity_grouped_queryset = apply_grouping(
+ annotated_queryset, complexity_params
+ )
+ complexity = [
+ {
+ "date": commit.truncated_date,
+ "complexity_ratio": round_decimals_down(
+ commit.complexity_ratio * 100, 2
+ ),
+ "commitid": commit.commitid,
+ }
+ for commit in complexity_grouped_queryset
+ if commit.complexity_ratio is not None
+ ]
+
+ return Response(data={"coverage": coverage, "complexity": complexity})
+
+
+class OrganizationChartHandler(APIView):
+ """
+ Returns array of datapoints retrieved by ChartQueryRunner.
+ Response data format is:
+ {
+ "coverage": [
+ {
+ "date": "2019-06-01 00:00:00+00:00",
+ "coverage": ,
+ "total_lines": ,
+ "total_hits": ,
+ "total_partials": ,
+ },
+ {
+ "date": "2019-07-01 00:00:00+00:00",
+ ...
+ },
+ ...
+ ]
+ }
+ """
+
+ permission_classes = [IsAuthenticated]
+ parser_classes = [JSONParser]
+
+ # this method is deprecated and will be removed
+ def post(self, request, *args, **kwargs):
+ query_runner = ChartQueryRunner(
+ user=request.current_owner, request_params={**kwargs, **request.data}
+ )
+ return Response(data={"coverage": query_runner.run_query()})
+
+ def get(self, request, *args, **kwargs):
+ # Get request params as a dict. We take special care to preserve
+ # the 'repositories' entry as a list, since the 'MultiValuedDict.dict'
+ # method clobbers list values
+ request_params_dict = request.query_params.dict()
+ if "repositories" in request.query_params:
+ request_params_dict.update(
+ {"repositories": request.query_params.getlist("repositories")}
+ )
+
+ query_runner = ChartQueryRunner(
+ user=request.current_owner, request_params={**kwargs, **request_params_dict}
+ )
+ return Response(data={"coverage": query_runner.run_query()})
diff --git a/apps/codecov-api/api/internal/commit/__init__.py b/apps/codecov-api/api/internal/commit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/commit/serializers.py b/apps/codecov-api/api/internal/commit/serializers.py
new file mode 100644
index 0000000000..58ace3903b
--- /dev/null
+++ b/apps/codecov-api/api/internal/commit/serializers.py
@@ -0,0 +1,69 @@
+import logging
+from typing import Dict, List
+
+import shared.reports.api_report_service as report_service
+from rest_framework import serializers
+from shared.reports.types import TOTALS_MAP
+
+from api.internal.owner.serializers import OwnerSerializer
+from api.shared.commit.serializers import CommitTotalsSerializer
+from core.models import Commit
+
+log = logging.getLogger(__name__)
+
+
+class CommitSerializer(serializers.ModelSerializer):
+ author = OwnerSerializer()
+ totals = CommitTotalsSerializer()
+
+ class Meta:
+ model = Commit
+ fields = (
+ "commitid",
+ "message",
+ "timestamp",
+ "ci_passed",
+ "author",
+ "branch",
+ "totals",
+ "state",
+ )
+
+
+class CommitWithFileLevelReportSerializer(CommitSerializer):
+ report = serializers.SerializerMethodField()
+
+ def get_report(self, commit: Commit) -> Dict[str, List[Dict] | Dict] | None:
+ report = report_service.build_report_from_commit(commit)
+ if report is None:
+ return None
+
+ files = []
+ for filename in report.files:
+ file_report = report.get(filename)
+ file_totals = CommitTotalsSerializer(
+ dict(zip(TOTALS_MAP, file_report.totals))
+ )
+ files.append(
+ {
+ "name": filename,
+ "totals": file_totals.data,
+ }
+ )
+
+ return {
+ "files": files,
+ "totals": CommitTotalsSerializer(commit.totals).data,
+ }
+
+ class Meta:
+ model = Commit
+ fields = (
+ "report",
+ "commitid",
+ "timestamp",
+ "ci_passed",
+ "repository",
+ "author",
+ "message",
+ )
diff --git a/apps/codecov-api/api/internal/commit/views.py b/apps/codecov-api/api/internal/commit/views.py
new file mode 100644
index 0000000000..19cc2d2e3b
--- /dev/null
+++ b/apps/codecov-api/api/internal/commit/views.py
@@ -0,0 +1,9 @@
+from rest_framework import mixins
+
+from api.shared.commit.mixins import CommitsViewSetMixin
+
+from .serializers import CommitSerializer
+
+
+class CommitsViewSet(CommitsViewSetMixin, mixins.ListModelMixin):
+ serializer_class = CommitSerializer
diff --git a/apps/codecov-api/api/internal/compare/__init__.py b/apps/codecov-api/api/internal/compare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/compare/serializers.py b/apps/codecov-api/api/internal/compare/serializers.py
new file mode 100644
index 0000000000..6979be4568
--- /dev/null
+++ b/apps/codecov-api/api/internal/compare/serializers.py
@@ -0,0 +1,8 @@
+from api.internal.commit.serializers import CommitSerializer
+from api.shared.compare.serializers import (
+ ComparisonSerializer as BaseComparisonSerializer,
+)
+
+
+class ComparisonSerializer(BaseComparisonSerializer):
+ commit_uploads = CommitSerializer(many=True, source="upload_commits")
diff --git a/apps/codecov-api/api/internal/compare/views.py b/apps/codecov-api/api/internal/compare/views.py
new file mode 100644
index 0000000000..1a61a3a92f
--- /dev/null
+++ b/apps/codecov-api/api/internal/compare/views.py
@@ -0,0 +1,12 @@
+from rest_framework import mixins
+
+from api.shared.compare.mixins import CompareViewSetMixin
+
+from .serializers import ComparisonSerializer
+
+
+class CompareViewSet(
+ CompareViewSetMixin,
+ mixins.RetrieveModelMixin,
+):
+ serializer_class = ComparisonSerializer
diff --git a/apps/codecov-api/api/internal/constants.py b/apps/codecov-api/api/internal/constants.py
new file mode 100644
index 0000000000..5944951dfb
--- /dev/null
+++ b/apps/codecov-api/api/internal/constants.py
@@ -0,0 +1 @@
+INTERNAL_API_PREFIX = "internal/"
diff --git a/apps/codecov-api/api/internal/coverage/__init__.py b/apps/codecov-api/api/internal/coverage/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/coverage/views.py b/apps/codecov-api/api/internal/coverage/views.py
new file mode 100644
index 0000000000..66998f9337
--- /dev/null
+++ b/apps/codecov-api/api/internal/coverage/views.py
@@ -0,0 +1,69 @@
+from typing import Any
+
+from django.http import HttpRequest
+from rest_framework import viewsets
+from rest_framework.decorators import action
+from rest_framework.exceptions import NotFound
+from rest_framework.response import Response
+
+import services.components as components_service
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from api.shared.report.serializers import TreeSerializer
+from services.path import ReportPaths
+
+
+class CoverageViewSet(viewsets.ViewSet, RepoPropertyMixin):
+ permission_classes = [RepositoryArtifactPermissions]
+
+ def get_object(self) -> ReportPaths:
+ commit_sha = self.request.query_params.get("sha")
+ if not commit_sha:
+ branch_name = self.request.query_params.get("branch", self.repo.branch)
+ branch = self.repo.branches.filter(name=branch_name).first()
+ if branch is None:
+ raise NotFound(
+ f"The branch '{branch_name}' is not in our records. Please provide a valid branch name.",
+ 404,
+ )
+ commit_sha = branch.head
+
+ commit = self.repo.commits.filter(commitid=commit_sha).first()
+ if commit is None:
+ raise NotFound(
+ f"The commit {commit_sha} is not in our records. Please specify a valid commit.",
+ 404,
+ )
+
+ report = commit.full_report
+ if report is None:
+ raise NotFound(f"Coverage report for {commit_sha} not found")
+
+ components = self.request.query_params.getlist("components")
+ component_paths = []
+ if components:
+ all_components = components_service.commit_components(commit, self.owner)
+ filtered_components = components_service.filter_components_by_name_or_id(
+ all_components, components
+ )
+
+ if not filtered_components:
+ raise NotFound(
+ f"Coverage report for components {filtered_components} not found"
+ )
+
+ for component in filtered_components:
+ component_paths.extend(component.paths)
+ flags = self.request.query_params.getlist("flags")
+
+ paths = ReportPaths(
+ report=report, filter_flags=flags, filter_paths=component_paths
+ )
+
+ return paths
+
+ @action(detail=False, methods=["get"], url_path="tree")
+ def tree(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ paths = self.get_object()
+ serializer = TreeSerializer(paths.single_directory(), many=True)
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/internal/enterprise_urls.py b/apps/codecov-api/api/internal/enterprise_urls.py
new file mode 100644
index 0000000000..cd8448b546
--- /dev/null
+++ b/apps/codecov-api/api/internal/enterprise_urls.py
@@ -0,0 +1,12 @@
+from django.urls import include, path
+
+from api.internal.self_hosted.views import UserViewSet
+from utils.routers import OptionalTrailingSlashRouter
+
+self_hosted_router = OptionalTrailingSlashRouter()
+self_hosted_router.register(r"users", UserViewSet, basename="selfhosted-users")
+
+urlpatterns = [
+ path("license/", include("api.internal.license.urls")),
+ path("", include(self_hosted_router.urls)),
+]
diff --git a/apps/codecov-api/api/internal/feature/helpers.py b/apps/codecov-api/api/internal/feature/helpers.py
new file mode 100644
index 0000000000..1a8700d98c
--- /dev/null
+++ b/apps/codecov-api/api/internal/feature/helpers.py
@@ -0,0 +1,23 @@
+from shared.django_apps.rollouts.models import FeatureFlag, RolloutUniverse
+
+FEATURES_CACHE_REDIS_KEY = "features_endpoint_cache"
+
+
+def get_flag_cache_redis_key(flag_name):
+ return FEATURES_CACHE_REDIS_KEY + ":" + flag_name
+
+
+def get_identifier(feature_flag: FeatureFlag, identifier_data):
+ """
+ Returns the appropriate identifier string based on the rollout identifier type.
+ """
+ if feature_flag.rollout_universe == RolloutUniverse.OWNER_ID:
+ return identifier_data["user_id"]
+ elif feature_flag.rollout_universe == RolloutUniverse.REPO_ID:
+ return identifier_data["repo_id"]
+ elif feature_flag.rollout_universe == RolloutUniverse.EMAIL:
+ return identifier_data["email"]
+ elif feature_flag.rollout_universe == RolloutUniverse.ORG_ID:
+ return identifier_data["org_id"]
+ else:
+ raise ValueError("Unknown RolloutUniverse type")
diff --git a/apps/codecov-api/api/internal/feature/serializers.py b/apps/codecov-api/api/internal/feature/serializers.py
new file mode 100644
index 0000000000..86426aa95f
--- /dev/null
+++ b/apps/codecov-api/api/internal/feature/serializers.py
@@ -0,0 +1,15 @@
+from rest_framework import serializers
+
+
+class FeatureIdentifierDataSerializer(serializers.Serializer):
+ email = serializers.CharField(max_length=200, allow_blank=True)
+ user_id = serializers.IntegerField()
+ repo_id = serializers.IntegerField()
+ org_id = serializers.IntegerField()
+
+
+class FeatureRequestSerializer(serializers.Serializer):
+ feature_flags = serializers.ListField(
+ child=serializers.CharField(max_length=200), allow_empty=True
+ )
+ identifier_data = FeatureIdentifierDataSerializer()
diff --git a/apps/codecov-api/api/internal/feature/views.py b/apps/codecov-api/api/internal/feature/views.py
new file mode 100644
index 0000000000..d02860e6b2
--- /dev/null
+++ b/apps/codecov-api/api/internal/feature/views.py
@@ -0,0 +1,104 @@
+import logging
+import pickle
+from typing import Any, Dict, List
+
+from rest_framework import status
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.django_apps.rollouts.models import FeatureFlag
+from shared.helpers.redis import get_redis_connection
+from shared.rollouts import Feature
+
+from api.internal.feature.helpers import get_flag_cache_redis_key, get_identifier
+from utils.config import get_config
+
+from .serializers import FeatureRequestSerializer
+
+log = logging.getLogger(__name__)
+
+
+class FeaturesView(APIView):
+ skip_feature_cache = get_config("setup", "skip_feature_cache", default=False)
+ timeout = 300
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ self.redis = get_redis_connection()
+ super().__init__(*args, **kwargs)
+
+ def get_many_from_redis(self, keys: List) -> Dict[str, Any]:
+ ret = self.redis.mget(keys)
+ return {k: pickle.loads(v) for k, v in zip(keys, ret) if v is not None}
+
+ def set_many_to_redis(self, data: Dict[str, Any]) -> None:
+ pipeline = self.redis.pipeline()
+ pipeline.mset({k: pickle.dumps(v) for k, v in data.items()})
+
+ # Setting timeout for each key as redis does not support timeout
+ # with mset().
+ for key in data:
+ pipeline.expire(key, self.timeout)
+ pipeline.execute()
+
+ def post(self, request: Request) -> Response:
+ serializer = FeatureRequestSerializer(data=request.data)
+ if serializer.is_valid():
+ flag_evaluations = {}
+ identifier_data = serializer.validated_data["identifier_data"]
+ feature_flag_names = serializer.validated_data["feature_flags"]
+
+ feature_flag_cache_keys = [
+ get_flag_cache_redis_key(flag_name) for flag_name in feature_flag_names
+ ]
+ cache_misses = []
+
+ if not self.skip_feature_cache:
+ # fetch flags from cache
+ cached_flags = self.get_many_from_redis(feature_flag_cache_keys)
+
+ for ind in range(len(feature_flag_cache_keys)):
+ cache_key = feature_flag_cache_keys[ind]
+ flag_name = feature_flag_names[ind]
+
+ # if flag is in cache, make the evaluation. Otherwise, we'll
+ # fetch the flag from DB later
+ if cache_key in cached_flags:
+ feature_flag = cached_flags[cache_key]
+ identifier = get_identifier(feature_flag, identifier_data)
+
+ flag_evaluations[flag_name] = Feature(
+ flag_name, feature_flag, list(feature_flag.variants.all())
+ ).check_value_no_fetch(identifier=identifier)
+ else:
+ cache_misses.append(flag_name)
+ else:
+ cache_misses = feature_flag_names
+ log.warning(
+ "skip_feature_cache for Feature should only be turned on in development environments, and should not be used in production"
+ )
+
+ flags_to_add_to_cache = {}
+
+ # fetch flags not in cache
+ missed_feature_flags = FeatureFlag.objects.filter(
+ name__in=cache_misses
+ ).prefetch_related("variants") # include the feature flag variants aswell
+
+ # evaluate the remaining flags
+ for feature_flag in missed_feature_flags:
+ identifier = get_identifier(feature_flag, identifier_data)
+
+ flag_evaluations[feature_flag.name] = Feature(
+ feature_flag.name, feature_flag, list(feature_flag.variants.all())
+ ).check_value_no_fetch(identifier=identifier)
+ flags_to_add_to_cache[get_flag_cache_redis_key(feature_flag.name)] = (
+ feature_flag
+ )
+
+ # add the new flags to cache
+ if len(flags_to_add_to_cache) >= 1:
+ self.set_many_to_redis(flags_to_add_to_cache)
+
+ return Response(flag_evaluations, status=status.HTTP_200_OK)
+ else:
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/apps/codecov-api/api/internal/license/__init__.py b/apps/codecov-api/api/internal/license/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/license/serializers.py b/apps/codecov-api/api/internal/license/serializers.py
new file mode 100644
index 0000000000..cf36d1846c
--- /dev/null
+++ b/apps/codecov-api/api/internal/license/serializers.py
@@ -0,0 +1,10 @@
+from rest_framework import serializers
+
+
+class LicenseSerializer(serializers.Serializer):
+ trial = serializers.BooleanField(source="is_trial")
+ url = serializers.CharField()
+ users = serializers.IntegerField(source="number_allowed_users")
+ repos = serializers.IntegerField(source="number_allowed_repos")
+ expires_at = serializers.DateTimeField(source="expires")
+ pr_billing = serializers.BooleanField(source="is_pr_billing")
diff --git a/apps/codecov-api/api/internal/license/urls.py b/apps/codecov-api/api/internal/license/urls.py
new file mode 100644
index 0000000000..12b8656e0a
--- /dev/null
+++ b/apps/codecov-api/api/internal/license/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from .views import LicenseView
+
+urlpatterns = [
+ path("", LicenseView.as_view(), name="license"),
+]
diff --git a/apps/codecov-api/api/internal/license/views.py b/apps/codecov-api/api/internal/license/views.py
new file mode 100644
index 0000000000..4a04514889
--- /dev/null
+++ b/apps/codecov-api/api/internal/license/views.py
@@ -0,0 +1,16 @@
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.license import get_current_license
+
+from .serializers import LicenseSerializer
+
+
+class LicenseView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, format=None):
+ license = get_current_license()
+ serializer = LicenseSerializer(license)
+
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/internal/owner/__init__.py b/apps/codecov-api/api/internal/owner/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/owner/serializers.py b/apps/codecov-api/api/internal/owner/serializers.py
new file mode 100644
index 0000000000..32bf23d175
--- /dev/null
+++ b/apps/codecov-api/api/internal/owner/serializers.py
@@ -0,0 +1,407 @@
+import logging
+from datetime import datetime
+from typing import Any, Dict
+
+from dateutil.relativedelta import relativedelta
+from rest_framework import serializers
+from rest_framework.exceptions import PermissionDenied
+from shared.plan.constants import (
+ TEAM_PLAN_MAX_USERS,
+ TierName,
+)
+from shared.plan.service import PlanService
+
+from codecov_auth.models import Owner, Plan
+from services.billing import BillingService
+from services.sentry import send_user_webhook as send_sentry_webhook
+
+log = logging.getLogger(__name__)
+
+
+class OwnerSerializer(serializers.ModelSerializer):
+ stats = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Owner
+ fields = (
+ "avatar_url",
+ "service",
+ "username",
+ "name",
+ "stats",
+ "ownerid",
+ "integration_id",
+ )
+
+ read_only_fields = fields
+
+ def get_stats(self, obj: Owner) -> str | None:
+ if obj.cache and "stats" in obj.cache:
+ return obj.cache["stats"]
+
+
+class StripeLineItemSerializer(serializers.Serializer):
+ description = serializers.CharField()
+ amount = serializers.FloatField()
+ currency = serializers.CharField()
+ period = serializers.JSONField()
+ plan_name = serializers.SerializerMethodField()
+ quantity = serializers.IntegerField()
+
+ def get_plan_name(self, line_item: Dict[str, str]) -> str | None:
+ plan = line_item.get("plan")
+ if plan:
+ return plan.get("name")
+
+
+class StripeInvoiceSerializer(serializers.Serializer):
+ id = serializers.CharField()
+ number = serializers.CharField()
+ status = serializers.CharField()
+ created = serializers.IntegerField()
+ period_start = serializers.IntegerField()
+ period_end = serializers.IntegerField()
+ due_date = serializers.IntegerField()
+ customer_name = serializers.CharField()
+ customer_address = serializers.CharField()
+ currency = serializers.CharField()
+ amount_paid = serializers.FloatField()
+ amount_due = serializers.FloatField()
+ amount_remaining = serializers.FloatField()
+ total = serializers.FloatField()
+ subtotal = serializers.FloatField()
+ invoice_pdf = serializers.CharField()
+ line_items = StripeLineItemSerializer(many=True, source="lines.data")
+ footer = serializers.CharField()
+ customer_email = serializers.CharField()
+ customer_shipping = serializers.CharField()
+
+
+class StripeDiscountSerializer(serializers.Serializer):
+ name = serializers.CharField(source="coupon.name")
+ percent_off = serializers.FloatField(source="coupon.percent_off")
+ duration_in_months = serializers.IntegerField(source="coupon.duration_in_months")
+ expires = serializers.SerializerMethodField()
+
+ def get_expires(self, customer: Dict[str, Dict]) -> int | None:
+ coupon = customer.get("coupon")
+ if coupon:
+ months = coupon.get("duration_in_months")
+ created = coupon.get("created")
+ if months and created:
+ expires = datetime.fromtimestamp(created) + relativedelta(months=months)
+ return int(expires.timestamp())
+
+
+class StripeCustomerSerializer(serializers.Serializer):
+ id = serializers.CharField()
+ discount = StripeDiscountSerializer()
+ email = serializers.CharField()
+
+
+class StripeCardSerializer(serializers.Serializer):
+ brand = serializers.CharField()
+ exp_month = serializers.IntegerField()
+ exp_year = serializers.IntegerField()
+ last4 = serializers.CharField()
+
+
+class StripeUSBankAccountSerializer(serializers.Serializer):
+ bank_name = serializers.CharField()
+ last4 = serializers.CharField()
+
+
+class StripePaymentMethodSerializer(serializers.Serializer):
+ card = StripeCardSerializer(read_only=True)
+ us_bank_account = StripeUSBankAccountSerializer(read_only=True)
+ billing_details = serializers.JSONField(read_only=True)
+
+
+class PlanSerializer(serializers.Serializer):
+ marketing_name = serializers.CharField(read_only=True)
+ value = serializers.CharField()
+ billing_rate = serializers.CharField(read_only=True)
+ base_unit_price = serializers.IntegerField(read_only=True)
+ benefits = serializers.JSONField(read_only=True)
+ quantity = serializers.IntegerField(required=False)
+
+ def validate_value(self, value: str) -> str:
+ current_org = self.context["view"].owner
+ current_owner = self.context["request"].current_owner
+
+ plan_service = PlanService(current_org=current_org)
+ plan_values = [
+ plan.name for plan in plan_service.available_plans(current_owner)
+ ]
+ if value not in plan_values:
+ raise serializers.ValidationError(
+ f"Invalid value for plan: {value}; must be one of {plan_values}"
+ )
+ return value
+
+ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]:
+ current_org = self.context["view"].owner
+ if current_org.account:
+ raise serializers.ValidationError(
+ detail="You cannot update your plan manually, for help or changes to plan, connect with sales@codecov.io"
+ )
+
+ active_plans = Plan.objects.select_related("tier").filter(
+ paid_plan=True, is_active=True
+ )
+
+ active_plan_names = set(active_plans.values_list("name", flat=True))
+ team_tier_plans = active_plans.filter(
+ tier__tier_name=TierName.TEAM.value
+ ).values_list("name", flat=True)
+
+ # Validate quantity here because we need access to whole plan object
+ if plan["value"] in active_plan_names:
+ if "quantity" not in plan:
+ raise serializers.ValidationError(
+ "Field 'quantity' required for updating to paid plans"
+ )
+ if plan["quantity"] <= 1:
+ raise serializers.ValidationError(
+ "Quantity for paid plan must be greater than 1"
+ )
+
+ plan_service = PlanService(current_org=current_org)
+ is_org_trialing = plan_service.is_org_trialing
+
+ if (
+ plan["quantity"] < current_org.activated_user_count
+ and not is_org_trialing
+ ):
+ raise serializers.ValidationError(
+ "Quantity cannot be lower than currently activated user count"
+ )
+ if (
+ plan["quantity"] == current_org.plan_user_count
+ and plan["value"] == current_org.plan
+ and not is_org_trialing
+ ):
+ raise serializers.ValidationError(
+ "Quantity or plan for paid plan must be different from the existing one"
+ )
+ if (
+ plan["value"] in team_tier_plans
+ and plan["quantity"] > TEAM_PLAN_MAX_USERS
+ ):
+ raise serializers.ValidationError(
+ f"Quantity for Team plan cannot exceed {TEAM_PLAN_MAX_USERS}"
+ )
+ return plan
+
+
+class SubscriptionDetailSerializer(serializers.Serializer):
+ latest_invoice = StripeInvoiceSerializer()
+ default_payment_method = StripePaymentMethodSerializer(
+ source="customer.invoice_settings.default_payment_method"
+ )
+ cancel_at_period_end = serializers.BooleanField()
+ current_period_end = serializers.IntegerField()
+ customer = StripeCustomerSerializer()
+ collection_method = serializers.CharField()
+ tax_ids = serializers.ListField(
+ source="customer.tax_ids.data", read_only=True, allow_null=True
+ )
+ trial_end = serializers.IntegerField()
+
+
+class StripeScheduledPhaseSerializer(serializers.Serializer):
+ start_date = serializers.IntegerField()
+ plan = serializers.SerializerMethodField()
+ quantity = serializers.SerializerMethodField()
+
+ def get_plan(self, phase: Dict[str, Any]) -> str:
+ plan_id = phase["items"][0]["plan"]
+ marketing_plan_name = Plan.objects.get(stripe_id=plan_id).marketing_name
+ return marketing_plan_name
+
+ def get_quantity(self, phase: Dict[str, Any]) -> int:
+ return phase["items"][0]["quantity"]
+
+
+class ScheduleDetailSerializer(serializers.Serializer):
+ id = serializers.CharField()
+ scheduled_phase = serializers.SerializerMethodField()
+
+ def get_scheduled_phase(self, schedule: Dict[str, Any]) -> Dict[str, Any] | None:
+ if len(schedule["phases"]) > 1:
+ return StripeScheduledPhaseSerializer(schedule["phases"][-1]).data
+ else:
+ # This error represents the phases object not having 2 phases; we are interested in the 2nd entry within phases
+ # since it represents the scheduled phase.
+ # It should not be possible for a schedule to have one phase, but we have seen certain cases where this is true
+ # after manual intervention on a subscription.
+ log.error(
+ "Expecting schedule object to have 2 phases, returning None",
+ extra=dict(
+ ownerid=schedule.metadata.get("obo_organization"),
+ requesting_user_id=schedule.metadata.get("obo"),
+ phases=schedule.get("phases", "no phases"),
+ ),
+ )
+ return None
+
+
+class RootOrganizationSerializer(serializers.Serializer):
+ """
+ Minimalist serializer to expose the root organization of a sub group
+ so we can expose the minimal data required for the UI while hiding data
+ that might only be for admin (invoice, billing data, etc)
+ """
+
+ username = serializers.CharField()
+ plan = PlanSerializer(source="pretty_plan")
+
+
+class AccountDetailsSerializer(serializers.ModelSerializer):
+ plan = PlanSerializer(source="pretty_plan")
+ checkout_session_id = serializers.SerializerMethodField()
+ subscription_detail = serializers.SerializerMethodField()
+ root_organization = RootOrganizationSerializer()
+ schedule_detail = serializers.SerializerMethodField()
+ apply_cancellation_discount = serializers.BooleanField(write_only=True)
+ activated_student_count = serializers.SerializerMethodField()
+ activated_user_count = serializers.SerializerMethodField()
+ delinquent = serializers.SerializerMethodField()
+ uses_invoice = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Owner
+
+ read_only_fields = ("integration_id",)
+
+ fields = read_only_fields + (
+ "activated_student_count",
+ "activated_user_count",
+ "apply_cancellation_discount",
+ "checkout_session_id",
+ "delinquent",
+ "email",
+ "inactive_user_count",
+ "name",
+ "nb_active_private_repos",
+ "plan_auto_activate",
+ "plan_provider",
+ "plan",
+ "repo_total_credits",
+ "root_organization",
+ "schedule_detail",
+ "student_count",
+ "subscription_detail",
+ "uses_invoice",
+ )
+
+ def _get_billing(self) -> BillingService:
+ current_owner = self.context["request"].current_owner
+ return BillingService(requesting_user=current_owner)
+
+ def get_subscription_detail(self, owner: Owner) -> Dict[str, Any] | None:
+ subscription_detail = self._get_billing().get_subscription(owner)
+ if subscription_detail:
+ return SubscriptionDetailSerializer(subscription_detail).data
+
+ def get_schedule_detail(self, owner: Owner) -> Dict[str, Any] | None:
+ schedule_detail = self._get_billing().get_schedule(owner)
+ if schedule_detail:
+ return ScheduleDetailSerializer(schedule_detail).data
+
+ def get_checkout_session_id(self, _: Any) -> str:
+ return self.context.get("checkout_session_id")
+
+ def get_activated_student_count(self, owner: Owner) -> int:
+ if owner.account:
+ return owner.account.activated_student_count
+ return owner.activated_student_count
+
+ def get_activated_user_count(self, owner: Owner) -> int:
+ if owner.account:
+ return owner.account.activated_user_count
+ return owner.activated_user_count
+
+ def get_delinquent(self, owner: Owner) -> bool:
+ if owner.account:
+ return owner.account.is_delinquent
+ return owner.delinquent
+
+ def get_uses_invoice(self, owner: Owner) -> bool:
+ if owner.account:
+ return owner.account.invoice_billing.filter(is_active=True).exists()
+ return owner.uses_invoice
+
+ def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object:
+ if "pretty_plan" in validated_data:
+ desired_plan = validated_data.pop("pretty_plan")
+ checkout_session_id_or_none = self._get_billing().update_plan(
+ instance, desired_plan
+ )
+
+ plan = (
+ Plan.objects.select_related("tier")
+ .filter(name=desired_plan["value"])
+ .first()
+ )
+
+ if plan and plan.tier.tier_name == TierName.SENTRY.value:
+ current_owner = self.context["view"].request.current_owner
+ send_sentry_webhook(current_owner, instance)
+
+ if checkout_session_id_or_none is not None:
+ self.context["checkout_session_id"] = checkout_session_id_or_none
+
+ if validated_data.get("apply_cancellation_discount") is True:
+ self._get_billing().apply_cancellation_discount(instance)
+
+ super().update(instance, validated_data)
+ return self.context["view"].get_object()
+
+
+class UserSerializer(serializers.ModelSerializer):
+ activated = serializers.BooleanField()
+ is_admin = serializers.BooleanField()
+ last_pull_timestamp = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Owner
+ fields = (
+ "activated",
+ "is_admin",
+ "username",
+ "email",
+ "ownerid",
+ "student",
+ "name",
+ "last_pull_timestamp",
+ )
+
+ def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object:
+ owner = self.context["view"].owner
+
+ if "activated" in validated_data:
+ if validated_data["activated"] is True and owner.can_activate_user(
+ instance
+ ):
+ owner.activate_user(instance)
+ elif validated_data["activated"] is False:
+ owner.deactivate_user(instance)
+ else:
+ raise PermissionDenied(
+ f"Cannot activate user {instance.username} -- not enough seats left."
+ )
+
+ if "is_admin" in validated_data:
+ if validated_data["is_admin"]:
+ owner.add_admin(instance)
+ else:
+ owner.remove_admin(instance)
+
+ # Re-fetch from DB to set activated and admin fields
+ return self.context["view"].get_object()
+
+ def get_last_pull_timestamp(self, obj: Owner) -> str | None:
+ # this field comes from an annotation that may not always be applied to the queryset
+ if hasattr(obj, "last_pull_timestamp"):
+ return obj.last_pull_timestamp
diff --git a/apps/codecov-api/api/internal/owner/views.py b/apps/codecov-api/api/internal/owner/views.py
new file mode 100644
index 0000000000..a367f6882f
--- /dev/null
+++ b/apps/codecov-api/api/internal/owner/views.py
@@ -0,0 +1,198 @@
+import logging
+
+from django.db.models import F
+from django_filters import rest_framework as django_filters
+from rest_framework import filters, mixins, status, viewsets
+from rest_framework.decorators import action
+from rest_framework.exceptions import PermissionDenied, ValidationError
+from rest_framework.response import Response
+from shared.django_apps.codecov_auth.models import Owner
+from shared.plan.constants import DEFAULT_FREE_PLAN
+
+from api.shared.mixins import OwnerPropertyMixin
+from api.shared.owner.mixins import OwnerViewSetMixin, UserViewSetMixin
+from api.shared.permissions import MemberOfOrgPermissions
+from billing.helpers import on_enterprise_plan
+from services.billing import BillingService
+from services.decorators import stripe_safe
+from services.task import TaskService
+
+from .serializers import (
+ AccountDetailsSerializer,
+ OwnerSerializer,
+ UserSerializer,
+)
+
+log = logging.getLogger(__name__)
+
+
+class OwnerViewSet(OwnerViewSetMixin, mixins.RetrieveModelMixin):
+ serializer_class = OwnerSerializer
+
+
+class AccountDetailsViewSet(
+ viewsets.GenericViewSet,
+ mixins.UpdateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.DestroyModelMixin,
+ OwnerPropertyMixin,
+):
+ serializer_class = AccountDetailsSerializer
+ permission_classes = [MemberOfOrgPermissions]
+
+ @stripe_safe
+ def retrieve(self, *args, **kwargs):
+ res = super().retrieve(*args, **kwargs)
+ return res
+
+ @stripe_safe
+ def update(self, request, *args, **kwargs):
+ # Temporary fix. Remove once Gazebo uses the new free plan
+ plan_value = request.data.get("plan", {}).get("value")
+ if plan_value == "users-basic":
+ request.data["plan"]["value"] = DEFAULT_FREE_PLAN
+
+ return super().update(request, *args, **kwargs)
+
+ def destroy(self, request, *args, **kwargs):
+ if self.owner.ownerid != request.current_owner.ownerid:
+ raise PermissionDenied("You can only delete your own account")
+
+ TaskService().delete_owner(self.owner.ownerid)
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ def get_object(self):
+ if self.owner.account:
+ # gets the related account and invoice_billing objects from db in 1 query
+ # otherwise, each reference to owner.account would be an additional query
+ self.owner = (
+ Owner.objects.filter(pk=self.owner.ownerid)
+ .select_related("account__invoice_billing")
+ .first()
+ )
+ return self.owner
+
+ @action(detail=False, methods=["patch"])
+ @stripe_safe
+ def update_payment(self, request, *args, **kwargs):
+ payment_method = request.data.get("payment_method")
+ if not payment_method:
+ raise ValidationError(detail="No payment_method sent")
+ owner = self.get_object()
+ billing = BillingService(requesting_user=request.current_owner)
+ billing.update_payment_method(owner, payment_method)
+ return Response(self.get_serializer(owner).data)
+
+ @action(detail=False, methods=["patch"])
+ @stripe_safe
+ def update_email(self, request, *args, **kwargs):
+ """
+ Update the email address associated with the owner's billing account.
+
+ Args:
+ request: The HTTP request object containing:
+ - new_email: The new email address to update to
+ - apply_to_default_payment_method: Boolean flag to update email on the default payment method (default False)
+
+ Returns:
+ Response with serialized owner data
+
+ Raises:
+ ValidationError: If no new_email is provided in the request
+ """
+ new_email = request.data.get("new_email")
+ if not new_email:
+ raise ValidationError(detail="No new_email sent")
+ owner = self.get_object()
+ billing = BillingService(requesting_user=request.current_owner)
+ apply_to_default_payment_method = request.data.get(
+ "apply_to_default_payment_method", False
+ )
+ billing.update_email_address(
+ owner,
+ new_email,
+ apply_to_default_payment_method=apply_to_default_payment_method,
+ )
+ return Response(self.get_serializer(owner).data)
+
+ @action(detail=False, methods=["patch"])
+ @stripe_safe
+ def update_billing_address(self, request, *args, **kwargs):
+ name = request.data.get("name")
+ if not name:
+ raise ValidationError(detail="No name sent")
+ billing_address = request.data.get("billing_address")
+ if not billing_address:
+ raise ValidationError(detail="No billing_address sent")
+ owner = self.get_object()
+
+ formatted_address = {
+ "line1": billing_address["line_1"],
+ "line2": billing_address["line_2"],
+ "city": billing_address["city"],
+ "state": billing_address["state"],
+ "postal_code": billing_address["postal_code"],
+ "country": billing_address["country"],
+ }
+
+ billing = BillingService(requesting_user=request.current_owner)
+ billing.update_billing_address(owner, name, billing_address=formatted_address)
+ return Response(self.get_serializer(owner).data)
+
+
+class UsersOrderingFilter(filters.OrderingFilter):
+ def get_valid_fields(self, queryset, view, context=None):
+ fields = super().get_valid_fields(queryset, view, context=context or {})
+
+ if "last_pull_timestamp" not in queryset.query.annotations:
+ # queryset not always annotated with `last_pull_timestamp`
+ fields = [
+ (name, verbose_name)
+ for (name, verbose_name) in fields
+ if name != "last_pull_timestamp"
+ ]
+
+ return fields
+
+ def filter_queryset(self, request, queryset, view):
+ ordering = self.get_ordering(request, queryset, view)
+
+ if ordering:
+ ordering = [self._order_expression(order) for order in ordering]
+ ordering += ["ownerid"] # secondary sort column makes this deterministic
+ return queryset.order_by(*ordering)
+
+ return queryset
+
+ def _order_expression(self, order):
+ """
+ Special cases for `last_pull_timestamp`:
+ - nulls first when ascending
+ - nulls last when descending
+ """
+ if order == "last_pull_timestamp":
+ return F("last_pull_timestamp").asc(nulls_first=True)
+ elif order == "-last_pull_timestamp":
+ return F("last_pull_timestamp").desc(nulls_last=True)
+ else:
+ return order
+
+
+class UserViewSet(
+ UserViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.UpdateModelMixin,
+):
+ serializer_class = UserSerializer
+ filter_backends = (
+ django_filters.DjangoFilterBackend,
+ UsersOrderingFilter,
+ filters.SearchFilter,
+ )
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ if on_enterprise_plan(self.owner):
+ # pull ordering only available for enterprise
+ qs = qs.annotate_last_pull_timestamp()
+ return qs
diff --git a/apps/codecov-api/api/internal/pull/__init__.py b/apps/codecov-api/api/internal/pull/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/pull/serializers.py b/apps/codecov-api/api/internal/pull/serializers.py
new file mode 100644
index 0000000000..55aff3f68a
--- /dev/null
+++ b/apps/codecov-api/api/internal/pull/serializers.py
@@ -0,0 +1,33 @@
+from rest_framework import serializers
+
+from api.internal.owner.serializers import OwnerSerializer
+from api.shared.commit.serializers import CommitTotalsSerializer
+from core.models import Pull
+
+
+class PullSerializer(serializers.ModelSerializer):
+ most_recent_commiter = serializers.CharField()
+ base_totals = CommitTotalsSerializer()
+ head_totals = CommitTotalsSerializer()
+ ci_passed = serializers.BooleanField()
+
+ class Meta:
+ model = Pull
+ fields = (
+ "pullid",
+ "title",
+ "most_recent_commiter",
+ "base_totals",
+ "head_totals",
+ "updatestamp",
+ "state",
+ "ci_passed",
+ )
+
+
+class PullDetailSerializer(PullSerializer):
+ author = OwnerSerializer()
+
+ class Meta:
+ model = Pull
+ fields = PullSerializer.Meta.fields + ("author",)
diff --git a/apps/codecov-api/api/internal/pull/views.py b/apps/codecov-api/api/internal/pull/views.py
new file mode 100644
index 0000000000..c3520ee546
--- /dev/null
+++ b/apps/codecov-api/api/internal/pull/views.py
@@ -0,0 +1,32 @@
+from django.db.models import OuterRef, Subquery
+from rest_framework import mixins
+
+from api.shared.pull.mixins import PullViewSetMixin
+from core.models import Commit
+
+from .serializers import PullDetailSerializer, PullSerializer
+
+
+class PullViewSet(
+ PullViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+):
+ def get_serializer_class(self):
+ if self.action == "retrieve":
+ return PullDetailSerializer
+ elif self.action == "list":
+ return PullSerializer
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .annotate(
+ most_recent_commiter=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"), repository=OuterRef("repository")
+ ).values("author__username")[:1]
+ ),
+ )
+ )
diff --git a/apps/codecov-api/api/internal/repo/__init__.py b/apps/codecov-api/api/internal/repo/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/repo/filter.py b/apps/codecov-api/api/internal/repo/filter.py
new file mode 100644
index 0000000000..6d844d90c7
--- /dev/null
+++ b/apps/codecov-api/api/internal/repo/filter.py
@@ -0,0 +1,68 @@
+from django.db.models import FloatField
+from django.db.models.fields.json import KeyTextTransform
+from django.db.models.functions import Cast
+from rest_framework import filters
+
+
+class RepositoryOrderingFilter(filters.OrderingFilter):
+ """
+ Ordering filter that lazy-loads data into queryset
+ when filtering on coverage metrics. This delays expensive queries
+ so that they only slow down requests that require them.
+ """
+
+ def _order_by_totals_field(self, ordering_field, queryset):
+ if ordering_field in ["coverage", "-coverage"]:
+ annotation_args = dict(
+ coverage=Cast(
+ KeyTextTransform("c", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ elif ordering_field in ["lines", "-lines"]:
+ annotation_args = dict(
+ lines=Cast(
+ KeyTextTransform("n", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ elif ordering_field in ["hits", "-hits"]:
+ annotation_args = dict(
+ hits=Cast(
+ KeyTextTransform("h", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ elif ordering_field in ["partials", "-partials"]:
+ annotation_args = dict(
+ partials=Cast(
+ KeyTextTransform("p", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ elif ordering_field in ["misses", "-misses"]:
+ annotation_args = dict(
+ misses=Cast(
+ KeyTextTransform("m", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ elif ordering_field in ["complexity", "-complexity"]:
+ annotation_args = dict(
+ complexity=Cast(
+ KeyTextTransform("C", "latest_commit_totals"),
+ output_field=FloatField(),
+ )
+ )
+ else:
+ return queryset.order_by(ordering_field)
+
+ return queryset.annotate(**annotation_args).order_by(ordering_field)
+
+ def filter_queryset(self, request, queryset, view):
+ ordering = self.get_ordering(request, queryset, view)
+
+ if ordering:
+ for ordering_field in ordering:
+ queryset = self._order_by_totals_field(ordering_field, queryset)
+ return queryset
diff --git a/apps/codecov-api/api/internal/repo/serializers.py b/apps/codecov-api/api/internal/repo/serializers.py
new file mode 100644
index 0000000000..2b79c2ffda
--- /dev/null
+++ b/apps/codecov-api/api/internal/repo/serializers.py
@@ -0,0 +1,107 @@
+from rest_framework import serializers
+
+from api.internal.commit.serializers import (
+ CommitTotalsSerializer,
+ CommitWithFileLevelReportSerializer,
+)
+from api.internal.owner.serializers import OwnerSerializer
+from core.models import Commit, Repository
+from services.analytics import AnalyticsService
+
+
+class RepoSerializer(serializers.ModelSerializer):
+ author = OwnerSerializer()
+
+ class Meta:
+ model = Repository
+ read_only_fields = (
+ "repoid",
+ "service_id",
+ "name",
+ "private",
+ "updatestamp",
+ "author",
+ "language",
+ "hookid",
+ "using_integration",
+ )
+ fields = read_only_fields + ("branch", "active", "activated")
+
+
+class RepoWithMetricsSerializer(RepoSerializer):
+ latest_commit_totals = CommitTotalsSerializer()
+ latest_coverage_change = serializers.FloatField()
+
+ class Meta(RepoSerializer.Meta):
+ fields = (
+ "latest_commit_totals",
+ "latest_coverage_change",
+ ) + RepoSerializer.Meta.fields
+
+
+class RepoDetailsSerializer(RepoSerializer):
+ fork = RepoSerializer()
+ latest_commit = serializers.SerializerMethodField(source="get_latest_commit")
+ bot = serializers.SerializerMethodField()
+
+ # Permissions
+ can_view = serializers.SerializerMethodField()
+ can_edit = serializers.SerializerMethodField()
+
+ class Meta(RepoSerializer.Meta):
+ read_only_fields = (
+ "fork",
+ "upload_token",
+ "yaml",
+ "image_token",
+ ) + RepoSerializer.Meta.read_only_fields
+ fields = (
+ ("can_edit", "can_view", "latest_commit", "bot")
+ + RepoSerializer.Meta.fields
+ + read_only_fields
+ )
+
+ def get_bot(self, repo):
+ if repo.bot:
+ return repo.bot.username
+
+ def get_latest_commit(self, repo):
+ commits_queryset = (
+ repo.commits.filter(state=Commit.CommitStates.COMPLETE)
+ .defer("_report")
+ .order_by("-timestamp")
+ )
+
+ branch_param = self.context["request"].query_params.get("branch", None)
+
+ commits_queryset = commits_queryset.filter(branch=branch_param or repo.branch)
+
+ commit = commits_queryset.first()
+ if commit:
+ return CommitWithFileLevelReportSerializer(commit).data
+
+ def get_can_view(self, _):
+ return self.context.get("can_view")
+
+ def get_can_edit(self, _):
+ return self.context.get("can_edit")
+
+ def to_representation(self, repo):
+ rep = super().to_representation(repo)
+ if not rep.get("can_edit"):
+ del rep["upload_token"]
+ return rep
+
+ def update(self, instance, validated_data):
+ analytics = AnalyticsService()
+ if "active" in validated_data:
+ if validated_data["active"] and not instance.active:
+ analytics.account_activated_repository(
+ self.context["request"].current_owner.ownerid, instance
+ )
+
+ return super().update(instance, validated_data)
+
+
+class SecretStringPayloadSerializer(serializers.Serializer):
+ value = serializers.CharField(required=True)
diff --git a/apps/codecov-api/api/internal/repo/views.py b/apps/codecov-api/api/internal/repo/views.py
new file mode 100644
index 0000000000..ff39f9ff62
--- /dev/null
+++ b/apps/codecov-api/api/internal/repo/views.py
@@ -0,0 +1,81 @@
+import logging
+
+from django.utils import timezone
+from django_filters import rest_framework as django_filters
+from rest_framework import filters, mixins
+from rest_framework.exceptions import PermissionDenied
+
+from api.internal.repo.filter import RepositoryOrderingFilter
+from api.shared.repo.filter import RepositoryFilters
+from api.shared.repo.mixins import RepositoryViewSetMixin
+
+from .serializers import (
+ RepoDetailsSerializer,
+ RepoWithMetricsSerializer,
+)
+
+log = logging.getLogger(__name__)
+
+
+class RepositoryViewSet(
+ RepositoryViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.DestroyModelMixin,
+):
+ filter_backends = (
+ django_filters.DjangoFilterBackend,
+ filters.SearchFilter,
+ RepositoryOrderingFilter,
+ )
+ filterset_class = RepositoryFilters
+ search_fields = ("name",)
+ ordering_fields = (
+ "updatestamp",
+ "name",
+ "latest_coverage_change",
+ "coverage",
+ "lines",
+ "hits",
+ "partials",
+ "misses",
+ "complexity",
+ )
+
+ def get_serializer_class(self):
+ if self.action == "list":
+ return RepoWithMetricsSerializer
+ return RepoDetailsSerializer
+
+ def get_serializer_context(self, *args, **kwargs):
+ context = super().get_serializer_context(*args, **kwargs)
+ if self.action != "list":
+ context.update({"can_edit": self.can_edit, "can_view": self.can_view})
+ return context
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+
+ if self.action == "list":
+ before_date = self.request.query_params.get(
+ "before_date", timezone.now().isoformat()
+ )
+ branch = self.request.query_params.get("branch", None)
+
+ queryset = queryset.with_latest_commit_totals_before(
+ before_date=before_date, branch=branch, include_previous_totals=True
+ ).with_latest_coverage_change()
+
+ if self.request.query_params.get("exclude_uncovered", False):
+ queryset = queryset.exclude_uncovered()
+
+ return queryset
+
+ def perform_update(self, serializer):
+ # Check repo limits for users with legacy plans
+ owner = self.owner
+ if serializer.validated_data.get("active"):
+ if owner.has_legacy_plan and owner.repo_credits <= 0:
+ raise PermissionDenied("Private repository limit reached.")
+ return super().perform_update(serializer)
diff --git a/apps/codecov-api/api/internal/self_hosted/__init__.py b/apps/codecov-api/api/internal/self_hosted/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/self_hosted/filters.py b/apps/codecov-api/api/internal/self_hosted/filters.py
new file mode 100644
index 0000000000..61e58547dd
--- /dev/null
+++ b/apps/codecov-api/api/internal/self_hosted/filters.py
@@ -0,0 +1,14 @@
+import django_filters
+
+
+class UserFilters(django_filters.FilterSet):
+ activated = django_filters.BooleanFilter(
+ method="filter_activated", label="activated"
+ )
+ is_admin = django_filters.BooleanFilter(method="filter_is_admin", label="is_admin")
+
+ def filter_activated(self, queryset, name, value):
+ return queryset.filter(activated=value)
+
+ def filter_is_admin(self, queryset, name, value):
+ return queryset.filter(is_admin=value)
diff --git a/apps/codecov-api/api/internal/self_hosted/permissions.py b/apps/codecov-api/api/internal/self_hosted/permissions.py
new file mode 100644
index 0000000000..f5b6245999
--- /dev/null
+++ b/apps/codecov-api/api/internal/self_hosted/permissions.py
@@ -0,0 +1,10 @@
+from rest_framework.permissions import BasePermission
+
+import services.self_hosted as self_hosted
+
+
+class AdminPermissions(BasePermission):
+ def has_permission(self, request, view):
+ return request.current_owner and self_hosted.is_admin_owner(
+ request.current_owner
+ )
diff --git a/apps/codecov-api/api/internal/self_hosted/serializers.py b/apps/codecov-api/api/internal/self_hosted/serializers.py
new file mode 100644
index 0000000000..384a02315c
--- /dev/null
+++ b/apps/codecov-api/api/internal/self_hosted/serializers.py
@@ -0,0 +1,34 @@
+from rest_framework import serializers
+from rest_framework.exceptions import PermissionDenied
+
+import services.self_hosted as self_hosted
+from codecov_auth.models import Owner
+
+
+class UserSerializer(serializers.ModelSerializer):
+ is_admin = serializers.BooleanField()
+ activated = serializers.BooleanField()
+
+ class Meta:
+ model = Owner
+ fields = (
+ "ownerid",
+ "username",
+ "email",
+ "name",
+ "is_admin",
+ "activated",
+ )
+
+ def update(self, instance, validated_data):
+ if "activated" in validated_data:
+ if validated_data["activated"] is True:
+ try:
+ self_hosted.activate_owner(instance)
+ except self_hosted.LicenseException as err:
+ raise PermissionDenied(err.message)
+ else:
+ self_hosted.deactivate_owner(instance)
+
+ # re-query for object to get updated `activated` value
+ return self.context["view"].get_queryset().filter(pk=instance.pk).first()
diff --git a/apps/codecov-api/api/internal/self_hosted/views.py b/apps/codecov-api/api/internal/self_hosted/views.py
new file mode 100644
index 0000000000..246030bcbb
--- /dev/null
+++ b/apps/codecov-api/api/internal/self_hosted/views.py
@@ -0,0 +1,65 @@
+from django.db.models import Exists, OuterRef, Q
+from django.db.models.functions import Coalesce
+from django_filters import rest_framework as django_filters
+from rest_framework import filters, mixins, viewsets
+from rest_framework.decorators import action
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+import services.self_hosted as self_hosted
+from codecov_auth.models import Owner
+
+from .filters import UserFilters
+from .permissions import AdminPermissions
+from .serializers import UserSerializer
+
+
+class UserViewSet(
+ viewsets.GenericViewSet,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+):
+ serializer_class = UserSerializer
+ filter_backends = (
+ django_filters.DjangoFilterBackend,
+ filters.SearchFilter,
+ )
+ filterset_class = UserFilters
+ permission_classes = [AdminPermissions]
+ ordering_fields = ("name", "username", "email")
+ search_fields = ["name", "username", "email"]
+
+ def get_queryset(self):
+ activated_owners = self_hosted.activated_owners()
+ condition = (Q(oauth_token__isnull=False) & Q(organizations__isnull=False)) | Q(
+ pk__in=activated_owners
+ )
+ return Owner.objects.filter(condition).annotate(
+ is_admin=Coalesce(
+ Exists(self_hosted.admin_owners().filter(pk=OuterRef("pk"))), False
+ ),
+ activated=Coalesce(
+ Exists(activated_owners.filter(pk=OuterRef("pk"))),
+ False,
+ ),
+ )
+
+ @action(
+ detail=False,
+ methods=["get"],
+ url_path="current",
+ permission_classes=[IsAuthenticated],
+ )
+ def current(self, request):
+ current_owner = self.get_queryset().filter(pk=request.current_owner.pk).first()
+ serializer = self.get_serializer(current_owner)
+ return Response(serializer.data)
+
+ @current.mapping.patch
+ def current_update(self, request):
+ current_owner = self.get_queryset().filter(pk=request.current_owner.pk).first()
+ serializer = self.get_serializer(current_owner, data=request.data, partial=True)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/internal/slack/__init__.py b/apps/codecov-api/api/internal/slack/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/slack/helpers.py b/apps/codecov-api/api/internal/slack/helpers.py
new file mode 100644
index 0000000000..a116841894
--- /dev/null
+++ b/apps/codecov-api/api/internal/slack/helpers.py
@@ -0,0 +1,14 @@
+from rest_framework.exceptions import ValidationError
+
+from codecov_auth.models import Service
+
+
+def validate_params(username, service):
+ """
+ Validates the parameters of the request.
+ """
+ if not username or not service:
+ raise ValidationError("Username and service are required")
+
+ if service not in Service:
+ raise ValidationError("Invalid service")
diff --git a/apps/codecov-api/api/internal/slack/urls.py b/apps/codecov-api/api/internal/slack/urls.py
new file mode 100644
index 0000000000..b4dc61c282
--- /dev/null
+++ b/apps/codecov-api/api/internal/slack/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from api.internal.slack.views import GenerateAccessTokenView
+
+urlpatterns = [
+ path("generate-token/", GenerateAccessTokenView.as_view(), name="generate-token"),
+]
diff --git a/apps/codecov-api/api/internal/slack/views.py b/apps/codecov-api/api/internal/slack/views.py
new file mode 100644
index 0000000000..47c3644021
--- /dev/null
+++ b/apps/codecov-api/api/internal/slack/views.py
@@ -0,0 +1,35 @@
+from rest_framework.exceptions import NotFound
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from api.shared.permissions import InternalTokenPermissions
+from codecov_auth.authentication import InternalTokenAuthentication
+from codecov_auth.models import Owner, UserToken
+
+from .helpers import validate_params
+
+
+class GenerateAccessTokenView(APIView):
+ """
+ Returns a new access token for the given user for slack integration
+ """
+
+ authentication_classes = [InternalTokenAuthentication]
+ permission_classes = [InternalTokenPermissions]
+
+ def post(self, request, *args, **kwargs):
+ username = request.data.get("username")
+ service = request.data.get("service")
+ validate_params(username, service)
+
+ owner = Owner.objects.filter(username=username, service=service).first()
+ if not owner:
+ raise NotFound("Owner not found")
+
+ token_type = UserToken.TokenType.API.value
+ user_token, _ = UserToken.objects.get_or_create(
+ name="slack-codecov-access-token",
+ owner=owner,
+ token_type=token_type,
+ )
+ return Response({"token": user_token.token}, status=200)
diff --git a/apps/codecov-api/api/internal/tests/__init__.py b/apps/codecov-api/api/internal/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_chunks.txt b/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_chunks.txt
new file mode 100644
index 0000000000..e18a385a25
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_chunks.txt
@@ -0,0 +1,49 @@
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_report.json b/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_report.json
new file mode 100644
index 0000000000..ae86688688
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/00c7b4b49778b3c79427f9c4c13a8612a376ff19_report.json
@@ -0,0 +1,219 @@
+{
+ "files": {
+ "awesome/__init__.py": [
+ 2,
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ],
+ "tests/__init__.py": [
+ 0,
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ],
+ "tests/test_sample.py": [
+ 1,
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ]
+ },
+ "sessions": {
+ "0": {
+ "N": null,
+ "a": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/00c7b4b49778b3c79427f9c4c13a8612a376ff19/e83211bb-55b8-4549-baef-cb159ba78cf0.txt",
+ "c": null,
+ "d": 1562093100,
+ "e": null,
+ "f": [
+ "flagone"
+ ],
+ "j": null,
+ "n": null,
+ "p": null,
+ "storage_path": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/00c7b4b49778b3c79427f9c4c13a8612a376ff19/e83211bb-55b8-4549-baef-cb159ba78cf0.txt",
+ "t": [
+ 3,
+ 24,
+ 19,
+ 5,
+ 0,
+ "79.16667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "u": null
+ },
+ "1": {
+ "N": null,
+ "a": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/00c7b4b49778b3c79427f9c4c13a8612a376ff19/14b2108c-552e-4277-a918-478c597c10bd.txt",
+ "c": null,
+ "d": 1562093214,
+ "e": null,
+ "f": [
+ "flagtwo"
+ ],
+ "j": null,
+ "n": null,
+ "p": null,
+ "storage_path": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/00c7b4b49778b3c79427f9c4c13a8612a376ff19/14b2108c-552e-4277-a918-478c597c10bd.txt",
+ "t": [
+ 3,
+ 24,
+ 19,
+ 5,
+ 0,
+ "79.16667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "u": null
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_chunks.txt b/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_chunks.txt
new file mode 100644
index 0000000000..b42fbcdbde
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_chunks.txt
@@ -0,0 +1,50 @@
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_report.json b/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_report.json
new file mode 100644
index 0000000000..d5183dad03
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/68946ef98daec68c7798459150982fc799c87d85_report.json
@@ -0,0 +1,234 @@
+{
+ "files": {
+ "awesome/__init__.py": [
+ 2,
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 14,
+ 10,
+ 4,
+ 0,
+ "71.42857",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ],
+ "tests/__init__.py": [
+ 0,
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 3,
+ 2,
+ 1,
+ 0,
+ "66.66667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ],
+ "tests/test_sample.py": [
+ 1,
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 7,
+ 7,
+ 0,
+ 0,
+ "100",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0
+ ]
+ ]
+ },
+ "sessions": {
+ "0": {
+ "N": null,
+ "a": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/d48148e85b32c37c821476c236d9c7a8aa7dcf8e/89b4620c-2740-42f9-bb95-eba438beb197.txt",
+ "c": null,
+ "d": 1562093431,
+ "e": null,
+ "f": [
+ "flagone"
+ ],
+ "j": null,
+ "n": null,
+ "p": null,
+ "storage_path": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/d48148e85b32c37c821476c236d9c7a8aa7dcf8e/89b4620c-2740-42f9-bb95-eba438beb197.txt",
+ "t": [
+ 3,
+ 24,
+ 19,
+ 5,
+ 0,
+ "79.16667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "u": null,
+ "st": "carriedforward"
+ },
+ "1": {
+ "N": null,
+ "a": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/d48148e85b32c37c821476c236d9c7a8aa7dcf8e/ac50a236-058d-4507-922a-4a13dca6762e.txt",
+ "c": null,
+ "d": 1562093484,
+ "e": null,
+ "f": [
+ "flagtwo"
+ ],
+ "j": null,
+ "n": null,
+ "p": null,
+ "storage_path": "v4/raw/2019-07-02/8AD32B748ABB0F88B6E68B3E4FA7A71F/d48148e85b32c37c821476c236d9c7a8aa7dcf8e/ac50a236-058d-4507-922a-4a13dca6762e.txt",
+ "t": [
+ 3,
+ 24,
+ 19,
+ 5,
+ 0,
+ "79.16667",
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "u": null
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/9sa8790asdf9agyasdg7a90sd9f89as7ga0sdf98a_chunks.txt b/apps/codecov-api/api/internal/tests/samples/9sa8790asdf9agyasdg7a90sd9f89as7ga0sdf98a_chunks.txt
new file mode 100644
index 0000000000..b42fbcdbde
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/9sa8790asdf9agyasdg7a90sd9f89as7ga0sdf98a_chunks.txt
@@ -0,0 +1,50 @@
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+[1, null, [[0, 1, null, null, null], [1, 0, null, null, null]]]
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
+
+
+
+
+[1, null, [[0, 1, null, null, null], [1, 1, null, null, null]]]
+[0, null, [[0, 0, null, null, null], [1, 0, null, null, null]]]
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/get_commit_diff-response.json b/apps/codecov-api/api/internal/tests/samples/get_commit_diff-response.json
new file mode 100644
index 0000000000..075df98aaa
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/get_commit_diff-response.json
@@ -0,0 +1,90 @@
+{
+ "files": {
+ "awesome/__init__.py": {
+ "before": "None",
+ "segments": [
+ {
+ "header": [
+ "10",
+ "3",
+ "10",
+ "7"
+ ],
+ "lines": [
+ " if n ",
+ "< 2:",
+ " ",
+ "return 1",
+ " ",
+ "return ",
+ "fib(n - 2) ",
+ "+ fib(n - ",
+ "1)",
+ "+",
+ "+",
+ "+def ",
+ "coala(k):",
+ "+ ",
+ "return k * ",
+ "k"
+ ]
+ }
+ ],
+ "stats": {
+ "added": 4,
+ "removed": 0
+ },
+ "type": "modified"
+ },
+ "coverage.xml": {
+ "before": "None",
+ "segments": [
+ {
+ "header": [
+ "1",
+ "5",
+ "1",
+ "5"
+ ],
+ "lines": [
+ " ",
+ "-",
+ "+",
+ " \t",
+ " \t",
+ " \t"
+ ]
+ }
+ ],
+ "stats": {
+ "added": 1,
+ "removed": 1
+ },
+ "type": "modified"
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/samples/report-field-example.json b/apps/codecov-api/api/internal/tests/samples/report-field-example.json
new file mode 100644
index 0000000000..a8e3fcc7fd
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/samples/report-field-example.json
@@ -0,0 +1,70 @@
+{
+ "files": {
+ "lib/calc.ts": [
+ 0,
+ [
+ 0,
+ 4,
+ 4,
+ 0,
+ 0,
+ "100",
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ [
+ [
+ 0,
+ 4,
+ 4,
+ 0,
+ 0,
+ "100",
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ ],
+ null
+ ]
+ },
+ "sessions": {
+ "0": {
+ "N": null,
+ "a": "v4/raw/2019-04-11/F841A2E295426932B8AE33B2E5739E94/edcf349a9e0cd7bd1c255028597357e6e2f9538d/42e252be-3cac-43e0-b418-5c3cc2102b6e.txt",
+ "c": null,
+ "d": 1554977264,
+ "e": null,
+ "f": null,
+ "j": null,
+ "n": null,
+ "p": null,
+ "t": [
+ 1,
+ 4,
+ 4,
+ 0,
+ 0,
+ "100",
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "u": null,
+ "storage_path": "v4/raw/2019-04-11/F841A2E295426932B8AE33B2E5739E94/edcf349a9e0cd7bd1c255028597357e6e2f9538d/42e252be-3cac-43e0-b418-5c3cc2102b6e.txt"
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/codecov-api/api/internal/tests/test_charts.py b/apps/codecov-api/api/internal/tests/test_charts.py
new file mode 100644
index 0000000000..7fa9476f08
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_charts.py
@@ -0,0 +1,990 @@
+from datetime import datetime, timedelta
+from decimal import Decimal
+from math import isclose
+from random import randint
+from unittest.mock import patch
+
+import pytest
+from dateutil.relativedelta import relativedelta
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from factory.faker import faker
+from pytz import UTC
+from rest_framework.exceptions import ValidationError
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from api.internal.chart.filters import apply_default_filters, apply_simple_filters
+from api.internal.chart.helpers import (
+ ChartQueryRunner,
+ annotate_commits_with_totals,
+ apply_grouping,
+ validate_params,
+)
+from codecov.tests.base_test import InternalAPITest
+from core.models import Commit
+from utils.test_utils import Client
+
+fake = faker.Faker()
+
+
+def generate_random_totals(
+ include_complexity=True, lines=None, hits=None, partials=None
+):
+ lines = lines or randint(5, 5000)
+ hits = hits or randint(0, lines)
+ partials = partials or randint(0, lines - hits)
+ misses = lines - hits - partials
+ coverage = (hits + partials) / lines
+ complexity = randint(0, 5) if include_complexity else 0
+ complexity_total = randint(complexity, 10) if include_complexity else 0
+
+ totals = {
+ "n": lines,
+ "h": hits,
+ "p": partials,
+ "m": misses,
+ "c": coverage,
+ "C": complexity,
+ "N": complexity_total,
+ # Not currenly used: diff, files, sessions, branches, methods
+ }
+ return totals
+
+
+def setup_commits(
+ repo,
+ num_commits,
+ branch="main",
+ start_date=None,
+ meets_default_filters=True,
+ **kwargs,
+):
+ """
+ Generate random commits with different configurations, to accommodate different testing scenarios.
+
+ :param repo: repo to associate the commits with
+ :param num_commits: number of commits to create
+ :param branch: branch to associate with the commit; randomly generated by DDF if none provided
+ :param start_date: if provided, commit timestamp will be set to after this date.
+ for more info on acceptable values for start_date, see: https://faker.readthedocs.io/en/master/providers/faker.providers.date_time.html#faker.providers.date_time.Provider.date_time_between
+ :param meets_default_filters: when true the commit will meet all the conditions for the initial filtering done on commits
+ :param kwargs: passed to generate_random_totals to manually set totals values
+ """
+ for _ in range(num_commits):
+ timestamp = (
+ fake.date_time_between(start_date=start_date, tzinfo=UTC)
+ if start_date
+ else fake.date_time(tzinfo=UTC)
+ )
+
+ totals = generate_random_totals(**kwargs) if meets_default_filters else None
+ state = "complete" if meets_default_filters else "pending"
+ ci_passed = True if meets_default_filters else False
+ deleted = False if meets_default_filters else True
+
+ CommitFactory(
+ repository=repo,
+ branch=branch,
+ timestamp=timestamp,
+ totals=totals,
+ state=state,
+ ci_passed=ci_passed,
+ deleted=deleted,
+ )
+
+
+def check_grouping_correctness(grouped_queryset, initial_queryset, data):
+ """
+ Used to test "apply_grouping" correctness. Programmatically verify that commits were grouped
+ by the correct unit of time, and that within that grouping the correct commit was returned based on the
+ query params provided.
+
+ :param grouped_queryset: the queryset generated by calling "apply_grouping" on the initial_queryset
+ :param initial_queryset: the annotated and filtered queryset provided to the apply_grouping call
+ :param data: the grouping and filtering parameters that were applied when generating the grouping
+ """
+ grouping_unit = data.get("grouping_unit")
+ agg_function = data.get("agg_function")
+ agg_value = data.get("agg_value")
+
+ """
+ For each of the grouped commits, retrieve all the commits from the initial queryset that are within the same time window
+ and verify that we can't find any that that better match the given aggregation function better.
+
+ For example, if we grouped by max coverage per month, we'll get the commits for that month and verify that none of them have a coverage
+ value greater than the grouped commit.
+ """
+ for commit in grouped_queryset:
+ # Get the unit of time we grouped by so we can filter for all the commits in this commit's time window
+ relative_delta_args = (
+ {f"{grouping_unit}s": 1}
+ if grouping_unit != "quarter"
+ else {
+ "months": 3
+ } # relativedelta doesn't except quarter as an argument so set that manually
+ )
+
+ # example: if agg_function is "min" and agg_value is "coverage", this will pass
+ # "coverage__lt: " to the filter call below, to check if any commits in this window had lower coverage
+ filtering_key = agg_value + ("__lt" if agg_function == "min" else "__gt")
+ filtering_value_args = {filtering_key: getattr(commit, agg_value)}
+
+ assert (
+ not initial_queryset.filter(
+ timestamp__gt=commit.truncated_date,
+ timestamp__lt=commit.truncated_date
+ + relativedelta(**relative_delta_args),
+ repository__name=commit.repository.name,
+ )
+ .filter(
+ **filtering_value_args
+ ) # make two filter calls to avoid potentially filtering by timestamp multiple time in the same call which makes django unhappy
+ .exclude(commitid=commit.commitid)
+ .exists()
+ )
+
+
+class CoverageChartHelpersTest(TestCase):
+ def setUp(self):
+ self.org1 = OwnerFactory(username="org1")
+ self.repo1_org1 = RepositoryFactory(author=self.org1, name="repo1")
+ setup_commits(self.repo1_org1, 10)
+
+ self.repo2_org1 = RepositoryFactory(author=self.org1, name="repo2")
+ setup_commits(self.repo2_org1, 10)
+
+ self.org2 = OwnerFactory(username="org2", service_id=1239128)
+ self.repo1_org2 = RepositoryFactory(author=self.org2, name="repo1")
+ setup_commits(self.repo1_org2, 10)
+
+ self.user = OwnerFactory(
+ organizations=[self.org1.ownerid],
+ permission=[
+ self.repo1_org1.repoid,
+ self.repo2_org1.repoid,
+ self.repo1_org2.repoid,
+ ],
+ )
+
+ def test_validate_params_invalid(self):
+ data = {
+ "agg_function": "potato",
+ "grouping_unit": "potato",
+ "coverage_timestamp_ordering": "potato",
+ "repositories": [],
+ "field_not_in_schema": True,
+ }
+
+ with self.assertRaises(ValidationError) as err:
+ validate_params(data)
+
+ # Check that only the expected validation errors occurred
+ validation_errors = err.exception.detail
+ assert len(validation_errors) == 5
+ assert "owner_username" in validation_errors # required field missing
+ assert "grouping_unit" in validation_errors # value not allowed
+ assert "agg_function" in validation_errors # value not allowed
+ assert (
+ "field_not_in_schema" in validation_errors
+ ) # only fields in the schema are allowed in params
+ assert "coverage_timestamp_ordering" in validation_errors # value not allowed
+
+ def test_validate_params_valid(self):
+ data = {
+ "owner_username": self.org1.username,
+ "agg_function": "max",
+ "agg_value": "coverage",
+ "grouping_unit": "month",
+ "coverage_timestamp_ordering": "increasing",
+ }
+
+ validate_params(data)
+
+ def test_validate_params_agg_fields(self):
+ data_aggregated = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "min",
+ "agg_value": "timestamp",
+ }
+ validate_params(data_aggregated)
+
+ # Check that aggregation parameters are not required when grouping by commit
+ data_grouped_by_commit = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "commit",
+ }
+ validate_params(data_grouped_by_commit)
+
+ # Check that aggregation parameters are required when grouping by commit
+ data_aggregated_missing_agg_fields = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "month",
+ }
+ with self.assertRaises(ValidationError) as err:
+ validate_params(data_aggregated_missing_agg_fields)
+
+ validation_errors = err.exception.detail
+ assert len(validation_errors) == 1
+ assert "grouping_unit" in validation_errors
+
+ def test_apply_default_filters(self):
+ setup_commits(self.repo1_org1, 10, meets_default_filters=False)
+
+ queryset = apply_default_filters(Commit.objects.all())
+
+ assert queryset.count() > 0 and queryset.count() < Commit.objects.count()
+ for commit in queryset:
+ assert commit.state == "complete"
+ assert commit.deleted is False
+ assert commit.ci_passed is True
+ assert commit.totals is not None
+
+ def test_apply_simple_filters(self):
+ setup_commits(self.repo1_org1, 10, start_date="-7d")
+ setup_commits(self.repo1_org1, 2, branch="production", start_date="-7d")
+
+ start_date = datetime.now() - relativedelta(days=7)
+ end_date = datetime.now()
+ data = {
+ "owner_username": self.org1.username,
+ "branch": "main",
+ "start_date": start_date.isoformat(),
+ "end_date": end_date.isoformat(),
+ "repositories": [self.repo1_org1.name, self.repo2_org1.name],
+ "service": "github",
+ }
+ queryset = apply_simple_filters(Commit.objects.all(), data, self.user)
+
+ assert queryset.count() > 0
+ for commit in queryset:
+ assert commit.repository.name in data.get("repositories")
+ assert commit.repository.author.username == data["owner_username"]
+ assert commit.branch == data["branch"]
+ assert commit.timestamp >= start_date
+ assert commit.timestamp <= end_date
+
+ def test_apply_simple_filters_repo_filtering(self):
+ """
+ This test verifies that when no "repository" parameters are returned, we only return all repositories
+ in the organization that the logged-in user has permissions to view.
+ """
+ no_permissions_repo = RepositoryFactory(
+ author=self.org1, name="no_permissions_to_this_repo", private=True
+ )
+ setup_commits(no_permissions_repo, 10)
+
+ data = {"owner_username": self.org1.username, "service": "github"}
+
+ queryset = apply_simple_filters(Commit.objects.all(), data, self.user)
+ assert queryset.count() > 0
+ for commit in queryset:
+ assert commit.repository.name != no_permissions_repo
+
+ def test_apply_simple_filters_without_service(self):
+ """
+ This test verifies that when no commits are returned if the user doesn't provide both a username and the service
+ """
+ repo = RepositoryFactory(author=self.org1, name="random_repo")
+ setup_commits(repo, 10)
+
+ data = {"owner_username": self.org1.username}
+
+ queryset = apply_simple_filters(Commit.objects.all(), data, self.user)
+ assert queryset.count() == 0
+
+ def test_apply_simple_filters_branch_filtering(self):
+ # Verify that when no "branch" param is provided, we filter commits by the repo's default branch
+
+ branch_test = RepositoryFactory(
+ author=self.org1, name="branch_test", private=False, branch="main"
+ ) # "main" is the default branch
+ setup_commits(branch_test, 10, branch="main")
+ setup_commits(branch_test, 10, branch="not_default")
+
+ # we shouldn't get commits on "main" branch for a repo that has a different default branch
+ setup_commits(self.repo1_org1, 10, branch="main")
+
+ data = {
+ "owner_username": self.org1.username,
+ "service": "gh",
+ "repositories": [self.repo1_org1.name, branch_test.name],
+ }
+ queryset = apply_simple_filters(Commit.objects.all(), data, self.user)
+ assert queryset.count() > 0
+ for commit in queryset:
+ assert (
+ commit.repository.name == self.repo1_org1.name
+ and commit.branch == "main"
+ ) or (
+ commit.repository.name == branch_test.name and commit.branch == "main"
+ )
+
+ # should still be able to query by non-default branch if desired
+ data = {
+ "owner_username": self.org1.username,
+ "repositories": [branch_test.name],
+ "service": "github",
+ "branch": "not_default",
+ }
+ queryset = apply_simple_filters(Commit.objects.all(), data, self.user)
+ assert queryset.count() > 0
+ for commit in queryset:
+ assert (
+ commit.repository.name == branch_test.name
+ and commit.branch == "not_default"
+ )
+
+ def test_annotate_commits_with_totals(self):
+ with_complexity_commitid = "i230tky2"
+ CommitFactory(
+ commitid=with_complexity_commitid,
+ totals={"n": 0, "h": 0, "p": 0, "m": 0, "c": 0, "C": 0, "N": 1},
+ )
+ annotated_commits = annotate_commits_with_totals(
+ Commit.objects.filter(commitid=with_complexity_commitid)
+ )
+
+ assert annotated_commits.count() > 0
+ for commit in annotated_commits:
+ # direct float equality checks in python are finicky so use "isclose" to check we got the expected value
+ assert isclose(commit.coverage, commit.totals["c"])
+ assert isclose(commit.complexity, commit.totals["C"])
+ assert isclose(commit.complexity_total, commit.totals["N"])
+ assert isclose(
+ commit.complexity_ratio, commit.totals["C"] / commit.totals["N"]
+ )
+
+ def test_annotate_commit_with_totals_no_complexity_sets_ratio_to_None(self):
+ no_complexity_commitid = "sdfkjwepj42"
+ CommitFactory(
+ commitid=no_complexity_commitid,
+ totals={"n": 0, "h": 0, "p": 0, "m": 0, "c": 0, "C": 0, "N": 0},
+ )
+ annotated_commits = annotate_commits_with_totals(
+ Commit.objects.filter(commitid=no_complexity_commitid)
+ )
+
+ assert annotated_commits.count() > 0
+ for commit in annotated_commits:
+ assert commit.complexity_ratio is None
+
+ def test_apply_grouping(self):
+ with self.subTest("min coverage"):
+ setup_commits(self.repo1_org1, 20, start_date="-7d")
+
+ data = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "min",
+ "agg_value": "coverage",
+ "start_date": (timezone.now() - relativedelta(days=7)).isoformat(),
+ "end_date": timezone.now().isoformat(),
+ "repositories": [self.repo1_org1.name],
+ }
+
+ initial_queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ grouped_queryset = apply_grouping(initial_queryset, data)
+ check_grouping_correctness(grouped_queryset, initial_queryset, data)
+
+ with self.subTest("max coverage"):
+ setup_commits(self.repo1_org1, 20, start_date="-180d")
+
+ data = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "month",
+ "agg_function": "max",
+ "agg_value": "coverage",
+ "start_date": (timezone.now() - relativedelta(months=6)).isoformat(),
+ "end_date": timezone.now().isoformat(),
+ "repositories": [self.repo1_org1.name],
+ }
+
+ initial_queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ grouped_queryset = apply_grouping(initial_queryset, data)
+ check_grouping_correctness(grouped_queryset, initial_queryset, data)
+
+ with self.subTest("min complexity"):
+ setup_commits(self.repo1_org1, 20, start_date="-7d")
+
+ data = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "max",
+ "agg_value": "complexity",
+ "start_date": (timezone.now() - relativedelta(days=7)).isoformat(),
+ "end_date": timezone.now().isoformat(),
+ "repositories": [self.repo1_org1.name],
+ }
+
+ initial_queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ grouped_queryset = apply_grouping(initial_queryset, data)
+ check_grouping_correctness(grouped_queryset, initial_queryset, data)
+
+ with self.subTest("max complexity"):
+ setup_commits(self.repo1_org1, 20, start_date="-7d")
+
+ data = {
+ "owner_username": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "max",
+ "agg_value": "complexity",
+ "start_date": (timezone.now() - relativedelta(days=7)).isoformat(),
+ "end_date": timezone.now().isoformat(),
+ "repositories": [self.repo1_org1.name],
+ }
+
+ initial_queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ grouped_queryset = apply_grouping(initial_queryset, data)
+ check_grouping_correctness(grouped_queryset, initial_queryset, data)
+
+ def test_ordering(self):
+ with self.subTest("order by increasing dates"):
+ data = {
+ "organization": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "min",
+ "coverage_timestamp_ordering": "increasing",
+ "agg_value": "coverage",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ queryset = apply_grouping(queryset, data)
+
+ results = queryset.values()
+ # -1 because the last result doesn't need to be tested against
+ for i in range(len(results) - 1):
+ assert results[i]["timestamp"] < results[i + 1]["timestamp"]
+
+ with self.subTest("order by decreasing dates"):
+ data = {
+ "organization": self.org1.username,
+ "grouping_unit": "day",
+ "agg_function": "min",
+ "coverage_timestamp_ordering": "decreasing",
+ "agg_value": "coverage",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ queryset = annotate_commits_with_totals(
+ apply_simple_filters(
+ apply_default_filters(Commit.objects.all()), data, self.user
+ )
+ )
+ queryset = apply_grouping(queryset, data)
+
+ results = queryset.values()
+ # -1 because the last result doesn't need to be tested against
+ for i in range(len(results) - 1):
+ assert results[i]["timestamp"] > results[i + 1]["timestamp"]
+
+
+class TestChartQueryRunnerQuery(TestCase):
+ """
+ Tests for the querying-part of the ChartQueryRunner.
+ """
+
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo1 = RepositoryFactory(author=self.org, active=True)
+ self.repo2 = RepositoryFactory(author=self.org, active=True)
+ self.repo3 = RepositoryFactory(author=self.org)
+ self.repo4 = RepositoryFactory(author=self.org, active=True)
+ self.user = OwnerFactory(
+ permission=[
+ self.repo1.repoid,
+ self.repo2.repoid,
+ self.repo3.repoid,
+ self.repo4.repoid,
+ ]
+ )
+ self.commit1 = CommitFactory(
+ repository=self.repo1,
+ totals={"h": 100, "n": 120, "p": 10, "m": 10},
+ branch=self.repo1.branch,
+ state="complete",
+ )
+ self.commit2 = CommitFactory(
+ repository=self.repo2,
+ totals={"h": 14, "n": 25, "p": 6, "m": 5},
+ branch=self.repo2.branch,
+ state="complete",
+ )
+ self.commit3 = CommitFactory(
+ repository=self.repo3,
+ totals={"h": 14, "n": 25, "p": 6, "m": 5},
+ branch=self.repo3.branch,
+ state="complete",
+ )
+
+ @override_settings(GITHUB_CLIENT_ID="3d44be0e772666136a13")
+ def test_query_aggregates_multiple_repository_totals(self):
+ query_runner = ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "end_date": str(timezone.now()),
+ "grouping_unit": "day",
+ },
+ )
+
+ results = query_runner.run_query()
+
+ assert len(results) == 1
+ assert results[0]["total_hits"] == 114
+ assert results[0]["total_lines"] == 145
+ assert results[0]["total_misses"] == 15
+ assert results[0]["total_partials"] == 16
+
+ @pytest.mark.skip(reason="flaky")
+ def test_query_aggregates_with_latest_commit_if_no_recent_upload(self):
+ # set timestamp to past, before 'start_date'
+ self.commit1.timestamp = timezone.now() - timedelta(days=7)
+ self.commit1.save()
+
+ query_runner = ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "start_date": str(timezone.now() - timedelta(days=1)),
+ "grouping_unit": "day",
+ },
+ )
+
+ results = query_runner.run_query()
+
+ assert len(results) == 2
+
+ # Day before commit2 is created, a few days after commit1 is created
+ assert results[0]["total_hits"] == 100
+ assert results[0]["total_lines"] == 120
+ assert results[0]["total_misses"] == 10
+ assert results[0]["total_partials"] == 10
+ assert results[0]["coverage"] == Decimal("91.67")
+
+ # Day commit2 is created
+ assert results[1]["total_hits"] == 114
+ assert results[1]["total_lines"] == 145
+ assert results[1]["total_misses"] == 15
+ assert results[1]["total_partials"] == 16
+ assert results[1]["coverage"] == Decimal("89.66")
+
+ @pytest.mark.skip(reason="flaky, skipping until re write")
+ def test_query_supports_different_grouping_params(self):
+ end_date = datetime.fromisoformat("2019-01-01")
+ self.commit1.timestamp = end_date - timedelta(days=365)
+ self.commit1.save()
+ pairs = [("day", 365), ("week", 52), ("month", 12), ("quarter", 4), ("year", 1)]
+ for grouping_unit, expected_num_datapoints in pairs:
+ query_runner = ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "start_date": str(end_date - timedelta(days=365)),
+ "end_date": str(end_date),
+ "grouping_unit": grouping_unit,
+ },
+ )
+
+ results = query_runner.run_query()
+
+ assert (
+ len(results) == expected_num_datapoints + 1
+ ) # We add one because the date range is inclusive
+
+ @pytest.mark.skip(
+ reason="flaky, skipping since we're moving away from ChartQueryRunner soon anyway"
+ )
+ def test_query_supports_reverse_ordering(self):
+ self.commit1.timestamp = timezone.now() - timedelta(days=7)
+ self.commit1.save()
+
+ query_runner = ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "start_date": str(timezone.now() - timedelta(days=1)),
+ "grouping_unit": "day",
+ "coverage_timestamp_ordering": "decreasing",
+ },
+ )
+
+ results = query_runner.run_query()
+
+ assert len(results) == 2
+ assert results[0]["date"] > results[1]["date"]
+
+ def test_query_doesnt_crash_if_no_commits(self):
+ with self.subTest("no repos case"):
+ self.org.repository_set.all().delete()
+ ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ ).run_query()
+
+ with self.subTest("no commits case"):
+ repo = RepositoryFactory(author=self.org)
+ self.user.permission = [repo.repoid]
+ self.user.save()
+ ChartQueryRunner(
+ user=self.user,
+ request_params={
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ ).run_query()
+
+
+class TestChartQueryRunnerHelperMethods(TestCase):
+ """
+ Tests for the non-querying-parts of the ChartQueryRunner, such
+ as validation and parameter transformation.
+ """
+
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.user = OwnerFactory()
+
+ def test_repoids(self):
+ repo1, repo2 = (
+ RepositoryFactory(author=self.org, active=True),
+ RepositoryFactory(author=self.org, active=True),
+ )
+ self.user.permission = [repo1.repoid, repo2.repoid]
+ self.user.save()
+ qr = ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ )
+
+ with self.subTest("returns repoids"):
+ assert qr.repoids == f"({repo2.repoid},{repo1.repoid})"
+
+ with self.subTest("filters by supplied repo names"):
+ qr = ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ "repositories": [repo1.name],
+ },
+ )
+ assert qr.repoids == f"({repo1.repoid})"
+
+ def test_interval(self):
+ with self.subTest("translates quarter into 3 months"):
+ assert (
+ ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "quarter",
+ },
+ ).interval
+ == "3 months"
+ )
+
+ with self.subTest("transforms grouping unit into '1 {grouping_unit}'"):
+ for grouping_unit in ["day", "week", "month", "year"]:
+ assert (
+ ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": grouping_unit,
+ },
+ ).interval
+ == f"1 {grouping_unit}"
+ )
+
+ def test_first_complete_commit_date_returns_date_of_first_complete_commit_in_repoids(
+ self,
+ ):
+ repo1, repo2 = (
+ RepositoryFactory(author=self.org, active=True),
+ RepositoryFactory(author=self.org, active=True),
+ )
+ self.user.permission = [repo1.repoid, repo2.repoid]
+ self.user.save()
+ CommitFactory(
+ repository=repo1,
+ branch=repo1.branch,
+ state="pending",
+ timestamp=timezone.now() - timedelta(days=7),
+ )
+ commit1 = CommitFactory(
+ repository=repo1,
+ branch=repo1.branch,
+ state="complete",
+ timestamp=timezone.now() - timedelta(days=3),
+ )
+ CommitFactory(repository=repo2, branch=repo2.branch, state="complete")
+
+ qr = ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ )
+
+ assert qr.first_complete_commit_date == datetime.date(commit1.timestamp)
+
+ def test_start_date(self):
+ with self.subTest("returns parsed start date if supplied"):
+ start_date = timezone.now()
+ assert ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ "start_date": str(start_date),
+ },
+ ).start_date == datetime.date(start_date)
+
+ with self.subTest("returns first_commit_date if not supplied"):
+ repo = RepositoryFactory(author=self.org, active=True)
+ self.user.permission = [repo.repoid]
+ self.user.save()
+ commit = CommitFactory(
+ repository=repo,
+ branch=repo.branch,
+ state="complete",
+ timestamp=timezone.now() - timedelta(days=3),
+ )
+ assert ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ ).start_date == datetime.date(commit.timestamp)
+
+ def test_end_date(self):
+ with self.subTest("returns parsed end date if supplied"):
+ end_date = timezone.now() - timedelta(days=7)
+ assert ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ "end_date": str(end_date),
+ },
+ ).end_date == datetime.date(end_date)
+
+ with self.subTest("returns timezone.now() if not supplied"):
+ assert ChartQueryRunner(
+ self.user,
+ {
+ "owner_username": self.org.username,
+ "service": self.org.service,
+ "grouping_unit": "day",
+ },
+ ).end_date == datetime.date(timezone.now())
+
+
+@patch("api.shared.permissions.RepositoryPermissionsService.has_read_permissions")
+class RepositoryCoverageChartTest(InternalAPITest):
+ def _retrieve(self, kwargs={}, data={}):
+ return self.client.post(
+ reverse("chart-coverage-repository", kwargs=kwargs),
+ data=data,
+ content_type="application/json",
+ )
+
+ def setUp(self):
+ self.org1 = OwnerFactory()
+ self.repo1_org1 = RepositoryFactory(author=self.org1)
+ setup_commits(self.repo1_org1, 10, start_date="-4d")
+
+ self.current_owner = OwnerFactory(
+ service="github",
+ organizations=[self.org1.ownerid],
+ permission=[self.repo1_org1.repoid],
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_no_permissions(self, mocked_get_permissions):
+ data = {
+ "branch": "main",
+ "start_date": timezone.now() - timedelta(7),
+ "end_date": timezone.now(),
+ "grouping_unit": "commit",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ kwargs = {"owner_username": self.org1.username, "service": "gh"}
+
+ mocked_get_permissions.return_value = False
+ response = self._retrieve(kwargs=kwargs, data=data)
+
+ # 404 for security to hide existence of repo
+ assert response.status_code == 404
+
+ # when "grouping_unit" is commit we just return all the commits with no grouping/aggregation
+ @pytest.mark.skip(reason="flaky, skipping until re write")
+ def test_get_commits_no_time_grouping(self, mocked_get_permissions):
+ data = {
+ "branch": "main",
+ "start_date": timezone.now() - timedelta(7),
+ "end_date": timezone.now(),
+ "grouping_unit": "commit",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ kwargs = {"owner_username": self.org1.username, "service": "gh"}
+
+ mocked_get_permissions.return_value = True
+ response = self._retrieve(kwargs=kwargs, data=data)
+
+ assert response.status_code == 200
+ assert len(response.data["coverage"]) == 10
+ assert len(response.data["complexity"]) == 10
+
+ def test_get_commits_with_time_grouping(self, mocked_get_permissions):
+ data = {
+ "branch": "main",
+ "start_date": timezone.now() - timedelta(7),
+ "end_date": timezone.now(),
+ "grouping_unit": "day",
+ "agg_function": "max",
+ "agg_value": "coverage",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ kwargs = {"owner_username": self.org1.username, "service": "gh"}
+
+ mocked_get_permissions.return_value = True
+ response = self._retrieve(kwargs=kwargs, data=data)
+
+ assert response.status_code == 200
+ assert len(response.data["coverage"]) > 0
+ assert len(response.data["complexity"]) > 0
+
+ def test_get_commits_with_coverage_change(self, mocked_get_permissions):
+ data = {
+ "branch": "main",
+ "start_date": timezone.now() - timedelta(7),
+ "end_date": timezone.now(),
+ "grouping_unit": "day",
+ "agg_function": "max",
+ "agg_value": "coverage",
+ "repositories": [self.repo1_org1.name],
+ }
+
+ kwargs = {"owner_username": self.org1.username, "service": "gh"}
+
+ mocked_get_permissions.return_value = True
+ response = self._retrieve(kwargs=kwargs, data=data)
+
+ assert response.status_code == 200
+
+ assert len(response.data["coverage"]) > 1
+ # Verify that the coverage change was properly computed
+ for index in range(len(response.data["coverage"])):
+ commit = response.data["coverage"][index]
+
+ # First commit should always have change = 0 since it changed nothing
+ if index == 0:
+ assert commit["coverage_change"] == 0
+ else:
+ assert (
+ commit["coverage_change"]
+ == commit["coverage"]
+ - response.data["coverage"][index - 1]["coverage"]
+ )
+
+
+class TestOrganizationChartHandler(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo1 = RepositoryFactory(author=self.org, active=True)
+ self.repo2 = RepositoryFactory(author=self.org, active=True)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo1.repoid, self.repo2.repoid]
+ )
+ self.commit1 = CommitFactory(
+ repository=self.repo1,
+ totals={"h": 100, "n": 120, "p": 10, "m": 10},
+ branch=self.repo1.branch,
+ state="complete",
+ )
+ self.commit2 = CommitFactory(
+ repository=self.repo2,
+ totals={"h": 14, "n": 25, "p": 6, "m": 5},
+ branch=self.repo2.branch,
+ state="complete",
+ )
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def _get(self, kwargs={}, data={}):
+ return self.client.get(
+ reverse("chart-coverage-organization", kwargs=kwargs),
+ data=data,
+ content_type="application/json",
+ )
+
+ def test_basic_success(self):
+ response = self._get(
+ kwargs={"owner_username": self.org.username, "service": self.org.service},
+ data={
+ "grouping_unit": "day",
+ "repositories": [self.repo1.name, self.repo2.name],
+ },
+ )
+
+ assert response.status_code == 200
+ assert len(response.data["coverage"]) == 1
+ assert response.data["coverage"][0]["total_hits"] == 114
+ assert response.data["coverage"][0]["total_lines"] == 145
+ assert response.data["coverage"][0]["total_misses"] == 15
+ assert response.data["coverage"][0]["total_partials"] == 16
diff --git a/apps/codecov-api/api/internal/tests/test_feature.py b/apps/codecov-api/api/internal/tests/test_feature.py
new file mode 100644
index 0000000000..9712ce4f69
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_feature.py
@@ -0,0 +1,230 @@
+import json
+
+import pytest
+from django.urls import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.django_apps.rollouts.models import (
+ FeatureFlag,
+ FeatureFlagVariant,
+ RolloutUniverse,
+)
+
+from utils.test_utils import Client
+
+
+class FeatureEndpointTests(APITestCase):
+ def setUp(self):
+ self.client = Client()
+ self.owner = OwnerFactory(plan="users-free", plan_user_count=5)
+ self.client.force_login_owner(self.owner)
+
+ def send_feature_request(self, data: dict):
+ return self.client.post(
+ reverse("features"), data=json.dumps(data), content_type="application/json"
+ )
+
+ def test_invalid_request_body(self):
+ data = {
+ "feature_flagsssss": ["fjdsioj"],
+ "identifier_dataa": {
+ "email": "dsfio",
+ "user_id": 1,
+ "org_id": 2,
+ "repo_id": 3,
+ },
+ }
+
+ res = self.send_feature_request(data)
+ self.assertEqual(res.status_code, 400)
+
+ def test_valid_request_body(self):
+ data = {
+ "feature_flags": [],
+ "identifier_data": {
+ "email": "daniel.yu@sentry.io",
+ "user_id": 0,
+ "org_id": 0,
+ "repo_id": 0,
+ },
+ }
+
+ res = self.send_feature_request(data)
+ self.assertEqual(res.status_code, 200)
+
+ def test_variant_assigned_true(self):
+ feature_a = FeatureFlag.objects.create(
+ name="feature_a", proportion=1.0, salt="random_salt"
+ )
+ FeatureFlagVariant.objects.create(
+ name="enabled",
+ feature_flag=feature_a,
+ proportion=1.0,
+ value=True,
+ )
+
+ feature_b = FeatureFlag.objects.create(
+ name="feature_b", proportion=1.0, salt="random_salt"
+ )
+ FeatureFlagVariant.objects.create(
+ name="enabled",
+ feature_flag=feature_b,
+ proportion=1.0,
+ value=True,
+ )
+
+ data = {
+ "feature_flags": ["feature_a", "feature_b"],
+ "identifier_data": {
+ "email": "d",
+ "user_id": 1,
+ "org_id": 1,
+ "repo_id": 1,
+ },
+ }
+
+ res = self.send_feature_request(data)
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.data["feature_a"], True)
+ self.assertEqual(res.data["feature_b"], True)
+
+ def test_variant_assigned_false(self):
+ feature_aaa = FeatureFlag.objects.create(
+ name="feature_aaa", proportion=1.0, salt="random_salt"
+ )
+ FeatureFlagVariant.objects.create(
+ name="disabled",
+ feature_flag=feature_aaa,
+ proportion=1.0,
+ value=False,
+ )
+
+ feature_bbb = FeatureFlag.objects.create(
+ name="feature_bbb", proportion=1.0, salt="random_salt"
+ )
+ FeatureFlagVariant.objects.create(
+ name="disabled",
+ feature_flag=feature_bbb,
+ proportion=1.0,
+ value=False,
+ )
+
+ data = {
+ "feature_flags": ["feature_aaa", "feature_bbb"],
+ "identifier_data": {
+ "email": "d",
+ "user_id": 1,
+ "org_id": 1,
+ "repo_id": 1,
+ },
+ }
+
+ res = self.send_feature_request(data)
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.data["feature_aaa"], False)
+ self.assertEqual(res.data["feature_bbb"], False)
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "rollout_universe,o_emails,o_owner_ids,o_repo_ids,o_org_ids,o_values",
+ [
+ (
+ RolloutUniverse.EMAIL,
+ (["david@gmail.com"], ["daniel@gmail.com"]),
+ ([], []),
+ ([], []),
+ ([], []),
+ (1, 2),
+ ),
+ (
+ RolloutUniverse.OWNER_ID,
+ ([], []),
+ (["1"], ["2"]),
+ ([], []),
+ ([], []),
+ (3, 4),
+ ),
+ (
+ RolloutUniverse.REPO_ID,
+ ([], []),
+ ([], []),
+ (["21"], ["31"]),
+ ([], []),
+ (5, 6),
+ ),
+ (
+ RolloutUniverse.ORG_ID,
+ ([], []),
+ ([], []),
+ ([], []),
+ (["11"], ["21"]),
+ (7, 8),
+ ),
+ ],
+)
+def test_overrides_by_email(
+ rollout_universe, o_emails, o_owner_ids, o_repo_ids, o_org_ids, o_values
+):
+ overrides = FeatureFlag.objects.create(
+ name="overrides_" + str(rollout_universe),
+ proportion=1.0,
+ rollout_universe=rollout_universe,
+ )
+ FeatureFlagVariant.objects.create(
+ name="overrides_a",
+ feature_flag=overrides,
+ proportion=1 / 3,
+ value=o_values[0],
+ override_emails=o_emails[0],
+ override_owner_ids=o_owner_ids[0],
+ override_repo_ids=o_repo_ids[0],
+ override_org_ids=o_org_ids[0],
+ )
+ FeatureFlagVariant.objects.create(
+ name="overrides_b",
+ feature_flag=overrides,
+ proportion=1 / 3,
+ value=o_values[1],
+ override_emails=o_emails[1],
+ override_owner_ids=o_owner_ids[1],
+ override_repo_ids=o_repo_ids[1],
+ override_org_ids=o_org_ids[1],
+ )
+ FeatureFlagVariant.objects.create(
+ name="overrides_c",
+ feature_flag=overrides,
+ proportion=1 / 3,
+ value="dfjosijsdiofjdos",
+ )
+
+ data1 = {
+ "feature_flags": ["overrides_" + str(rollout_universe)],
+ "identifier_data": {
+ "email": o_emails[0][0] if o_emails[0] else "",
+ "user_id": o_owner_ids[0][0] if o_owner_ids[0] else 0,
+ "org_id": o_org_ids[0][0] if o_org_ids[0] else 0,
+ "repo_id": o_repo_ids[0][0] if o_repo_ids[0] else 0,
+ },
+ }
+ mock = FeatureEndpointTests()
+ mock.setUp()
+ res1 = mock.send_feature_request(data1)
+
+ data2 = {
+ "feature_flags": ["overrides_" + str(rollout_universe)],
+ "identifier_data": {
+ "email": o_emails[1][0] if o_emails[1] else "",
+ "user_id": o_owner_ids[1][0] if o_owner_ids[1] else 0,
+ "org_id": o_org_ids[1][0] if o_org_ids[1] else 0,
+ "repo_id": o_repo_ids[1][0] if o_repo_ids[1] else 0,
+ },
+ }
+ res2 = mock.send_feature_request(data2)
+
+ assert res1.status_code == 200
+ assert res1.data["overrides_" + str(rollout_universe)] == o_values[0]
+ assert res2.status_code == 200
+ assert res2.data["overrides_" + str(rollout_universe)] == o_values[1]
diff --git a/apps/codecov-api/api/internal/tests/test_pagination.py b/apps/codecov-api/api/internal/tests/test_pagination.py
new file mode 100644
index 0000000000..ca972f18c2
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_pagination.py
@@ -0,0 +1,45 @@
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import TierName
+
+from utils.test_utils import Client
+
+
+class PageNumberPaginationTests(APITestCase):
+ def setUp(self):
+ self.client = Client()
+ tier = TierFactory(tier_name=TierName.BASIC.value)
+ plan = PlanFactory(tier=tier, is_active=True)
+ self.owner = OwnerFactory(plan=plan.name, plan_user_count=5)
+ self.users = [
+ OwnerFactory(organizations=[self.owner.ownerid]),
+ OwnerFactory(organizations=[self.owner.ownerid]),
+ OwnerFactory(organizations=[self.owner.ownerid]),
+ ]
+
+ def test_pagination_returned_page_size(self):
+ self.client.force_login_owner(self.owner)
+
+ def _list(kwargs={}, query_params={}):
+ if not kwargs:
+ kwargs = {
+ "service": self.owner.service,
+ "owner_username": self.owner.username,
+ }
+ return self.client.get(
+ reverse("users-list", kwargs=kwargs), data=query_params
+ )
+
+ response = _list()
+
+ assert response.data["total_pages"] == 1
+
+ response = _list(query_params={"page_size": "1"})
+
+ assert response.data["total_pages"] == 3
+
+ response = _list(query_params={"page_size": "100"})
+
+ assert response.data["total_pages"] == 1
diff --git a/apps/codecov-api/api/internal/tests/test_permissions.py b/apps/codecov-api/api/internal/tests/test_permissions.py
new file mode 100644
index 0000000000..2b23d6753e
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_permissions.py
@@ -0,0 +1,195 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from rest_framework.exceptions import APIException
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from api.internal.tests.test_utils import (
+ GetAdminErrorProviderAdapter,
+ GetAdminProviderAdapter,
+)
+from api.shared.permissions import RepositoryPermissionsService, UserIsAdminPermissions
+
+
+class MockedPermissionsAdapter:
+ async def get_authenticated(self):
+ return True, True
+
+
+class TestRepositoryPermissionsService(TestCase):
+ def setUp(self):
+ self.permissions_service = RepositoryPermissionsService()
+
+ def test_has_read_permissions_returns_true_if_user_is_owner(self):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ assert self.permissions_service.has_read_permissions(owner, repo) is True
+
+ def test_has_read_permissions_returns_true_if_repoid_in_permission_array(self):
+ repo = RepositoryFactory(author=OwnerFactory())
+ owner = OwnerFactory(permission=[repo.repoid])
+ assert self.permissions_service.has_read_permissions(owner, repo) is True
+
+ def test_has_read_permissions_returns_true_if_repo_not_private(self):
+ repo = RepositoryFactory(private=False)
+ owner = OwnerFactory()
+ assert self.permissions_service.has_read_permissions(owner, repo) is True
+
+ @patch(
+ "api.shared.permissions.RepositoryPermissionsService._fetch_provider_permissions"
+ )
+ def test_has_read_permissions_gets_permissions_from_provider_if_above_conds_not_met(
+ self, fetch_mock
+ ):
+ fetch_mock.return_value = True, False
+ repo = RepositoryFactory()
+ owner = OwnerFactory()
+
+ assert self.permissions_service.has_read_permissions(owner, repo) is True
+
+ fetch_mock.assert_called_once_with(owner, repo)
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_fetch_provider_permissions_fetches_permissions_from_provider(
+ self, mocked_provider
+ ):
+ mocked_provider.return_value = MockedPermissionsAdapter()
+ repo = RepositoryFactory()
+ owner = OwnerFactory()
+
+ assert self.permissions_service._fetch_provider_permissions(owner, repo) == (
+ True,
+ True,
+ )
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_fetch_provider_permissions_caches_read_permissions(self, mocked_provider):
+ mocked_provider.return_value = MockedPermissionsAdapter()
+ repo = RepositoryFactory()
+ owner = OwnerFactory()
+ self.permissions_service._fetch_provider_permissions(owner, repo)
+
+ owner.refresh_from_db()
+ assert repo.repoid in owner.permission
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_fetch_provider_permissions_caches_read_permissions_when_owner_has_no_permissions(
+ self, mocked_provider
+ ):
+ mocked_provider.return_value = MockedPermissionsAdapter()
+ repo = RepositoryFactory()
+ owner = OwnerFactory(permission=None)
+ self.permissions_service._fetch_provider_permissions(owner, repo)
+
+ owner.refresh_from_db()
+ assert repo.repoid in owner.permission
+
+ def test_user_is_activated_returns_false_if_user_not_in_owner_org(self):
+ with self.subTest("user orgs is None"):
+ user = OwnerFactory()
+ owner = OwnerFactory(plan="users-inappy")
+ assert self.permissions_service.user_is_activated(user, owner) is False
+
+ with self.subTest("owner not in user orgs"):
+ owner = OwnerFactory(plan="users-inappy")
+ user = OwnerFactory(organizations=[])
+ assert self.permissions_service.user_is_activated(user, owner) is False
+
+ def test_user_is_activated_returns_false_if_owner_is_none(self):
+ user = OwnerFactory()
+ assert self.permissions_service.user_is_activated(user, None) is False
+
+ def test_user_is_activated_returns_false_if_user_is_none(self):
+ owner = OwnerFactory()
+ assert self.permissions_service.user_is_activated(None, owner) is False
+
+ def test_user_is_activated_returns_true_when_owner_has_legacy_plan(self):
+ user = OwnerFactory()
+ owner = OwnerFactory(plan="v4-50m")
+ assert self.permissions_service.user_is_activated(user, owner) is True
+
+ def test_user_is_activated_returns_true_when_user_is_owner(self):
+ user = OwnerFactory()
+ assert self.permissions_service.user_is_activated(user, user) is True
+
+ def test_user_is_activated_returns_true_if_user_is_activated(self):
+ user = OwnerFactory()
+ owner = OwnerFactory(plan="users-inappy", plan_activated_users=[user.ownerid])
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ assert self.permissions_service.user_is_activated(user, owner) is True
+
+ def test_user_is_activated_activates_user_and_returns_true_if_can_auto_activate(
+ self,
+ ):
+ user = OwnerFactory()
+ owner = OwnerFactory(
+ plan="users-inappy", plan_auto_activate=True, plan_user_count=1
+ )
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ assert self.permissions_service.user_is_activated(user, owner) is True
+
+ owner.refresh_from_db()
+ assert user.ownerid in owner.plan_activated_users
+
+ @patch("services.self_hosted.license_seats")
+ @override_settings(IS_ENTERPRISE=True)
+ def test_user_is_activated_when_self_hosted(self, license_seats):
+ license_seats.return_value = 5
+
+ user = OwnerFactory()
+ owner = OwnerFactory(plan_auto_activate=True)
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ assert self.permissions_service.user_is_activated(user, owner) is True
+
+ owner.refresh_from_db()
+ assert user.ownerid in owner.plan_activated_users
+
+ def test_user_is_activated_returns_false_if_cant_auto_activate(self):
+ owner = OwnerFactory(plan="users-inappy", plan_user_count=10)
+ user = OwnerFactory(organizations=[owner.ownerid])
+
+ with self.subTest("auto activate set to false"):
+ owner.plan_auto_activate = False
+ owner.save()
+ assert self.permissions_service.user_is_activated(user, owner) is False
+
+ with self.subTest("auto activate true but not enough seats"):
+ owner.plan_auto_activate = True
+ owner.plan_user_count = 0
+ owner.save()
+ assert self.permissions_service.user_is_activated(user, owner) is False
+
+
+class TestUserIsAdminPermissions(TestCase):
+ def setUp(self):
+ self.permissions_class = UserIsAdminPermissions()
+
+ @patch("api.shared.permissions.get_provider")
+ def test_is_admin_on_provider_invokes_torngit_adapter_when_user_not_in_admin_array(
+ self, mocked_get_adapter
+ ):
+ org = OwnerFactory()
+ user = OwnerFactory()
+
+ mocked_get_adapter.return_value = GetAdminProviderAdapter()
+ self.permissions_class._is_admin_on_provider(user, org)
+ assert mocked_get_adapter.return_value.last_call_args == {
+ "username": user.username,
+ "service_id": user.service_id,
+ }
+
+ @patch("api.shared.permissions.get_provider")
+ def test_is_admin_on_provider_handles_torngit_exception(self, mock_get_provider):
+ code, message = 404, "uh oh"
+ mock_get_provider.return_value = GetAdminErrorProviderAdapter(code, message)
+ org = OwnerFactory()
+ user = OwnerFactory()
+
+ with self.assertRaises(APIException):
+ self.permissions_class._is_admin_on_provider(user, org)
diff --git a/apps/codecov-api/api/internal/tests/test_repo_accessors.py b/apps/codecov-api/api/internal/tests/test_repo_accessors.py
new file mode 100644
index 0000000000..2ac448146b
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_repo_accessors.py
@@ -0,0 +1,89 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.torngit.exceptions import TorngitClientError, TorngitClientGeneralError
+
+from api.shared.repo.repository_accessors import RepoAccessors
+
+
+class RepositoryAccessorsTestCase(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+
+ self.repo1 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="A"
+ )
+ self.repo2 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="B"
+ )
+
+ self.user = OwnerFactory(
+ username="codecov-user", service="github", organizations=[self.org.ownerid]
+ )
+
+ self.client.force_login(user=self.user)
+
+ def test_get_repo_permissions_when_author(self):
+ user = OwnerFactory(username="myself", service="github")
+ repo = RepositoryFactory(author=user, active=True, private=True, name="A")
+ can_view, can_edit = RepoAccessors().get_repo_permissions(user, repo)
+ assert (can_view, can_edit) == (True, True)
+
+ def test_get_repo_details_if_exists(self):
+ repo = RepoAccessors.get_repo_details(
+ self, self.user, self.repo1.name, self.org.username, self.org.service
+ )
+ self.assertEqual(repo, self.repo1)
+
+ def test_get_repo_details_if_not_exists(self):
+ repo = RepoAccessors.get_repo_details(
+ self, self.user, "repo-not-in-db", self.org.username, self.org.service
+ )
+ self.assertEqual(repo, None)
+
+ @patch("services.repo_providers.RepoProviderService.get_by_name")
+ def test_fetch_and_create_repo(self, mocked_repo_provider_service):
+ git_repo_response = {
+ "repo": {
+ "name": "new-repo",
+ "branch": "default",
+ "private": True,
+ "service_id": "7293846",
+ "fork": {
+ "repo": {
+ "name": "fork-repo",
+ "branch": "master",
+ "private": True,
+ "service_id": "4720394",
+ },
+ "owner": {"username": "fork_owner", "service_id": "0956093"},
+ },
+ },
+ "owner": {"username": "new-org", "service_id": "9437469"},
+ }
+
+ class MockedRepoService:
+ async def get_repository(self):
+ return git_repo_response
+
+ mocked_repo_provider_service.return_value = MockedRepoService()
+
+ repo = RepoAccessors.fetch_from_git_and_create_repo(
+ self, self.user, "new-repo", "new-org", "github"
+ )
+ assert repo.name == git_repo_response["repo"]["name"]
+ assert repo.fork is not None
+
+ @patch("services.repo_providers.RepoProviderService")
+ def test_fetch_and_create_repo_if_torngit_error(self, mocked_repo_provider_service):
+ class MockedRepoService:
+ async def get_repository(self):
+ raise TorngitClientGeneralError(404, response=None, message="Not Found")
+
+ mocked_repo_provider_service.return_value = MockedRepoService()
+
+ with self.assertRaises(TorngitClientError):
+ RepoAccessors.fetch_from_git_and_create_repo(
+ self, self.user, "repo-not-in-db", self.org.username, self.org.service
+ )
diff --git a/apps/codecov-api/api/internal/tests/test_utils.py b/apps/codecov-api/api/internal/tests/test_utils.py
new file mode 100644
index 0000000000..f0c52477c4
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_utils.py
@@ -0,0 +1,45 @@
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+
+def to_drf_datetime_str(datetime):
+ """
+ DRF does custom datetime representation, which makes comparing
+ expected timestamps in tests really annoying. This function tries
+ to mimic DRF datetime representation using ISO-8601 format, minus
+ reading from gobal formatting settings.
+
+ source: https://github.com/encode/django-rest-framework/blob/aed74961ba03e3e6f53c468353f4e255eb788555/rest_framework/fields.py#L1227
+ """
+ value = datetime.isoformat()
+ if value.endswith("+00:00"):
+ value = value[:-6] + "Z"
+ return value
+
+
+class GetAdminProviderAdapter:
+ """
+ Mock adapter providing the `get_is_admin` coroutine, which returns `self.result`.
+ """
+
+ def __init__(self, result=False):
+ self.result = result
+ self.last_call_args = None
+
+ async def get_is_admin(self, user):
+ self.last_call_args = user
+ return self.result
+
+
+class GetAdminErrorProviderAdapter:
+ """
+ Mock adapter that raises a torngit error.
+ """
+
+ def __init__(self, code, message):
+ self.code = code
+ self.message = message
+
+ async def get_is_admin(self, user):
+ raise TorngitClientGeneralError(
+ self.code, response_data=None, message=self.message
+ )
diff --git a/apps/codecov-api/api/internal/tests/test_views.py b/apps/codecov-api/api/internal/tests/test_views.py
new file mode 100644
index 0000000000..8ee8a1c5ef
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/test_views.py
@@ -0,0 +1,742 @@
+import json
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from codecov.tests.base_test import InternalAPITest
+from utils.test_utils import Client
+
+get_permissions_method = (
+ "api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions"
+)
+
+
+@patch(get_permissions_method)
+class RepoPullList(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ other_org = OwnerFactory(username="other_org")
+ # Create different types of repos / pulls
+ self.repo = RepositoryFactory(author=self.org, name="testRepoName", active=True)
+ other_repo = RepositoryFactory(
+ author=other_org, name="otherRepoName", active=True
+ )
+ repo_with_permission = [self.repo.repoid]
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repo_with_permission,
+ )
+ PullFactory(
+ pullid=10,
+ author=self.org,
+ repository=self.repo,
+ state="open",
+ head=CommitFactory(
+ repository=self.repo, author=self.current_owner
+ ).commitid,
+ base=CommitFactory(
+ repository=self.repo, author=self.current_owner
+ ).commitid,
+ )
+ PullFactory(pullid=11, author=self.org, repository=self.repo, state="closed")
+ PullFactory(pullid=12, author=other_org, repository=other_repo)
+ self.correct_kwargs = {
+ "service": "github",
+ "owner_username": "codecov",
+ "repo_name": "testRepoName",
+ }
+ self.incorrect_kwargs = {
+ "service": "github",
+ "owner_username": "codecov",
+ "repo_name": "otherRepoName",
+ }
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_can_get_public_repo_pulls_when_not_authenticated(self, mock_provider):
+ self.client.logout()
+ mock_provider.return_value = True, True
+ author = OwnerFactory()
+ repo = RepositoryFactory(private=False, author=author)
+ response = self.client.get(
+ reverse(
+ "pulls-list",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ },
+ )
+ )
+ assert response.status_code == 200
+ assert response.data["results"] == []
+
+ def test_get_pulls(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ self.assertEqual(response.status_code, 200)
+ content = self.json_content(response)
+ self.assertEqual(
+ len(content["results"]),
+ 3,
+ "got the wrong number of pulls: {}".format(content["results"]),
+ )
+
+ def test_get_pulls_no_permissions(self, mock_provider):
+ mock_provider.return_value = False, False
+ self.current_owner.permission = []
+ self.current_owner.save()
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ self.assertEqual(response.status_code, 404)
+
+ def test_get_pulls_filter_state(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get(
+ reverse("pulls-list", kwargs=self.correct_kwargs), data={"state": "open"}
+ )
+ self.assertEqual(response.status_code, 200)
+ content = self.json_content(response)
+ self.assertEqual(
+ len(content["results"]),
+ 2,
+ "got the wrong number of open pulls: {}".format(content["results"]),
+ )
+
+ def test_get_pulls_ordered_by_pullid(self, mock_provider):
+ mock_provider.return_value = True, True
+ # Test increasing ordering
+ response = self.client.get(
+ reverse("pulls-list", kwargs=self.correct_kwargs),
+ data={"ordering": "pullid"},
+ )
+ content = self.json_content(response)
+ pullids = [r["pullid"] for r in content["results"]]
+ self.assertEqual(pullids, [1, 10, 11])
+ # Test decreasing ordering
+ response = self.client.get(
+ reverse("pulls-list", kwargs=self.correct_kwargs),
+ data={"ordering": "-pullid"},
+ )
+ content = self.json_content(response)
+ pullids = [r["pullid"] for r in content["results"]]
+ self.assertEqual(pullids, [11, 10, 1])
+
+ def test_get_pulls_default_ordering(self, mock_provider):
+ mock_provider.return_value = True, True
+ # Test default ordering
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ content = self.json_content(response)
+ pullids = [r["pullid"] for r in content["results"]]
+ self.assertEqual(pullids, [11, 10, 1])
+
+ def test_get_pull_wrong_org(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get(reverse("pulls-list", kwargs=self.incorrect_kwargs))
+ content = self.json_content(response)
+ self.assertEqual(
+ response.status_code, 404, "got unexpected response: {}".format(content)
+ )
+
+ def test_pulls_list_returns_most_recent_commiter(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+
+ assert (
+ response.data["results"][1]["most_recent_commiter"]
+ == self.current_owner.username
+ )
+
+ def test_get_pulls_null_head_author_doesnt_crash(self, mock_provider):
+ mock_provider.return_value = True, True
+ new_owner = OwnerFactory()
+
+ PullFactory(
+ pullid=13,
+ author=self.org,
+ repository=self.repo,
+ state="open",
+ head=CommitFactory(repository=self.repo, author=new_owner).commitid,
+ base=CommitFactory(repository=self.repo, author=new_owner).commitid,
+ )
+
+ new_owner.delete()
+
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_get_pulls_no_head_commit_returns_null_for_head_totals(self, mock_provider):
+ mock_provider.return_value = True, True
+
+ PullFactory(
+ pullid=13,
+ author=self.org,
+ repository=self.repo,
+ state="open",
+ head="",
+ base=CommitFactory(
+ repository=self.repo, author=self.current_owner
+ ).commitid,
+ )
+
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ assert response.status_code == status.HTTP_200_OK
+ assert [p for p in response.data["results"] if p["pullid"] == 13][0][
+ "head_totals"
+ ] is None
+
+ def test_get_pulls_no_base_commit_returns_null_for_base_totals(self, mock_provider):
+ mock_provider.return_value = True, True
+
+ PullFactory(
+ pullid=13,
+ author=self.org,
+ repository=self.repo,
+ state="open",
+ base="",
+ head=CommitFactory(
+ repository=self.repo, author=self.current_owner
+ ).commitid,
+ )
+
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ assert response.status_code == status.HTTP_200_OK
+ assert [p for p in response.data["results"] if p["pullid"] == 13][0][
+ "base_totals"
+ ] is None
+
+ def test_get_pulls_as_inactive_user_returns_403(self, mock_provider):
+ self.org.plan = "users-inappm"
+ self.org.plan_auto_activate = False
+ self.org.save()
+ response = self.client.get(reverse("pulls-list", kwargs=self.correct_kwargs))
+ assert response.status_code == 403
+
+ def test_list_pulls_comparedto_not_base(self, mock_provider):
+ repo = RepositoryFactory.create(
+ author=self.org, name="test_list_pulls_comparedto_not_base", active=True
+ )
+ repo.save()
+ user = OwnerFactory.create(
+ username="test_list_pulls_comparedto_not_base",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[repo.repoid],
+ )
+ user.save()
+ mock_provider.return_value = True, True
+ pull = PullFactory.create(
+ pullid=101,
+ author=self.org,
+ repository=repo,
+ state="open",
+ head=CommitFactory(repository=repo, author=user, pullid=None).commitid,
+ base=CommitFactory(
+ repository=repo, pullid=None, author=user, totals=None
+ ).commitid,
+ compared_to=CommitFactory(
+ pullid=None,
+ repository=repo,
+ author=user,
+ totals={
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "30.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 6,
+ "m": 10,
+ "n": 20,
+ "p": 4,
+ "s": 1,
+ },
+ ).commitid,
+ )
+ response = self.client.get(
+ reverse(
+ "pulls-list",
+ kwargs={
+ "service": "github",
+ "owner_username": "codecov",
+ "repo_name": "test_list_pulls_comparedto_not_base",
+ },
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ content = self.json_content(response)
+ expected_content = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "pullid": 101,
+ "title": pull.title,
+ "most_recent_commiter": "test_list_pulls_comparedto_not_base",
+ "base_totals": {
+ "files": 3,
+ "lines": 20,
+ "hits": 6,
+ "misses": 10,
+ "partials": 4,
+ "coverage": 30.0,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "head_totals": {
+ "files": 3,
+ "lines": 20,
+ "hits": 17,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 85.0,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ # This whole TZ settings is messing things up a bit
+ "updatestamp": pull.updatestamp.replace(tzinfo=None).isoformat()
+ + "Z",
+ "state": "open",
+ "ci_passed": True,
+ }
+ ],
+ "total_pages": 1,
+ }
+ assert content["results"][0] == expected_content["results"][0]
+ assert content == expected_content
+
+
+@patch(get_permissions_method)
+class RepoPullDetail(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ other_org = OwnerFactory(username="other_org")
+ # Create different types of repos / pulls
+ repo = RepositoryFactory(author=self.org, name="testRepoName", active=True)
+ RepositoryFactory(author=other_org, name="otherRepoName", active=True)
+ repo_with_permission = [repo.repoid]
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repo_with_permission,
+ )
+ PullFactory(pullid=10, author=self.org, repository=repo, state="open")
+ PullFactory(pullid=11, author=self.org, repository=repo, state="closed")
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_can_get_public_repo_pull_detail_when_not_authenticated(
+ self, mock_provider
+ ):
+ self.client.logout()
+ mock_provider.return_value = True, True
+ author = OwnerFactory()
+ repo = RepositoryFactory(private=False, author=author)
+ pull = PullFactory(repository=repo)
+ response = self.client.get(
+ reverse(
+ "pulls-detail",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ "pullid": pull.pullid,
+ },
+ )
+ )
+ assert response.status_code == 200
+ assert response.data["pullid"] == pull.pullid
+
+ def test_get_pull(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get("/internal/github/codecov/testRepoName/pulls/10/")
+ self.assertEqual(response.status_code, 200)
+ content = self.json_content(response)
+ self.assertEqual(content["pullid"], 10)
+
+ def test_get_pull_no_permissions(self, mock_provider):
+ self.current_owner.permission = []
+ self.current_owner.save()
+ mock_provider.return_value = False, False
+ response = self.client.get("/internal/github/codecov/testRepoName/pulls/10/")
+ self.assertEqual(response.status_code, 404)
+
+ def test_get_pull_as_inactive_user_returns_403(self, mock_provider):
+ self.org.plan = "users-inappm"
+ self.org.plan_auto_activate = False
+ self.org.save()
+ response = self.client.get("/internal/github/codecov/testRepoName/pulls/10/")
+ assert response.status_code == 403
+
+
+@patch(get_permissions_method)
+class RepoCommitList(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ other_org = OwnerFactory(username="other_org")
+ # Create different types of repos / commits
+ self.repo = RepositoryFactory(author=self.org, name="testRepoName", active=True)
+ other_repo = RepositoryFactory(
+ author=other_org, name="otherRepoName", active=True
+ )
+ repo_with_permission = [self.repo.repoid]
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repo_with_permission,
+ )
+ self.first_test_commit = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ totals={
+ "C": 2,
+ "M": 0,
+ "N": 5,
+ "b": 0,
+ "c": "79.16667",
+ "d": 0,
+ "f": 3,
+ "h": 19,
+ "m": 5,
+ "n": 24,
+ "p": 0,
+ "s": 2,
+ "diff": 0,
+ },
+ )
+ self.second_test_commit = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ totals={
+ "C": 3,
+ "M": 0,
+ "N": 5,
+ "b": 0,
+ "c": "79.16667",
+ "d": 0,
+ "f": 3,
+ "h": 19,
+ "m": 5,
+ "n": 24,
+ "p": 0,
+ "s": 2,
+ "diff": 0,
+ },
+ )
+ self.third_test_commit = CommitFactory(
+ author=other_org,
+ repository=other_repo,
+ totals={
+ "C": 3,
+ "M": 0,
+ "N": 6,
+ "b": 0,
+ "c": "79.16667",
+ "d": 0,
+ "f": 3,
+ "h": 19,
+ "m": 5,
+ "n": 24,
+ "p": 0,
+ "s": 2,
+ "diff": 0,
+ },
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_can_get_public_repo_commits_if_not_authenticated(self, mocked_provider):
+ mocked_provider.return_value = True, True
+ self.client.logout()
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author, private=False)
+ response = self.client.get(
+ reverse(
+ "commits-list",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ },
+ )
+ )
+ assert response.status_code == 200
+
+ # TODO: Improve this test to not assert the pagination data
+ def test_get_commits(self, mock_provider):
+ mock_provider.return_value = True, True
+ response = self.client.get("/internal/github/codecov/testRepoName/commits/")
+ self.assertEqual(response.status_code, 200)
+ content = self.json_content(response)
+ self.assertEqual(
+ len(content["results"]),
+ 2,
+ "got the wrong number of commits: {}".format(content["results"]),
+ )
+ expected_result = {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "total_pages": 1,
+ "results": [
+ {
+ "commitid": self.second_test_commit.commitid,
+ "message": self.second_test_commit.message,
+ "timestamp": self.second_test_commit.timestamp.strftime(
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ),
+ "ci_passed": self.second_test_commit.ci_passed,
+ "author": {
+ "service": self.org.service,
+ "username": self.org.username,
+ "avatar_url": self.org.avatar_url,
+ "stats": self.org.cache["stats"]
+ if self.org.cache and "stats" in self.org.cache
+ else None,
+ "name": self.org.name,
+ "ownerid": self.org.ownerid,
+ "integration_id": self.org.integration_id,
+ },
+ "branch": self.second_test_commit.branch,
+ "totals": {
+ "branches": 0,
+ "complexity": 3.0,
+ "complexity_total": 5.0,
+ "complexity_ratio": 60.0,
+ "coverage": 79.16,
+ "diff": 0,
+ "files": 3,
+ "hits": 19,
+ "lines": 24,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 2,
+ },
+ "state": self.second_test_commit.state,
+ },
+ {
+ "commitid": self.first_test_commit.commitid,
+ "message": self.first_test_commit.message,
+ "timestamp": self.first_test_commit.timestamp.strftime(
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ),
+ "ci_passed": self.first_test_commit.ci_passed,
+ "author": {
+ "service": self.org.service,
+ "username": self.org.username,
+ "avatar_url": self.org.avatar_url,
+ "stats": self.org.cache["stats"]
+ if self.org.cache and "stats" in self.org.cache
+ else None,
+ "name": self.org.name,
+ "ownerid": self.org.ownerid,
+ "integration_id": self.org.integration_id,
+ },
+ "branch": self.first_test_commit.branch,
+ "totals": {
+ "branches": 0,
+ "complexity": 2.0,
+ "complexity_total": 5.0,
+ "complexity_ratio": 40.0,
+ "coverage": 79.16,
+ "diff": 0,
+ "files": 3,
+ "hits": 19,
+ "lines": 24,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 2,
+ },
+ "state": self.first_test_commit.state,
+ },
+ ],
+ }
+ assert content == expected_result
+
+ def test_get_commits_wrong_org(self, mock_provider):
+ response = self.client.get("/internal/github/codecov/otherRepoName/commits/")
+ content = self.json_content(response)
+ self.assertEqual(
+ response.status_code, 404, "got unexpected response: {}".format(content)
+ )
+
+ def test_filters_by_branch_name(self, mock_provider):
+ mock_provider.return_value = True, True
+ repo = RepositoryFactory(
+ author=self.current_owner, active=True, private=True, name="banana"
+ )
+ CommitFactory.create(
+ message="test_commits_base",
+ commitid="9193232a8fe3429496956ba82b5fed2583d1b5ec",
+ repository=repo,
+ )
+ commit_non_master = CommitFactory.create(
+ message="another_commit_not_on_master",
+ commitid="ddcc232a8fe3429496956ba82b5fed2583d1b5ec",
+ repository=repo,
+ branch="other-branch",
+ )
+
+ response = self.client.get("/internal/github/codecov-user/banana/commits/")
+ content = json.loads(response.content.decode())
+ assert len(content["results"]) == 2
+ assert content["results"][0]["commitid"] == commit_non_master.commitid
+
+ response = self.client.get(
+ "/internal/github/codecov-user/banana/commits/?branch=other-branch"
+ )
+ content = json.loads(response.content.decode())
+ assert len(content["results"]) == 1
+ assert content["results"][0]["commitid"] == commit_non_master.commitid
+
+ def test_fetch_commits_no_permissions(self, mock_provider):
+ mock_provider.return_value = False, False
+ self.current_owner.permission = []
+ self.current_owner.save()
+
+ response = self.client.get("/internal/github/codecov/testRepoName/commits/")
+
+ assert response.status_code == 404
+
+ def test_fetch_commits_inactive_user_returns_403(self, mock_provider):
+ self.org.plan = "users-inappm"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self.client.get("/internal/github/codecov/testRepoName/commits/")
+
+ assert response.status_code == 403
+
+
+@patch(get_permissions_method)
+class BranchViewSetTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid], organizations=[self.org.ownerid]
+ )
+ self.other_user = OwnerFactory(permission=[self.repo.repoid])
+
+ self.branches = [
+ BranchFactory(repository=self.repo, name="foo"),
+ BranchFactory(repository=self.repo, name="bar"),
+ ]
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def _get_branches(self, kwargs={}, query={}):
+ if not kwargs:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ return self.client.get(reverse("branches-list", kwargs=kwargs), data=query)
+
+ def test_can_get_public_repo_branches_if_not_authenticated(self, mocked_provider):
+ mocked_provider.return_value = True, True
+ self.client.logout()
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author, private=False)
+ response = self._get_branches(
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ }
+ )
+ assert response.status_code == 200
+
+ def test_list_returns_200_and_expected_branches(self, mock_provider):
+ response = self._get_branches()
+ assert response.status_code == 200
+ assert response.data["results"][0]["name"] == self.branches[1].name
+ assert response.data["results"][1]["name"] == self.branches[0].name
+
+ def test_list_without_permission_returns_403(self, mock_provider):
+ mock_provider.return_value = False, False
+ repo_no_permissions = RepositoryFactory(author=self.org)
+ response = self._get_branches(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": repo_no_permissions.name,
+ }
+ )
+ assert response.status_code == 404
+
+ def test_list_with_nonexistent_repo_returns_404(self, mock_provider):
+ nonexistent_repo_name = "existant"
+ response = self._get_branches(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": nonexistent_repo_name,
+ }
+ )
+ assert response.status_code == 404
+
+ def test_branch_data_includes_most_recent_commiter_of_each_branch(
+ self, mock_provider
+ ):
+ self.branches[0].head = CommitFactory(
+ repository=self.repo,
+ author=self.current_owner,
+ branch=self.branches[0].name,
+ ).commitid
+ self.branches[0].save()
+ self.branches[1].head = CommitFactory(
+ repository=self.repo, author=self.other_user, branch=self.branches[1].name
+ ).commitid
+ self.branches[1].save()
+
+ response = self._get_branches()
+
+ assert (
+ response.data["results"][0]["most_recent_commiter"]
+ == self.other_user.username
+ )
+ assert (
+ response.data["results"][1]["most_recent_commiter"]
+ == self.current_owner.username
+ )
+
+ def test_list_as_inactive_user_returns_403(self, mock_provider):
+ self.org.plan = "users-inappy"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self._get_branches()
+
+ assert response.status_code == 403
diff --git a/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_compare_file_src_accepts_pullid_query_parameter.yaml b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_compare_file_src_accepts_pullid_query_parameter.yaml
new file mode 100644
index 0000000000..004e59e96d
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_compare_file_src_accepts_pullid_query_parameter.yaml
@@ -0,0 +1,97 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5
+ response:
+ body: {string: '{"name":"test_adder.py","path":"tests/unit/adder/test_adder.py","sha":"abf2ea13871a916997281e1273d28b3c3c72e818","size":251,"url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5","html_url":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py","git_url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/abf2ea13871a916997281e1273d28b3c3c72e818","download_url":"https://raw.githubusercontent.com/eyoel-cov/codecov-assume-flag-test/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py?token=test1f92hh3qe9dbdpw0bgqfu4l1s","type":"file","content":"aW1wb3J0IHB5dGVzdApmcm9tIHNyYy5hZGRlci5hZGRlcnMgaW1wb3J0IEFk\nZGVyCgpkZWYgdGVzdF9zdW1fdHdvX3BsdXNfdHdvX2lzX2ZvdXIoKToKICAg\nIGFzc2VydCBBZGRlcigpLmFkZCgzLDMpID09IDYKCmRlZiB0ZXN0X3N1bV90\nd29fcGx1c190d29faXNfbm90X2ZpdmUoKToKICAgIGFzc2VydCBBZGRlcigp\nLmFkZCgzLDQpICE9IDYKCmRlZiB0ZXN0X2FkZDJfd2l0aF9mb3VyKCk6Cglh\nc3NlcnQgQWRkZXIoKS5hZGQyKDQpID09IDY=\n","encoding":"base64","_links":{"self":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5","git":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/abf2ea13871a916997281e1273d28b3c3c72e818","html":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py"}}'}
+ headers:
+ - !!python/tuple
+ - Date
+ - ['Tue, 17 Sep 2019 23:50:24 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Transfer-Encoding
+ - [chunked]
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Status
+ - [200 OK]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['5000']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['4999']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1568767823']
+ - !!python/tuple
+ - Cache-Control
+ - ['private, max-age=60, s-maxage=60']
+ - !!python/tuple
+ - Vary
+ - ['Accept, Authorization, Cookie, X-GitHub-OTP', Accept-Encoding]
+ - !!python/tuple
+ - Etag
+ - [W/"abf2ea13871a916997281e1273d28b3c3c72e818"]
+ - !!python/tuple
+ - Last-Modified
+ - ['Wed, 28 Aug 2019 02:06:22 GMT']
+ - !!python/tuple
+ - X-Oauth-Scopes
+ - ['read:org, repo, user:email, write:repo_hook']
+ - !!python/tuple
+ - X-Accepted-Oauth-Scopes
+ - ['']
+ - !!python/tuple
+ - X-Oauth-Client-Id
+ - [3d44be0e772666136a13]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['CCB3:2D00:9AFF35:10BD1F8:5D81713F']
+ - !!python/tuple
+ - X-Consumed-Content-Encoding
+ - [gzip]
+ status: {code: 200, message: OK}
+ url: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_cov_decrease___success.yaml b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_cov_decrease___success.yaml
new file mode 100644
index 0000000000..1f8f25a2fe
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_cov_decrease___success.yaml
@@ -0,0 +1,251 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/subtractor/subtractor.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67
+ response:
+ body: {string: '{"name":"subtractor.py","path":"src/subtractor/subtractor.py","sha":"75e0c8aabc2ff179e022d8262aa32ab844a1a12c","size":277,"url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/subtractor/subtractor.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67","html_url":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/subtractor/subtractor.py","git_url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/75e0c8aabc2ff179e022d8262aa32ab844a1a12c","download_url":"https://raw.githubusercontent.com/eyoel-cov/codecov-assume-flag-test/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/subtractor/subtractor.py?token=testv485nsrwfeixy8agrq8k3s64j","type":"file","content":"aW1wb3J0IG1hdGgKCmNsYXNzIFN1YnRyYWN0b3Iob2JqZWN0KToKICAgIGRl\nZiBzdWJ0cmFjdChzZWxmLCB4LCB5KToKICAgICAgICByZXR1cm4geCAtIHkK\nICAgIAogICAgZGVmIGRpdmlkZShzZWxmLCB4LCB5KToKICAgICAgICByZXR1\ncm4gZmxvYXQoeCkgLyBmbG9hdCh5KQogICAgCiAgICBkZWYgZnJhY3Rpb25h\ndGUoc2VsZiwgeCk6CiAgICAgICAgcmV0dXJuIDEgLyBmbG9hdCh4KQogICAg\nCiAgICBkZWYgaGFsZihzZWxmLCB4KToKICAgICAgICByZXR1cm4gZmxvYXQo\neCkgLyAyCg==\n","encoding":"base64","_links":{"self":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/subtractor/subtractor.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67","git":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/75e0c8aabc2ff179e022d8262aa32ab844a1a12c","html":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/subtractor/subtractor.py"}}'}
+ headers:
+ - !!python/tuple
+ - Date
+ - ['Wed, 28 Aug 2019 06:00:52 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Transfer-Encoding
+ - [chunked]
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Status
+ - [200 OK]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['5000']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['4871']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1566975458']
+ - !!python/tuple
+ - Cache-Control
+ - ['private, max-age=60, s-maxage=60']
+ - !!python/tuple
+ - Vary
+ - ['Accept, Authorization, Cookie, X-GitHub-OTP', Accept-Encoding]
+ - !!python/tuple
+ - Etag
+ - [W/"75e0c8aabc2ff179e022d8262aa32ab844a1a12c"]
+ - !!python/tuple
+ - Last-Modified
+ - ['Wed, 28 Aug 2019 02:16:58 GMT']
+ - !!python/tuple
+ - X-Oauth-Scopes
+ - ['read:org, repo, user:email, write:repo_hook']
+ - !!python/tuple
+ - X-Accepted-Oauth-Scopes
+ - ['']
+ - !!python/tuple
+ - X-Oauth-Client-Id
+ - [3d44be0e772666136a13]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['E5CF:467C:37F6F6:427F28:5D661893']
+ - !!python/tuple
+ - X-Consumed-Content-Encoding
+ - [gzip]
+ status: {code: 200, message: OK}
+ url: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/subtractor/subtractor.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060051Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF00C75F7EF9903L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C75F7EF990]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060051Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C75FFEDAAC]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T060051Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/791886122b02a2e52e5dd09e8be5f92e09e83f0b/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIAAkPZl0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKsZFC5UDabkNCCik5qXE56fFJ2eU5mUr
+ 2IEAV3UtDTxAK1cRbTB9nD/w/sSRArDrN0ARMwCKAQAa5Y0UEgMAAA==
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['97']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Etag: ['"018949c5c5d6b0fbf4f15fc9b51f07b7"']
+ Expires: ['1566969619']
+ Last-Modified: ['Wed, 28 Aug 2019 05:20:09 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C760610E20]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060051Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF00C7616075B83L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C7616075B8]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060051Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C761D9DCA0]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T060051Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIALMUZl0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKkZ9hTYgoJCalxKfnxafnFGal61gBwJc
+ 1SS4apDaRorKQeBZog0mXrMBipjB4EhClMYKsV4FAFtPdXVYAwAA
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['96']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:00:51 GMT']
+ Etag: ['"75e94db29362b38856098010597b4c55"']
+ Expires: ['1566971069']
+ Last-Modified: ['Wed, 28 Aug 2019 05:44:19 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00C7623B1EAC]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_diff_change.yaml b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_diff_change.yaml
new file mode 100644
index 0000000000..a3c617e721
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_diff_change.yaml
@@ -0,0 +1,251 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5
+ response:
+ body: {string: '{"name":"test_adder.py","path":"tests/unit/adder/test_adder.py","sha":"abf2ea13871a916997281e1273d28b3c3c72e818","size":251,"url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5","html_url":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py","git_url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/abf2ea13871a916997281e1273d28b3c3c72e818","download_url":"https://raw.githubusercontent.com/eyoel-cov/codecov-assume-flag-test/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py?token=testadwrd6hmwu6i6vcbmabkmlz45","type":"file","content":"aW1wb3J0IHB5dGVzdApmcm9tIHNyYy5hZGRlci5hZGRlcnMgaW1wb3J0IEFk\nZGVyCgpkZWYgdGVzdF9zdW1fdHdvX3BsdXNfdHdvX2lzX2ZvdXIoKToKICAg\nIGFzc2VydCBBZGRlcigpLmFkZCgzLDMpID09IDYKCmRlZiB0ZXN0X3N1bV90\nd29fcGx1c190d29faXNfbm90X2ZpdmUoKToKICAgIGFzc2VydCBBZGRlcigp\nLmFkZCgzLDQpICE9IDYKCmRlZiB0ZXN0X2FkZDJfd2l0aF9mb3VyKCk6Cglh\nc3NlcnQgQWRkZXIoKS5hZGQyKDQpID09IDY=\n","encoding":"base64","_links":{"self":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5","git":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/abf2ea13871a916997281e1273d28b3c3c72e818","html":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/48a4883890d71d777794ed998a1ad4d24a9ebab5/tests/unit/adder/test_adder.py"}}'}
+ headers:
+ - !!python/tuple
+ - Date
+ - ['Wed, 28 Aug 2019 06:27:03 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Transfer-Encoding
+ - [chunked]
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Status
+ - [200 OK]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['5000']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['4971']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1566976200']
+ - !!python/tuple
+ - Cache-Control
+ - ['private, max-age=60, s-maxage=60']
+ - !!python/tuple
+ - Vary
+ - ['Accept, Authorization, Cookie, X-GitHub-OTP', Accept-Encoding]
+ - !!python/tuple
+ - Etag
+ - [W/"abf2ea13871a916997281e1273d28b3c3c72e818"]
+ - !!python/tuple
+ - Last-Modified
+ - ['Wed, 28 Aug 2019 02:06:22 GMT']
+ - !!python/tuple
+ - X-Oauth-Scopes
+ - ['read:org, repo, user:email, write:repo_hook']
+ - !!python/tuple
+ - X-Accepted-Oauth-Scopes
+ - ['']
+ - !!python/tuple
+ - X-Oauth-Client-Id
+ - [3d44be0e772666136a13]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['FFDB:8C50:30C734:3942EA:5D661EB7']
+ - !!python/tuple
+ - X-Consumed-Content-Encoding
+ - [gzip]
+ status: {code: 200, message: OK}
+ url: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/tests/unit/adder/test_adder.py?ref=48a4883890d71d777794ed998a1ad4d24a9ebab5
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T062703Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF023545A9FD983L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF023545A9FD98]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T062703Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF0235472C75EC]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T062703Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/791886122b02a2e52e5dd09e8be5f92e09e83f0b/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIAAkPZl0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKsZFC5UDabkNCCik5qXE56fFJ2eU5mUr
+ 2IEAV3UtDTxAK1cRbTB9nD/w/sSRArDrN0ARMwCKAQAa5Y0UEgMAAA==
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['97']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Etag: ['"018949c5c5d6b0fbf4f15fc9b51f07b7"']
+ Expires: ['1566969619']
+ Last-Modified: ['Wed, 28 Aug 2019 05:20:09 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF0235486EC8C4]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T062703Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF02354A8276243L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF02354A827624]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T062703Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF02354BAE2480]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T062703Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/48a4883890d71d777794ed998a1ad4d24a9ebab5/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIAMfiZV0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKjaACm1AQCE1LyU+Py0+OaM0L1vBDgS4
+ qklw/iC1jRSVg8CzRBtMvGYDFDGDwZGEKI0VqnsVABdi/36qAwAA
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['96']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:27:03 GMT']
+ Etag: ['"033429e66a86d5d4aabcbcfef61e7a4e"']
+ Expires: ['1566958289']
+ Last-Modified: ['Wed, 28 Aug 2019 02:11:19 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF02354C87C820]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_filename_change.yaml b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_filename_change.yaml
new file mode 100644
index 0000000000..c9f408db14
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_file_view/TestCompareSingleFileChangesView/test_fetch_file_with_filename_change.yaml
@@ -0,0 +1,346 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adders.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67
+ response:
+ body: {string: '{"name":"adders.py","path":"src/adder/adders.py","sha":"0e949ad4302ff0b9bbc8a6bc5d33a2b66eda2524","size":254,"url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adders.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67","html_url":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/adder/adders.py","git_url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/0e949ad4302ff0b9bbc8a6bc5d33a2b66eda2524","download_url":"https://raw.githubusercontent.com/eyoel-cov/codecov-assume-flag-test/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/adder/adders.py?token=testvo6iptaqeppdciif0dnbm14cl","type":"file","content":"aW1wb3J0IG1hdGgKCmNsYXNzIEFkZGVyKG9iamVjdCk6CiAgICBkZWYgYWRk\nKHNlbGYsIHgsIHkpOgogICAgICAgICMgYSBsaW5lIC0tCiAgICAgICAgIyBh\nbm90aGVyIGxpbmUKICAgICAgICAjIGEgdGhpcmQgbGluZQogICAgICAgIHJl\ndHVybiB4ICsgeQogICAgICAgIAogICAgZGVmIG11bHRpcGx5KHNlbGYsIHgs\nIHkpOgogICAgICAgIHJldHVybiB4ICogeQogICAgCiAgICBkZWYgYWRkMihz\nZWxmLCB4KToKICAgICAgICByZXR1cm4geCArIDI=\n","encoding":"base64","_links":{"self":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adders.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67","git":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/0e949ad4302ff0b9bbc8a6bc5d33a2b66eda2524","html":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/src/adder/adders.py"}}'}
+ headers:
+ - !!python/tuple
+ - Date
+ - ['Wed, 28 Aug 2019 06:02:31 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Transfer-Encoding
+ - [chunked]
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Status
+ - [200 OK]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['5000']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['4870']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1566975458']
+ - !!python/tuple
+ - Cache-Control
+ - ['private, max-age=60, s-maxage=60']
+ - !!python/tuple
+ - Vary
+ - ['Accept, Authorization, Cookie, X-GitHub-OTP', Accept-Encoding]
+ - !!python/tuple
+ - Etag
+ - [W/"0e949ad4302ff0b9bbc8a6bc5d33a2b66eda2524"]
+ - !!python/tuple
+ - Last-Modified
+ - ['Wed, 28 Aug 2019 02:16:58 GMT']
+ - !!python/tuple
+ - X-Oauth-Scopes
+ - ['read:org, repo, user:email, write:repo_hook']
+ - !!python/tuple
+ - X-Accepted-Oauth-Scopes
+ - ['']
+ - !!python/tuple
+ - X-Oauth-Client-Id
+ - [3d44be0e772666136a13]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['E76D:9A4C:15292E:192CD7:5D6618F7']
+ - !!python/tuple
+ - X-Consumed-Content-Encoding
+ - [gzip]
+ status: {code: 200, message: OK}
+ url: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adders.py?ref=f64b22e6af4bbe3917ac4a7c346feb03493c2b67
+- request:
+ body: null
+ headers:
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adder.py?ref=791886122b02a2e52e5dd09e8be5f92e09e83f0b
+ response:
+ body: {string: '{"name":"adder.py","path":"src/adder/adder.py","sha":"ab899c4500a413d034be1df900727113859ced40","size":206,"url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adder.py?ref=791886122b02a2e52e5dd09e8be5f92e09e83f0b","html_url":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/791886122b02a2e52e5dd09e8be5f92e09e83f0b/src/adder/adder.py","git_url":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/ab899c4500a413d034be1df900727113859ced40","download_url":"https://raw.githubusercontent.com/eyoel-cov/codecov-assume-flag-test/791886122b02a2e52e5dd09e8be5f92e09e83f0b/src/adder/adder.py?token=test30t9gcnxo8gnj976iajbx62v9","type":"file","content":"aW1wb3J0IG1hdGgKCmNsYXNzIEFkZGVyKG9iamVjdCk6CiAgICBkZWYgYWRk\nKHNlbGYsIHgsIHkpOgogICAgICAgICMgYSBsaW5lIC0tCiAgICAgICAgIyBh\nbm90aGVyIGxpbmUKICAgICAgICAjIGEgdGhpcmQgbGluZQogICAgICAgIHJl\ndHVybiB4ICsgeQogICAgICAgIAogICAgZGVmIG11bHRpcGx5KHNlbGYsIHgs\nIHkpOgogICAgICAgIHJldHVybiB4ICogeQo=\n","encoding":"base64","_links":{"self":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adder.py?ref=791886122b02a2e52e5dd09e8be5f92e09e83f0b","git":"https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/git/blobs/ab899c4500a413d034be1df900727113859ced40","html":"https://github.com/eyoel-cov/codecov-assume-flag-test/blob/791886122b02a2e52e5dd09e8be5f92e09e83f0b/src/adder/adder.py"}}'}
+ headers:
+ - !!python/tuple
+ - Date
+ - ['Wed, 28 Aug 2019 06:02:31 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Transfer-Encoding
+ - [chunked]
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Status
+ - [200 OK]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['5000']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['4869']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1566975458']
+ - !!python/tuple
+ - Cache-Control
+ - ['private, max-age=60, s-maxage=60']
+ - !!python/tuple
+ - Vary
+ - ['Accept, Authorization, Cookie, X-GitHub-OTP', Accept-Encoding]
+ - !!python/tuple
+ - Etag
+ - [W/"ab899c4500a413d034be1df900727113859ced40"]
+ - !!python/tuple
+ - Last-Modified
+ - ['Tue, 28 May 2019 12:12:04 GMT']
+ - !!python/tuple
+ - X-Oauth-Scopes
+ - ['read:org, repo, user:email, write:repo_hook']
+ - !!python/tuple
+ - X-Accepted-Oauth-Scopes
+ - ['']
+ - !!python/tuple
+ - X-Oauth-Client-Id
+ - [3d44be0e772666136a13]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['E772:2500:3B1B1A:463989:5D6618F7']
+ - !!python/tuple
+ - X-Consumed-Content-Encoding
+ - [gzip]
+ status: {code: 200, message: OK}
+ url: https://api.github.com/repos/eyoel-cov/codecov-assume-flag-test/contents/src/adder/adder.py?ref=791886122b02a2e52e5dd09e8be5f92e09e83f0b
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060231Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF00DE9F51482C3L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DE9F51482C]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060231Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DE9FD8F36C]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T060231Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/791886122b02a2e52e5dd09e8be5f92e09e83f0b/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIAAkPZl0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKsZFC5UDabkNCCik5qXE56fFJ2eU5mUr
+ 2IEAV3UtDTxAK1cRbTB9nD/w/sSRArDrN0ARMwCKAQAa5Y0UEgMAAA==
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['97']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Etag: ['"018949c5c5d6b0fbf4f15fc9b51f07b7"']
+ Expires: ['1566969619']
+ Last-Modified: ['Wed, 28 Aug 2019 05:20:09 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DEA039C87C]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ User-Agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060231Z]
+ method: PUT
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: '
+
+ BucketAlreadyOwnedByYouYour previous request
+ to create the named bucket succeeded and you already own it./archive/15BF00DEA124A9003L137'}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DEA124A900]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 409, message: Conflict}
+- request:
+ body: null
+ headers:
+ Host: ['minio:9000']
+ X-Amz-Content-Sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ X-Amz-Date: [20190828T060231Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DEA1893028]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [Minio (Linux; x86_64) minio-py/4.0.8]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20190828T060231Z]
+ method: GET
+ uri: http://minio:9000/archive/v4/repos/4A3A4B0C7B00EAEA3F77AA7EB90BE4AB/commits/f64b22e6af4bbe3917ac4a7c346feb03493c2b67/chunks.txt
+ response:
+ body:
+ string: !!binary |
+ H4sIALMUZl0C/6uu5Yo21FHIK83J0VGIjjbQUTCMjY3FKkZ9hTYgoJCalxKfnxafnFGal61gBwJc
+ 1SS4apDaRorKQeBZog0mXrMBipjB4EhClMYKsV4FAFtPdXVYAwAA
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Encoding: [gzip]
+ Content-Length: ['96']
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [text/plain]
+ Date: ['Wed, 28 Aug 2019 06:02:31 GMT']
+ Etag: ['"75e94db29362b38856098010597b4c55"']
+ Expires: ['1566971069']
+ Last-Modified: ['Wed, 28 Aug 2019 05:44:19 GMT']
+ Server: [Minio/RELEASE.2018-08-02T23-11-36Z (linux; amd64)]
+ Vary: [Origin]
+ X-Amz-Request-Id: [15BF00DEA1F06BE4]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_view/TestCompareDetailsView/test_compare_line_coverage_view.yaml b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_view/TestCompareDetailsView/test_compare_line_coverage_view.yaml
new file mode 100644
index 0000000000..6004175b57
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/cassetes/test_compare_view/TestCompareDetailsView/test_compare_line_coverage_view.yaml
@@ -0,0 +1,74 @@
+interactions:
+- request:
+ body: null
+ headers:
+ sdfkjgt
+ Accept: [application/json]
+ User-Agent: [Default]
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/9193232a8fe3429496956ba82b5fed2583d1b5eb...abf6d4df662c47e32460020ab14abf9303581429
+ response:
+ body: {string: '{"message":"Bad credentials","documentation_url":"https://developer.github.com/v3"}'}
+ headers:
+ - !!python/tuple
+ - Server
+ - [GitHub.com]
+ - !!python/tuple
+ - Date
+ - ['Mon, 16 Sep 2019 18:36:06 GMT']
+ - !!python/tuple
+ - Content-Type
+ - [application/json; charset=utf-8]
+ - !!python/tuple
+ - Content-Length
+ - ['83']
+ - !!python/tuple
+ - Connection
+ - [close]
+ - !!python/tuple
+ - Status
+ - [401 Unauthorized]
+ - !!python/tuple
+ - X-Github-Media-Type
+ - [github.v3]
+ - !!python/tuple
+ - X-Ratelimit-Limit
+ - ['60']
+ - !!python/tuple
+ - X-Ratelimit-Remaining
+ - ['57']
+ - !!python/tuple
+ - X-Ratelimit-Reset
+ - ['1568662022']
+ - !!python/tuple
+ - Access-Control-Expose-Headers
+ - ['ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type']
+ - !!python/tuple
+ - Access-Control-Allow-Origin
+ - ['*']
+ - !!python/tuple
+ - Strict-Transport-Security
+ - [max-age=31536000; includeSubdomains; preload]
+ - !!python/tuple
+ - X-Frame-Options
+ - [deny]
+ - !!python/tuple
+ - X-Content-Type-Options
+ - [nosniff]
+ - !!python/tuple
+ - X-Xss-Protection
+ - [1; mode=block]
+ - !!python/tuple
+ - Referrer-Policy
+ - ['origin-when-cross-origin, strict-origin-when-cross-origin']
+ - !!python/tuple
+ - Content-Security-Policy
+ - [default-src 'none']
+ - !!python/tuple
+ - X-Github-Request-Id
+ - ['F95D:3B2F:6A7076:BDB881:5D7FD616']
+ status: {code: 401, message: Unauthorized}
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/compare/9193232a8fe3429496956ba82b5fed2583d1b5eb...abf6d4df662c47e32460020ab14abf9303581429
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/unit/views/test_compare_flags_view.py b/apps/codecov-api/api/internal/tests/unit/views/test_compare_flags_view.py
new file mode 100644
index 0000000000..94ff0b235a
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/test_compare_flags_view.py
@@ -0,0 +1,422 @@
+from pathlib import Path
+from unittest.mock import PropertyMock, patch
+
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitWithReportFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.types import ReportTotals
+
+from codecov.tests.base_test import InternalAPITest
+
+current_file = Path(__file__)
+
+
+@patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+@patch("shared.api_archive.archive.ArchiveService.read_chunks")
+@patch("shared.reports.filtered.FilteredReport.apply_diff")
+@patch(
+ "api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions",
+ lambda self, repo, user: (True, True),
+)
+class TestCompareFlagsView(InternalAPITest):
+ def _get_compare_flags(self, kwargs, query_params):
+ return self.client.get(
+ reverse("compare-flags", kwargs=kwargs), data=query_params
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory.create(author__username="ThiagoCodecov")
+ self.parent_commit = CommitWithReportFactory.create(
+ commitid="00c7b4b49778b3c79427f9c4c13a8612a376ff19", repository=self.repo
+ )
+ self.commit = CommitWithReportFactory.create(
+ message="test_report_serializer",
+ commitid="68946ef98daec68c7798459150982fc799c87d85",
+ parent_commit_id=self.parent_commit.commitid,
+ repository=self.repo,
+ )
+
+ self.client.session["current_owner_id"] = self.repo.author.pk
+ self.client.force_login(self.repo.author.user)
+
+ def test_compare_flags___success(
+ self, diff_totals_mock, read_chunks_mock, git_comparison_mock
+ ):
+ head_chunks = open(
+ current_file.parent.parent.parent
+ / f"samples/{self.commit.commitid}_chunks.txt",
+ "r",
+ ).read()
+ base_chunks = open(
+ current_file.parent.parent.parent
+ / f"samples/{self.parent_commit.commitid}_chunks.txt",
+ "r",
+ ).read()
+ read_chunks_mock.side_effect = (
+ lambda x: head_chunks if x == self.commit.commitid else base_chunks
+ )
+ diff_totals_mock.return_value = ReportTotals(
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ coverage="0",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ )
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ response = self._get_compare_flags(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.data == [
+ {
+ "name": "unittests",
+ "base_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 79.16,
+ "diff": 0,
+ "files": 3,
+ "hits": 19,
+ "lines": 24,
+ "messages": 0,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 1,
+ },
+ "diff_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 0,
+ "diff": 0,
+ "files": 0,
+ "hits": 0,
+ "lines": 0,
+ "messages": 0,
+ "methods": 0,
+ "misses": 0,
+ "partials": 0,
+ "sessions": 0,
+ },
+ "head_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 80.00,
+ "diff": 0,
+ "files": 3,
+ "hits": 20,
+ "lines": 25,
+ "messages": 0,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 1,
+ },
+ },
+ {
+ "name": "integrations",
+ "base_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 79.16,
+ "diff": 0,
+ "files": 3,
+ "hits": 19,
+ "lines": 24,
+ "messages": 0,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 1,
+ },
+ "diff_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 0,
+ "diff": 0,
+ "files": 0,
+ "hits": 0,
+ "lines": 0,
+ "messages": 0,
+ "methods": 0,
+ "misses": 0,
+ "partials": 0,
+ "sessions": 0,
+ },
+ "head_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 56.00,
+ "diff": 0,
+ "files": 3,
+ "hits": 14,
+ "lines": 25,
+ "messages": 0,
+ "methods": 0,
+ "misses": 11,
+ "partials": 0,
+ "sessions": 1,
+ },
+ },
+ ]
+
+ def test_compare_flags_with_report_with_cff_and_non_cff(
+ self, diff_totals_mock, read_chunks_mock, git_comparison_mock
+ ):
+ commit_with_custom_reports = CommitWithReportFactory.create(
+ message="test_report_serializer",
+ commitid="9sa8790asdf9agyasdg7a90sd9f89as7ga0sdf98a",
+ parent_commit_id=self.parent_commit.commitid,
+ repository=self.repo,
+ )
+ report = commit_with_custom_reports._report
+ report["sessions"]["0"].update(
+ st="carriedforward",
+ se={"carriedforward_from": "56e05fced214c44a37759efa2dfc25a65d8ae98d"},
+ )
+ commit_with_custom_reports.save()
+
+ upload = (
+ commit_with_custom_reports.reports.first()
+ .sessions.filter(flags__flag_name="unittests")
+ .first()
+ )
+ upload.upload_type = "carriedforward"
+ upload.upload_extras = {
+ "carriedforward_from": "56e05fced214c44a37759efa2dfc25a65d8ae98d"
+ }
+ upload.save()
+
+ head_chunks = open(
+ current_file.parent.parent.parent
+ / f"samples/{commit_with_custom_reports.commitid}_chunks.txt",
+ "r",
+ ).read()
+ base_chunks = open(
+ current_file.parent.parent.parent
+ / f"samples/{self.parent_commit.commitid}_chunks.txt",
+ "r",
+ ).read()
+ read_chunks_mock.side_effect = (
+ lambda x: head_chunks
+ if x == commit_with_custom_reports.commitid
+ else base_chunks
+ )
+ diff_totals_mock.return_value = ReportTotals(
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ coverage="0",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ )
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ response = self._get_compare_flags(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "base": self.parent_commit.commitid,
+ "head": commit_with_custom_reports.commitid,
+ },
+ )
+
+ assert response.status_code == 200
+
+ expected_result = [
+ {
+ "name": "integrations",
+ "base_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 79.16,
+ "diff": 0,
+ "files": 3,
+ "hits": 19,
+ "lines": 24,
+ "messages": 0,
+ "methods": 0,
+ "misses": 5,
+ "partials": 0,
+ "sessions": 1,
+ },
+ "diff_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 0,
+ "diff": 0,
+ "files": 0,
+ "hits": 0,
+ "lines": 0,
+ "messages": 0,
+ "methods": 0,
+ "misses": 0,
+ "partials": 0,
+ "sessions": 0,
+ },
+ "head_report_totals": {
+ "branches": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "coverage": 56.00,
+ "diff": 0,
+ "files": 3,
+ "hits": 14,
+ "lines": 25,
+ "messages": 0,
+ "methods": 0,
+ "misses": 11,
+ "partials": 0,
+ "sessions": 1,
+ },
+ }
+ ]
+ # Only the non-carried forward report is returned
+ assert len(response.data) == 1
+ assert (
+ response.data[0]["base_report_totals"]
+ == expected_result[0]["base_report_totals"]
+ )
+ assert (
+ response.data[0]["head_report_totals"]
+ == expected_result[0]["head_report_totals"]
+ )
+ assert response.data[0] == expected_result[0]
+ assert response.data == expected_result
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ def test_compare_flags_view_accepts_pullid_query_param(
+ self, diff_totals_mock, read_chunks_mock, git_comparison_mock
+ ):
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ read_chunks_mock.return_value = ""
+ diff_totals_mock.return_value = ReportTotals()
+
+ response = self._get_compare_flags(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "pullid": PullFactory(
+ base=self.parent_commit.commitid,
+ head=self.commit.commitid,
+ compared_to=self.parent_commit.commitid,
+ pullid=2,
+ author=self.commit.author,
+ repository=self.repo,
+ ).pullid
+ },
+ )
+
+ assert response.status_code == 200
+
+ @patch("services.comparison.FlagComparison.base_report", new_callable=PropertyMock)
+ def test_compare_flags_doesnt_crash_if_base_doesnt_have_flags(
+ self, base_flag_mock, diff_totals_mock, read_chunks_mock, git_comparison_mock
+ ):
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ read_chunks_mock.return_value = ""
+ base_flag_mock.return_value = None
+ diff_totals_mock.return_value = ReportTotals()
+
+ # should not crash
+ self._get_compare_flags(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ },
+ )
+
+ @patch("shared.reports.resources.Report.totals", new_callable=PropertyMock)
+ def test_compare_flags_view_doesnt_crash_if_coverage_is_none(
+ self,
+ report_totals_mock,
+ diff_totals_mock,
+ read_chunks_mock,
+ git_comparison_mock,
+ ):
+ diff_totals_mock.return_value = ReportTotals()
+ read_chunks_mock.return_value = ""
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ report_totals_mock.return_value = ReportTotals(
+ branches=0,
+ complexity=0,
+ complexity_total=0,
+ coverage=None,
+ diff=0,
+ files=3,
+ hits=19,
+ lines=24,
+ messages=0,
+ methods=0,
+ misses=5,
+ partials=0,
+ sessions=2,
+ )
+
+ # should not crash
+ self._get_compare_flags(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ },
+ )
diff --git a/apps/codecov-api/api/internal/tests/unit/views/test_compare_view.py b/apps/codecov-api/api/internal/tests/unit/views/test_compare_view.py
new file mode 100644
index 0000000000..439578ffd9
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/unit/views/test_compare_view.py
@@ -0,0 +1,284 @@
+import json
+from unittest.mock import PropertyMock, patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitWithReportFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from api.internal.commit.serializers import CommitTotalsSerializer
+from codecov.tests.base_test import InternalAPITest
+
+
+def build_commits(client):
+ """
+ build commits in mock_db that are based on a real git commit for using VCR
+ :param user:
+ :param client:
+ :return: repo, commit_base, commit_head
+ """
+ repo = RepositoryFactory.create(
+ author__unencrypted_oauth_token="testqmit3okrgutcoyzscveipor3toi3nsmb927v",
+ author__username="ThiagoCodecov",
+ )
+ parent_commit = CommitWithReportFactory.create(
+ message="test_compare_parent", commitid="c5b6730", repository=repo
+ )
+ commit_base = CommitWithReportFactory.create(
+ message="test_compare_commits_base",
+ commitid="9193232a8fe3429496956ba82b5fed2583d1b5eb",
+ parent_commit_id=parent_commit.commitid,
+ repository=repo,
+ )
+ commit_head = CommitWithReportFactory.create(
+ message="test_compare_commits_head",
+ commitid="abf6d4df662c47e32460020ab14abf9303581429",
+ parent_commit_id=parent_commit.commitid,
+ repository=repo,
+ )
+ client.session["current_owner_id"] = repo.author.pk
+ client.force_login(user=repo.author.user)
+ return repo, commit_base, commit_head
+
+
+@patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, sha: "")
+@patch(
+ "api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions",
+ lambda self, repo, user: (True, True),
+)
+class TestCompareCommitsView(InternalAPITest):
+ def setUp(self):
+ org = OwnerFactory(username="Codecov")
+ self.user = OwnerFactory(
+ username="codecov-user",
+ email="codecov-user@codecov.io",
+ organizations=[org.ownerid],
+ )
+ self.repo, self.commit_base, self.commit_head = build_commits(self.client)
+ self.commit_base_totals_serialized = {
+ "files": self.commit_base.totals["f"],
+ "lines": self.commit_base.totals["n"],
+ "hits": self.commit_base.totals["h"],
+ "misses": self.commit_base.totals["m"],
+ "partials": self.commit_base.totals["p"],
+ "coverage": round(float(self.commit_base.totals["c"]), 2),
+ "branches": self.commit_base.totals["b"],
+ "methods": self.commit_base.totals["d"],
+ "sessions": self.commit_base.totals["s"],
+ "diff": self.commit_base.totals["diff"],
+ "complexity": self.commit_base.totals["C"],
+ "complexity_total": self.commit_base.totals["N"],
+ }
+ self.commit_head_totals_serialized = {
+ "files": self.commit_head.totals["f"],
+ "lines": self.commit_head.totals["n"],
+ "hits": self.commit_head.totals["h"],
+ "misses": self.commit_head.totals["m"],
+ "partials": self.commit_head.totals["p"],
+ "coverage": round(float(self.commit_head.totals["c"]), 2),
+ "branches": self.commit_head.totals["b"],
+ "methods": self.commit_head.totals["d"],
+ "sessions": self.commit_head.totals["s"],
+ "diff": self.commit_head.totals["diff"],
+ "complexity": self.commit_head.totals["C"],
+ "complexity_total": self.commit_head.totals["N"],
+ }
+
+ def _get_commits_comparison(self, kwargs, query_params):
+ return self.client.get(
+ reverse("compare-detail", kwargs=kwargs), data=query_params
+ )
+
+ def _configure_mocked_comparison_with_commits(self, mock):
+ mock.return_value = {
+ "diff": {"files": {}},
+ "commits": [
+ {
+ "commitid": self.commit_base.commitid,
+ "message": self.commit_base.message,
+ "timestamp": "2019-03-31T02:28:02Z",
+ "author": {
+ "id": self.repo.author.ownerid,
+ "username": self.repo.author.username,
+ "name": self.repo.author.name,
+ "email": self.repo.author.email,
+ },
+ },
+ {
+ "commitid": self.commit_head.commitid,
+ "message": self.commit_head.message,
+ "timestamp": "2019-03-31T07:23:19Z",
+ "author": {
+ "id": self.repo.author.ownerid,
+ "username": self.repo.author.username,
+ "name": self.repo.author.name,
+ "email": self.repo.author.email,
+ },
+ },
+ ],
+ }
+
+ def test_compare_commits_bad_commit(self):
+ bad_commitid = "9193232a8fe3429496123ba82b5fed2583d1b5eb"
+ response = self._get_commits_comparison(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={"base": self.commit_base.commitid, "head": bad_commitid},
+ )
+ assert response.status_code == 404
+
+ def test_compare_commits_bad_branch(self):
+ bad_branch = "bad-branch"
+ branch_base = BranchFactory.create(head=self.commit_base, repository=self.repo)
+ response = self._get_commits_comparison(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={"base": branch_base.name, "head": bad_branch},
+ )
+ assert response.status_code == 404
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ def test_compare_commits_view_with_branchname(self, mocked_comparison):
+ self._configure_mocked_comparison_with_commits(mocked_comparison)
+ branch_base = BranchFactory.create(
+ head=self.commit_base.commitid, repository=self.commit_base.repository
+ )
+ branch_head = BranchFactory.create(
+ head=self.commit_head.commitid, repository=self.commit_head.repository
+ )
+
+ response = self._get_commits_comparison(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={"base": branch_base.name, "head": branch_head.name},
+ )
+
+ assert response.status_code == 200
+ content = json.loads(response.content.decode())
+ assert (
+ content["diff"]["git_commits"] == mocked_comparison.return_value["commits"]
+ )
+
+ head_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_head.commitid
+ )
+ assert (
+ head_upload["totals"]
+ == CommitTotalsSerializer(self.commit_head.totals).data
+ )
+
+ base_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_base.commitid
+ )
+ assert (
+ base_upload["totals"]
+ == CommitTotalsSerializer(self.commit_base.totals).data
+ )
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ def test_compare_commits_view_with_commitid(self, mocked_comparison):
+ self._configure_mocked_comparison_with_commits(mocked_comparison)
+ response = self._get_commits_comparison(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={
+ "base": self.commit_base.commitid,
+ "head": self.commit_head.commitid,
+ },
+ )
+ assert response.status_code == 200
+ content = json.loads(response.content.decode())
+
+ assert (
+ content["diff"]["git_commits"] == mocked_comparison.return_value["commits"]
+ )
+
+ head_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_head.commitid
+ )
+ assert (
+ head_upload["totals"]
+ == CommitTotalsSerializer(self.commit_head.totals).data
+ )
+
+ base_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_base.commitid
+ )
+ assert (
+ base_upload["totals"]
+ == CommitTotalsSerializer(self.commit_base.totals).data
+ )
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ def test_compare_commits_view_with_pullid(self, mocked_comparison):
+ self._configure_mocked_comparison_with_commits(mocked_comparison)
+ pull = PullFactory(
+ pullid=2,
+ repository=self.repo,
+ author=self.repo.author,
+ base=self.commit_base.commitid,
+ compared_to=self.commit_base.commitid,
+ head=self.commit_head.commitid,
+ )
+
+ response = self._get_commits_comparison(
+ kwargs={
+ "service": self.repo.author.service,
+ "owner_username": self.repo.author.username,
+ "repo_name": self.repo.name,
+ },
+ query_params={"pullid": pull.pullid},
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ content = json.loads(response.content.decode())
+
+ assert (
+ content["diff"]["git_commits"] == mocked_comparison.return_value["commits"]
+ )
+
+ head_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_head.commitid
+ )
+ assert (
+ head_upload["totals"]
+ == CommitTotalsSerializer(self.commit_head.totals).data
+ )
+
+ base_upload = next(
+ commit
+ for commit in content["commit_uploads"]
+ if commit["commitid"] == self.commit_base.commitid
+ )
+ assert (
+ base_upload["totals"]
+ == CommitTotalsSerializer(self.commit_base.totals).data
+ )
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_billing_address.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_billing_address.yaml
new file mode 100644
index 0000000000..51c8537256
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_billing_address.yaml
@@ -0,0 +1,79 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Stripe-Version:
+ - '2024-04-10'
+ User-Agent:
+ - Stripe/v1 PythonBindings/9.6.0
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "9.6.0", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.12.3", "platform": "Linux-6.6.31-linuxkit-aarch64-with-glibc2.36",
+ "uname": "Linux b0efe3849169 6.6.31-linuxkit #1 SMP Thu May 23 08:36:57 UTC
+ 2024 aarch64 "}'
+ method: GET
+ uri: https://api.stripe.com/v1/subscriptions/djfos?expand%5B0%5D=latest_invoice&expand%5B1%5D=customer&expand%5B2%5D=customer.invoice_settings.default_payment_method
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\":
+ \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\":
+ \"No such subscription: 'djfos'\",\n \"param\": \"id\",\n \"request_log_url\":
+ \"https://dashboard.stripe.com/test/logs/req_4POjCjRMnbE5Wg?t=1718056644\",\n
+ \ \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET,HEAD,PUT,PATCH,POST,DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '324'
+ Content-Security-Policy:
+ - report-uri https://q.stripe.com/csp-report?p=v1%2Fsubscriptions%2F%3Asubscription_exposed_id;
+ block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action
+ 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample';
+ style-src 'self'
+ Content-Type:
+ - application/json
+ Cross-Origin-Opener-Policy-Report-Only:
+ - same-origin; report-to="coop"
+ Date:
+ - Mon, 10 Jun 2024 21:57:24 GMT
+ Report-To:
+ - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report?s=billing-api-srv"}],"include_subdomains":true}'
+ Reporting-Endpoints:
+ - coop="https://q.stripe.com/coop-report?s=billing-api-srv"
+ Request-Id:
+ - req_4POjCjRMnbE5Wg
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - '2024-04-10'
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 404
+ message: Not Found
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_handles_stripe_error.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_handles_stripe_error.yaml
new file mode 100644
index 0000000000..254ef9a55d
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_handles_stripe_error.yaml
@@ -0,0 +1,82 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Stripe-Version:
+ - 2024-12-18.acacia
+ User-Agent:
+ - Stripe/v1 PythonBindings/11.4.1
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "11.4.1", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.12.8", "platform": "Linux-6.10.14-linuxkit-aarch64-with-glibc2.36",
+ "uname": "Linux f69fe8d5c257 6.10.14-linuxkit #1 SMP Fri Nov 29 17:22:03 UTC
+ 2024 aarch64 "}'
+ method: GET
+ uri: https://api.stripe.com/v1/subscriptions/djfos?expand%5B0%5D=latest_invoice&expand%5B1%5D=customer&expand%5B2%5D=customer.invoice_settings.default_payment_method&expand%5B3%5D=customer.tax_ids
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\":
+ \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\":
+ \"No such subscription: 'djfos'\",\n \"param\": \"id\",\n \"request_log_url\":
+ \"https://dashboard.stripe.com/test/logs/req_fCLmExiHliLLAy?t=1738274612\",\n
+ \ \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '324'
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; upgrade-insecure-requests;
+ report-uri https://q.stripe.com/csp-violation?q=JU_aZLssk7a3_VZEeiDM3UWQN0mgWJiEG8zz5aFpDfoiI4Itt-XeW-vHYyCYd8ZJIklaArUO0YdslYml
+ Content-Type:
+ - application/json
+ Cross-Origin-Opener-Policy-Report-Only:
+ - same-origin; report-to="coop"
+ Date:
+ - Thu, 30 Jan 2025 22:03:32 GMT
+ Report-To:
+ - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}'
+ Reporting-Endpoints:
+ - coop="https://q.stripe.com/coop-report"
+ Request-Id:
+ - req_fCLmExiHliLLAy
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - 2024-12-18.acacia
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - AB
+ status:
+ code: 404
+ message: Not Found
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml
new file mode 100644
index 0000000000..2ee7c35712
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml
@@ -0,0 +1,92 @@
+interactions:
+- request:
+ body: billing_address_collection=required&payment_method_collection=if_required&client_reference_id=65&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjustin47%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjustin47%3Fcancel&customer=1000&mode=subscription&line_items[0][price]=price_1OCM0gGlVGuVgOrkWDYEBtSL&line_items[0][quantity]=11&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=65&subscription_data[metadata][username]=justin47&subscription_data[metadata][obo_name]=Kelly+Williams&subscription_data[metadata][obo_email]=christopher27%40lopez-welch.com&subscription_data[metadata][obo]=65&tax_id_collection[enabled]=True&customer_update[name]=auto&customer_update[address]=auto
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '744'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 2e7e12e1-9051-4766-b947-1abe985b5e98
+ Stripe-Version:
+ - 2024-12-18.acacia
+ User-Agent:
+ - Stripe/v1 PythonBindings/11.4.1
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "11.4.1", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.12.8", "platform": "Linux-6.10.14-linuxkit-aarch64-with-glibc2.36",
+ "uname": "Linux 35c9e7c77efc 6.10.14-linuxkit #1 SMP Fri Nov 29 17:22:03 UTC
+ 2024 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/checkout/sessions
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\":
+ \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\":
+ \"No such customer: '1000'\",\n \"param\": \"customer\",\n \"request_log_url\":
+ \"https://dashboard.stripe.com/test/logs/req_oevRZUbMiaT1kM?t=1737668618\",\n
+ \ \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '325'
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; upgrade-insecure-requests;
+ report-uri https://q.stripe.com/csp-violation?q=ieXMYrsoTw4hsNfwL6RhDPN6zQnVwnAAc0QsFb8i8xtl4N7V94VZzDmgDhKzWxW8mHRRg08-d5GW6oHr
+ Content-Type:
+ - application/json
+ Cross-Origin-Opener-Policy-Report-Only:
+ - same-origin; report-to="coop"
+ Date:
+ - Thu, 23 Jan 2025 21:43:38 GMT
+ Idempotency-Key:
+ - 2e7e12e1-9051-4766-b947-1abe985b5e98
+ Original-Request:
+ - req_oevRZUbMiaT1kM
+ Report-To:
+ - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}'
+ Reporting-Endpoints:
+ - coop="https://q.stripe.com/coop-report"
+ Request-Id:
+ - req_oevRZUbMiaT1kM
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - 2024-12-18.acacia
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - AB
+ status:
+ code: 400
+ message: Bad Request
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_payment_method.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_payment_method.yaml
new file mode 100644
index 0000000000..acef70d627
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_payment_method.yaml
@@ -0,0 +1,91 @@
+interactions:
+- request:
+ body: default_payment_method=pm_123
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '29'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 7c40f9e9-3a01-4109-87bf-3218dfbcf27f
+ Stripe-Version:
+ - '2024-04-10'
+ User-Agent:
+ - Stripe/v1 PythonBindings/9.6.0
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "9.6.0", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.12.4", "platform": "Linux-6.6.31-linuxkit-aarch64-with-glibc2.36",
+ "uname": "Linux 2b87f96d1995 6.6.31-linuxkit #1 SMP Thu May 23 08:36:57 UTC
+ 2024 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/subscriptions/djfos
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\":
+ \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\":
+ \"No such PaymentMethod: 'pm_123'\",\n \"param\": \"default_payment_method\",\n
+ \ \"request_log_url\": \"https://dashboard.stripe.com/test/logs/req_xT5h1VWY7P75Lu?t=1719007484\",\n
+ \ \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET,HEAD,PUT,PATCH,POST,DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '346'
+ Content-Security-Policy:
+ - report-uri https://q.stripe.com/csp-report?p=v1%2Fsubscriptions%2F%3Asubscription_exposed_id;
+ block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action
+ 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample';
+ style-src 'self'
+ Content-Type:
+ - application/json
+ Cross-Origin-Opener-Policy-Report-Only:
+ - same-origin; report-to="coop"
+ Date:
+ - Fri, 21 Jun 2024 22:04:44 GMT
+ Idempotency-Key:
+ - 7c40f9e9-3a01-4109-87bf-3218dfbcf27f
+ Original-Request:
+ - req_xT5h1VWY7P75Lu
+ Report-To:
+ - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report?s=billing-api-srv"}],"include_subdomains":true}'
+ Reporting-Endpoints:
+ - coop="https://q.stripe.com/coop-report?s=billing-api-srv"
+ Request-Id:
+ - req_xT5h1VWY7P75Lu
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - '2024-04-10'
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 400
+ message: Bad Request
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_quantity_must_be_greater_or_equal_to_current_activated_users_if_paid_plan.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_quantity_must_be_greater_or_equal_to_current_activated_users_if_paid_plan.yaml
new file mode 100644
index 0000000000..d48e89ce02
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_quantity_must_be_greater_or_equal_to_current_activated_users_if_paid_plan.yaml
@@ -0,0 +1,71 @@
+interactions:
+- request:
+ body: billing_address_collection=required&payment_method_types[0]=card&payment_method_collection=if_required&client_reference_id=65&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fysantos%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fysantos%3Fcancel&subscription_data[items][0][plan]=plan_H6P16wij3lUuxg&subscription_data[items][0][quantity]=14&subscription_data[payment_behavior]=allow_incomplete&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=65&subscription_data[metadata][username]=ysantos&subscription_data[metadata][obo_name]=Matthew+Guzman&subscription_data[metadata][obo_email]=ybrown%40fitzgerald-cummings.org&subscription_data[metadata][obo]=65&customer=1000
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '733'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 21e170c5-bf4a-47f7-9da5-262844bbdbf2
+ User-Agent:
+ - Stripe/v1 PythonBindings/2.55.2
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "2.55.2", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.9.16", "platform": "Linux-5.15.49-linuxkit-pr-aarch64-with",
+ "uname": "Linux 7e5f26339edc 5.15.49-linuxkit-pr #1 SMP PREEMPT Thu May 25
+ 07:27:39 UTC 2023 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/checkout/sessions
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\"\
+ : \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\"\
+ : \"No such customer: '1000'\",\n \"param\": \"customer\",\n \"request_log_url\"\
+ : \"https://dashboard.stripe.com/test/logs/req_vVNzxP8mblHEcJ?t=1689601951\"\
+ ,\n \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, POST, HEAD, OPTIONS, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '325'
+ Content-Type:
+ - application/json
+ Date:
+ - Mon, 17 Jul 2023 13:52:31 GMT
+ Idempotency-Key:
+ - 21e170c5-bf4a-47f7-9da5-262844bbdbf2
+ Original-Request:
+ - req_vVNzxP8mblHEcJ
+ Request-Id:
+ - req_vVNzxP8mblHEcJ
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - '2017-05-25'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 400
+ message: Bad Request
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual.yaml
new file mode 100644
index 0000000000..355d48135e
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual.yaml
@@ -0,0 +1,64 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ User-Agent:
+ - Stripe/v1 PythonBindings/2.55.2
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics": {"request_id": "req_vVNzxP8mblHEcJ", "request_duration_ms":
+ 353}}'
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "2.55.2", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.9.16", "platform": "Linux-5.15.49-linuxkit-pr-aarch64-with",
+ "uname": "Linux 7e5f26339edc 5.15.49-linuxkit-pr #1 SMP PREEMPT Thu May 25
+ 07:27:39 UTC 2023 aarch64 "}'
+ method: GET
+ uri: https://api.stripe.com/v1/subscriptions/djfos?expand%5B0%5D=latest_invoice&expand%5B1%5D=customer&expand%5B2%5D=customer.invoice_settings.default_payment_method
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\"\
+ : \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\"\
+ : \"No such subscription: 'djfos'\",\n \"param\": \"id\",\n \"request_log_url\"\
+ : \"https://dashboard.stripe.com/test/logs/req_5JWnxFXQbAG4xJ?t=1689601951\"\
+ ,\n \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, POST, HEAD, OPTIONS, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '324'
+ Content-Type:
+ - application/json
+ Date:
+ - Mon, 17 Jul 2023 13:52:31 GMT
+ Request-Id:
+ - req_5JWnxFXQbAG4xJ
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - '2017-05-25'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 404
+ message: Not Found
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual_with_users_org.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual_with_users_org.yaml
new file mode 100644
index 0000000000..b99d1a0fbb
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_annual_with_users_org.yaml
@@ -0,0 +1,115 @@
+interactions:
+- request:
+ body: billing_address_collection=auto&payment_method_types[0]=card&payment_method_collection=if_required&client_reference_id=69&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fdustinbrown%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fdustinbrown%3Fcancel&subscription_data[items][0][plan]=price_1Mj1mMGlVGuVgOrkC0ORc6iW&subscription_data[items][0][quantity]=12&subscription_data[payment_behavior]=allow_incomplete&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=69&subscription_data[metadata][username]=dustinbrown&subscription_data[metadata][obo_name]=Melanie+Young&subscription_data[metadata][obo_email]=nelsondavid%40gutierrez-carlson.biz&subscription_data[metadata][obo]=68&subscription_data[trial_period_days]=14&subscription_data[trial_settings][end_behavior][missing_payment_method]=cancel&customer_email=karenmorgan%40hotmail.com
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '900'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 4a99c686-ec33-4be8-a5e0-84bfd1d25a5f
+ User-Agent:
+ - Stripe/v1 PythonBindings/2.55.2
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics": {"request_id": "req_5JWnxFXQbAG4xJ", "request_duration_ms":
+ 329}}'
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "2.55.2", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.9.16", "platform": "Linux-5.15.49-linuxkit-pr-aarch64-with",
+ "uname": "Linux 7e5f26339edc 5.15.49-linuxkit-pr #1 SMP PREEMPT Thu May 25
+ 07:27:39 UTC 2023 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/checkout/sessions
+ response:
+ body:
+ string: "{\n \"id\": \"cs_test_a1VJ7uPphpFIRYNicL2xduJkZf7kMlUsgom2pcLv35tQFZGQmbeZXWMZJp\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 0,\n \"amount_total\"\
+ : 0,\n \"automatic_tax\": {\n \"enabled\": false,\n \"status\": null\n\
+ \ },\n \"billing_address_collection\": \"auto\",\n \"cancel_url\": \"http://localhost:3000/plan/gh/dustinbrown?cancel\"\
+ ,\n \"client_reference_id\": \"69\",\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1689601952,\n \"currency\": \"usd\",\n \"currency_conversion\"\
+ : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"shipping_address\"\
+ : null,\n \"submit\": null\n },\n \"customer\": null,\n \"customer_creation\"\
+ : \"always\",\n \"customer_details\": {\n \"address\": null,\n \"email\"\
+ : \"karenmorgan@hotmail.com\",\n \"name\": null,\n \"phone\": null,\n\
+ \ \"tax_exempt\": \"none\",\n \"tax_ids\": null\n },\n \"customer_email\"\
+ : \"karenmorgan@hotmail.com\",\n \"display_items\": [\n {\n \"amount\"\
+ : 0,\n \"currency\": \"usd\",\n \"plan\": {\n \"id\": \"\
+ price_1Mj1mMGlVGuVgOrkC0ORc6iW\",\n \"object\": \"plan\",\n \
+ \ \"active\": true,\n \"aggregate_usage\": null,\n \"amount\"\
+ : null,\n \"amount_decimal\": null,\n \"billing_scheme\": \"\
+ tiered\",\n \"created\": 1678200374,\n \"currency\": \"usd\"\
+ ,\n \"interval\": \"year\",\n \"interval_count\": 1,\n \
+ \ \"livemode\": false,\n \"metadata\": {},\n \"name\": \"\
+ users-sentryy\",\n \"nickname\": null,\n \"product\": \"prod_NTzlixoYR2xMef\"\
+ ,\n \"statement_descriptor\": null,\n \"tiers\": [\n \
+ \ {\n \"amount\": null,\n \"flat_amount\": 34800,\n\
+ \ \"flat_amount_decimal\": \"34800\",\n \"unit_amount_decimal\"\
+ : null,\n \"up_to\": 5\n },\n {\n \
+ \ \"amount\": 12000,\n \"flat_amount\": null,\n \"flat_amount_decimal\"\
+ : null,\n \"unit_amount_decimal\": \"12000\",\n \"up_to\"\
+ : null\n }\n ],\n \"tiers_mode\": \"graduated\",\n\
+ \ \"transform_usage\": null,\n \"trial_period_days\": 14,\n\
+ \ \"usage_type\": \"licensed\"\n },\n \"quantity\": 12,\n\
+ \ \"type\": \"plan\"\n }\n ],\n \"expires_at\": 1689688352,\n \"\
+ invoice\": null,\n \"invoice_creation\": null,\n \"livemode\": false,\n\
+ \ \"locale\": null,\n \"metadata\": {},\n \"mode\": \"subscription\",\n\
+ \ \"payment_intent\": null,\n \"payment_link\": null,\n \"payment_method_collection\"\
+ : \"if_required\",\n \"payment_method_options\": null,\n \"payment_method_types\"\
+ : [\n \"card\"\n ],\n \"payment_status\": \"unpaid\",\n \"phone_number_collection\"\
+ : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"setup_intent\"\
+ : null,\n \"shipping\": null,\n \"shipping_address_collection\": null,\n\
+ \ \"shipping_options\": [],\n \"shipping_rate\": null,\n \"status\": \"\
+ open\",\n \"submit_type\": null,\n \"subscription\": null,\n \"success_url\"\
+ : \"http://localhost:3000/plan/gh/dustinbrown?success\",\n \"total_details\"\
+ : {\n \"amount_discount\": 0,\n \"amount_shipping\": 0,\n \"amount_tax\"\
+ : 0\n },\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_a1VJ7uPphpFIRYNicL2xduJkZf7kMlUsgom2pcLv35tQFZGQmbeZXWMZJp#fidkdWxOYHwnPyd1blpxYHZxWjA0MVZPUUpCaVNCcFNiSnduV2JtMn99dTZxVDJnXTFGXDN1a319cjN%2FV19fVmpBU3FQUG9Ed1VORjJqXWBgTGdPS0xGUXRWMk09PUNXY3Jfa1JIdm5VTn1qNTVnRGtwN2w8TCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
+ \n}"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, POST, HEAD, OPTIONS, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '3341'
+ Content-Type:
+ - application/json
+ Date:
+ - Mon, 17 Jul 2023 13:52:32 GMT
+ Idempotency-Key:
+ - 4a99c686-ec33-4be8-a5e0-84bfd1d25a5f
+ Original-Request:
+ - req_RwdTyPtxxWL9eu
+ Request-Id:
+ - req_RwdTyPtxxWL9eu
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Should-Retry:
+ - 'false'
+ Stripe-Version:
+ - '2017-05-25'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly.yaml
new file mode 100644
index 0000000000..3b2144fcf8
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly.yaml
@@ -0,0 +1,64 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ User-Agent:
+ - Stripe/v1 PythonBindings/2.55.2
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics": {"request_id": "req_RwdTyPtxxWL9eu", "request_duration_ms":
+ 899}}'
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "2.55.2", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.9.16", "platform": "Linux-5.15.49-linuxkit-pr-aarch64-with",
+ "uname": "Linux 7e5f26339edc 5.15.49-linuxkit-pr #1 SMP PREEMPT Thu May 25
+ 07:27:39 UTC 2023 aarch64 "}'
+ method: GET
+ uri: https://api.stripe.com/v1/subscriptions/djfos?expand%5B0%5D=latest_invoice&expand%5B1%5D=customer&expand%5B2%5D=customer.invoice_settings.default_payment_method
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\"\
+ : \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\"\
+ : \"No such subscription: 'djfos'\",\n \"param\": \"id\",\n \"request_log_url\"\
+ : \"https://dashboard.stripe.com/test/logs/req_3UbPl8augcgjaw?t=1689601953\"\
+ ,\n \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, POST, HEAD, OPTIONS, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '324'
+ Content-Type:
+ - application/json
+ Date:
+ - Mon, 17 Jul 2023 13:52:33 GMT
+ Request-Id:
+ - req_3UbPl8augcgjaw
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - '2017-05-25'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 404
+ message: Not Found
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly_with_users_org.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly_with_users_org.yaml
new file mode 100644
index 0000000000..344e985206
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_sentry_plan_monthly_with_users_org.yaml
@@ -0,0 +1,115 @@
+interactions:
+- request:
+ body: billing_address_collection=auto&payment_method_types[0]=card&payment_method_collection=if_required&client_reference_id=72&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjohnmathews%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjohnmathews%3Fcancel&subscription_data[items][0][plan]=price_1Mj1kYGlVGuVgOrk7jucaZAa&subscription_data[items][0][quantity]=12&subscription_data[payment_behavior]=allow_incomplete&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=72&subscription_data[metadata][username]=johnmathews&subscription_data[metadata][obo_name]=Joshua+Murray&subscription_data[metadata][obo_email]=phammatthew%40lynn.org&subscription_data[metadata][obo]=71&subscription_data[trial_period_days]=14&subscription_data[trial_settings][end_behavior][missing_payment_method]=cancel&customer_email=hollowaydeborah%40yahoo.com
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '889'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 74f2d021-03a1-4843-baf9-901640e18259
+ User-Agent:
+ - Stripe/v1 PythonBindings/2.55.2
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics": {"request_id": "req_3UbPl8augcgjaw", "request_duration_ms":
+ 428}}'
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "2.55.2", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.9.16", "platform": "Linux-5.15.49-linuxkit-pr-aarch64-with",
+ "uname": "Linux 7e5f26339edc 5.15.49-linuxkit-pr #1 SMP PREEMPT Thu May 25
+ 07:27:39 UTC 2023 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/checkout/sessions
+ response:
+ body:
+ string: "{\n \"id\": \"cs_test_a1L24ai3WCyYK7UzTMS6HE1Yfhnc28ng781GDaufn1rT6l23ouNABrCsBA\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 0,\n \"amount_total\"\
+ : 0,\n \"automatic_tax\": {\n \"enabled\": false,\n \"status\": null\n\
+ \ },\n \"billing_address_collection\": \"auto\",\n \"cancel_url\": \"http://localhost:3000/plan/gh/johnmathews?cancel\"\
+ ,\n \"client_reference_id\": \"72\",\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1689601954,\n \"currency\": \"usd\",\n \"currency_conversion\"\
+ : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"shipping_address\"\
+ : null,\n \"submit\": null\n },\n \"customer\": null,\n \"customer_creation\"\
+ : \"always\",\n \"customer_details\": {\n \"address\": null,\n \"email\"\
+ : \"hollowaydeborah@yahoo.com\",\n \"name\": null,\n \"phone\": null,\n\
+ \ \"tax_exempt\": \"none\",\n \"tax_ids\": null\n },\n \"customer_email\"\
+ : \"hollowaydeborah@yahoo.com\",\n \"display_items\": [\n {\n \"\
+ amount\": 0,\n \"currency\": \"usd\",\n \"plan\": {\n \"\
+ id\": \"price_1Mj1kYGlVGuVgOrk7jucaZAa\",\n \"object\": \"plan\",\n\
+ \ \"active\": true,\n \"aggregate_usage\": null,\n \"\
+ amount\": null,\n \"amount_decimal\": null,\n \"billing_scheme\"\
+ : \"tiered\",\n \"created\": 1678200262,\n \"currency\": \"\
+ usd\",\n \"interval\": \"month\",\n \"interval_count\": 1,\n\
+ \ \"livemode\": false,\n \"metadata\": {},\n \"name\"\
+ : \"users-sentrym\",\n \"nickname\": null,\n \"product\": \"\
+ prod_NTzkoJuGDejxOc\",\n \"statement_descriptor\": null,\n \"\
+ tiers\": [\n {\n \"amount\": null,\n \"flat_amount\"\
+ : 2900,\n \"flat_amount_decimal\": \"2900\",\n \"unit_amount_decimal\"\
+ : null,\n \"up_to\": 5\n },\n {\n \
+ \ \"amount\": 1200,\n \"flat_amount\": null,\n \"flat_amount_decimal\"\
+ : null,\n \"unit_amount_decimal\": \"1200\",\n \"up_to\"\
+ : null\n }\n ],\n \"tiers_mode\": \"graduated\",\n\
+ \ \"transform_usage\": null,\n \"trial_period_days\": 14,\n\
+ \ \"usage_type\": \"licensed\"\n },\n \"quantity\": 12,\n\
+ \ \"type\": \"plan\"\n }\n ],\n \"expires_at\": 1689688353,\n \"\
+ invoice\": null,\n \"invoice_creation\": null,\n \"livemode\": false,\n\
+ \ \"locale\": null,\n \"metadata\": {},\n \"mode\": \"subscription\",\n\
+ \ \"payment_intent\": null,\n \"payment_link\": null,\n \"payment_method_collection\"\
+ : \"if_required\",\n \"payment_method_options\": null,\n \"payment_method_types\"\
+ : [\n \"card\"\n ],\n \"payment_status\": \"unpaid\",\n \"phone_number_collection\"\
+ : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"setup_intent\"\
+ : null,\n \"shipping\": null,\n \"shipping_address_collection\": null,\n\
+ \ \"shipping_options\": [],\n \"shipping_rate\": null,\n \"status\": \"\
+ open\",\n \"submit_type\": null,\n \"subscription\": null,\n \"success_url\"\
+ : \"http://localhost:3000/plan/gh/johnmathews?success\",\n \"total_details\"\
+ : {\n \"amount_discount\": 0,\n \"amount_shipping\": 0,\n \"amount_tax\"\
+ : 0\n },\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_a1L24ai3WCyYK7UzTMS6HE1Yfhnc28ng781GDaufn1rT6l23ouNABrCsBA#fidkdWxOYHwnPyd1blpxYHZxWjA0MVZPUUpCaVNCcFNiSnduV2JtMn99dTZxVDJnXTFGXDN1a319cjN%2FV19fVmpBU3FQUG9Ed1VORjJqXWBgTGdPS0xGUXRWMk09PUNXY3Jfa1JIdm5VTn1qNTVnRGtwN2w8TCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
+ \n}"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, POST, HEAD, OPTIONS, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '3342'
+ Content-Type:
+ - application/json
+ Date:
+ - Mon, 17 Jul 2023 13:52:34 GMT
+ Idempotency-Key:
+ - 74f2d021-03a1-4843-baf9-901640e18259
+ Original-Request:
+ - req_AaY8IvHbbSDcvz
+ Request-Id:
+ - req_AaY8IvHbbSDcvz
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Should-Retry:
+ - 'false'
+ Stripe-Version:
+ - '2017-05-25'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml
new file mode 100644
index 0000000000..f8c6ccc4d2
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml
@@ -0,0 +1,95 @@
+interactions:
+- request:
+ body: billing_address_collection=required&payment_method_collection=if_required&client_reference_id=93&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjocelyn62%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjocelyn62%3Fcancel&customer=1000&mode=subscription&line_items[0][price]=price_1OCM0gGlVGuVgOrkWDYEBtSL&line_items[0][quantity]=11&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=93&subscription_data[metadata][username]=jocelyn62&subscription_data[metadata][obo_name]=Crystal+Schmitt&subscription_data[metadata][obo_email]=smithamanda%40flowers.biz&subscription_data[metadata][obo]=93&tax_id_collection[enabled]=True&customer_update[name]=auto&customer_update[address]=auto
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '742'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Idempotency-Key:
+ - 26000f5a-feb8-4647-ac57-32a5e5ff8729
+ Stripe-Version:
+ - 2024-12-18.acacia
+ User-Agent:
+ - Stripe/v1 PythonBindings/11.4.1
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics": {"request_id": "req_AaY8IvHbbSDcvz", "request_duration_ms":
+ 2}}'
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version": "11.4.1", "lang": "python", "publisher": "stripe", "httplib":
+ "requests", "lang_version": "3.12.8", "platform": "Linux-6.10.14-linuxkit-aarch64-with-glibc2.36",
+ "uname": "Linux 35c9e7c77efc 6.10.14-linuxkit #1 SMP Fri Nov 29 17:22:03 UTC
+ 2024 aarch64 "}'
+ method: POST
+ uri: https://api.stripe.com/v1/checkout/sessions
+ response:
+ body:
+ string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\":
+ \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\":
+ \"No such customer: '1000'\",\n \"param\": \"customer\",\n \"request_log_url\":
+ \"https://dashboard.stripe.com/test/logs/req_k8lY68XdxWIFHo?t=1737668619\",\n
+ \ \"type\": \"invalid_request_error\"\n }\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '325'
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; upgrade-insecure-requests;
+ report-uri https://q.stripe.com/csp-violation?q=1vgSiZ6UPd0qoBXo1Mjsk02GXFGP3M7PXsjua2jiowWQKm8jByxTHbhTeRirsQsrZ7jscQLjXtdCc_sh
+ Content-Type:
+ - application/json
+ Cross-Origin-Opener-Policy-Report-Only:
+ - same-origin; report-to="coop"
+ Date:
+ - Thu, 23 Jan 2025 21:43:39 GMT
+ Idempotency-Key:
+ - 26000f5a-feb8-4647-ac57-32a5e5ff8729
+ Original-Request:
+ - req_k8lY68XdxWIFHo
+ Report-To:
+ - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}'
+ Reporting-Endpoints:
+ - coop="https://q.stripe.com/coop-report"
+ Request-Id:
+ - req_k8lY68XdxWIFHo
+ Server:
+ - nginx
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ Stripe-Version:
+ - 2024-12-18.acacia
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - AB
+ status:
+ code: 400
+ message: Bad Request
+version: 1
diff --git a/apps/codecov-api/api/internal/tests/views/test_account_viewset.py b/apps/codecov-api/api/internal/tests/views/test_account_viewset.py
new file mode 100644
index 0000000000..0cb97d449a
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_account_viewset.py
@@ -0,0 +1,1845 @@
+import json
+import os
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from django.test import override_settings
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ InvoiceBillingFactory,
+ OwnerFactory,
+ UserFactory,
+)
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, TrialStatus
+from stripe import StripeError
+
+from api.internal.tests.test_utils import GetAdminProviderAdapter
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.models import Service
+from utils.test_utils import APIClient
+
+curr_path = os.path.dirname(__file__)
+
+
+class MockSubscription(object):
+ def __init__(self, subscription_params: dict):
+ self.items = {"data": [{"id": "abc"}]}
+ self.cancel_at_period_end = False
+ self.current_period_end = 1633512445
+ self.latest_invoice = subscription_params.get(
+ "latest_invoice",
+ {
+ "id": "in_123",
+ "status": "complete",
+ },
+ )
+
+ default_payment_method = {
+ "id": "pm_123",
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ },
+ }
+ self.customer = {
+ "invoice_settings": {
+ "default_payment_method": subscription_params.get(
+ "default_payment_method", default_payment_method
+ )
+ },
+ "id": "cus_LK&*Hli8YLIO",
+ "discount": None,
+ "email": None,
+ }
+ self.schedule = subscription_params.get("schedule_id")
+ self.status = subscription_params.get("status", "active")
+ self.collection_method = subscription_params.get(
+ "collection_method", "charge_automatically"
+ )
+ self.trial_end = subscription_params.get("trial_end")
+
+ customer_coupon = subscription_params.get("customer_coupon")
+ if customer_coupon:
+ self.customer["discount"] = {"coupon": customer_coupon}
+
+ pending_update = subscription_params.get("pending_update")
+ if pending_update:
+ self.pending_update = pending_update
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class MockMetadata(object):
+ def __init__(self):
+ self.obo = 2
+ self.obo_organization = 3
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class MockSchedule(object):
+ def __init__(self, schedule_params, phases):
+ self.id = schedule_params["id"]
+ self.phases = phases
+ self.metadata = MockMetadata()
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+@pytest.mark.usefixtures("codecov_vcr")
+class AccountViewSetTests(APITestCase):
+ def _retrieve(self, kwargs={}):
+ if not kwargs:
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ return self.client.get(reverse("account_details-detail", kwargs=kwargs))
+
+ def _update(self, kwargs, data):
+ return self.client.patch(
+ reverse("account_details-detail", kwargs=kwargs), data=data, format="json"
+ )
+
+ def _destroy(self, kwargs):
+ return self.client.delete(reverse("account_details-detail", kwargs=kwargs))
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ def setUp(self):
+ self.service = "gitlab"
+ self.current_owner = OwnerFactory(
+ stripe_customer_id=1000,
+ service=Service.GITHUB.value,
+ service_id="10238974029348",
+ )
+ self.expected_invoice = {
+ "number": "EF0A41E-0001",
+ "status": "paid",
+ "id": "in_19yTU92eZvKYlo2C7uDjvu6v",
+ "created": 1489789429,
+ "period_start": 1487370220,
+ "period_end": 1489789420,
+ "due_date": None,
+ "customer_name": "Peer Company",
+ "customer_address": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "currency": "usd",
+ "amount_paid": 999,
+ "amount_due": 999,
+ "amount_remaining": 0,
+ "total": 999,
+ "subtotal": 999,
+ "invoice_pdf": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ/pdf",
+ "line_items": [
+ {
+ "description": "(10) users-pr-inappm",
+ "amount": 120,
+ "currency": "usd",
+ "plan_name": PlanName.CODECOV_PRO_MONTHLY.value,
+ "quantity": 1,
+ "period": {"end": 1521326190, "start": 1518906990},
+ }
+ ],
+ "footer": None,
+ "customer_email": "olivia.williams.03@example.com",
+ "customer_shipping": None,
+ }
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_retrieve_own_account_give_200(self):
+ response = self._retrieve(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_retrieve_account_gets_account_fields(self):
+ owner = OwnerFactory(admins=[self.current_owner.ownerid])
+ self.current_owner.organizations = [owner.ownerid]
+ self.current_owner.save()
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "activated_user_count": 0,
+ "root_organization": None,
+ "integration_id": owner.integration_id,
+ "plan_auto_activate": owner.plan_auto_activate,
+ "inactive_user_count": 1,
+ "plan": {
+ "marketing_name": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billing_rate": None,
+ "base_unit_price": 0,
+ "benefits": [
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "quantity": 1,
+ },
+ "subscription_detail": None,
+ "checkout_session_id": None,
+ "name": owner.name,
+ "email": owner.email,
+ "nb_active_private_repos": 0,
+ "repo_total_credits": 99999999,
+ "plan_provider": owner.plan_provider,
+ "activated_student_count": 0,
+ "student_count": 0,
+ "schedule_detail": None,
+ "uses_invoice": False,
+ "delinquent": None,
+ }
+
+ @patch("services.billing.stripe.SubscriptionSchedule.retrieve")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_retrieve_account_gets_account_fields_when_there_are_scheduled_details(
+ self, mock_retrieve_subscription, mock_retrieve_schedule
+ ):
+ owner = OwnerFactory(
+ admins=[self.current_owner.ownerid], stripe_subscription_id="sub_123"
+ )
+ self.current_owner.organizations = [owner.ownerid]
+ self.current_owner.save()
+
+ subscription_params = {
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": None,
+ "schedule_id": "sub_sched_456",
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ mock_retrieve_subscription.return_value = MockSubscription(subscription_params)
+ schedule_params = {
+ "id": 123,
+ "start_date": 123689126736,
+ "stripe_plan_id": "plan_pro_yearly",
+ "quantity": 6,
+ }
+ phases = [
+ {},
+ {
+ "start_date": schedule_params["start_date"],
+ "items": [
+ {
+ "plan": schedule_params["stripe_plan_id"],
+ "quantity": schedule_params["quantity"],
+ }
+ ],
+ },
+ ]
+
+ mock_retrieve_schedule.return_value = MockSchedule(schedule_params, phases)
+
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "integration_id": owner.integration_id,
+ "activated_student_count": 0,
+ "activated_user_count": 0,
+ "checkout_session_id": None,
+ "delinquent": None,
+ "email": owner.email,
+ "inactive_user_count": 1,
+ "name": owner.name,
+ "nb_active_private_repos": 0,
+ "plan_auto_activate": True,
+ "plan_provider": owner.plan_provider,
+ "plan": {
+ "marketing_name": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billing_rate": None,
+ "base_unit_price": 0,
+ "benefits": [
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "quantity": 1,
+ },
+ "repo_total_credits": 99999999,
+ "root_organization": None,
+ "schedule_detail": {
+ "id": "123",
+ "scheduled_phase": {
+ "start_date": schedule_params["start_date"],
+ "plan": "Pro",
+ "quantity": schedule_params["quantity"],
+ },
+ },
+ "student_count": 0,
+ "subscription_detail": {
+ "latest_invoice": None,
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "customer": {"id": "cus_LK&*Hli8YLIO", "discount": None, "email": None},
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ "trial_end": None,
+ },
+ "uses_invoice": False,
+ }
+
+ @patch("services.billing.stripe.SubscriptionSchedule.retrieve")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_retrieve_account_returns_last_phase_when_more_than_one_scheduled_phases(
+ self, mock_retrieve_subscription, mock_retrieve_schedule
+ ):
+ owner = OwnerFactory(
+ admins=[self.current_owner.ownerid], stripe_subscription_id="sub_2345687"
+ )
+ self.current_owner.organizations = [owner.ownerid]
+ self.current_owner.save()
+
+ subscription_params = {
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": None,
+ "schedule_id": "sub_sched_456678999",
+ "collection_method": "charge_automatically",
+ "trial_end": 1633512445,
+ "tax_ids": None,
+ }
+
+ mock_retrieve_subscription.return_value = MockSubscription(subscription_params)
+ schedule_params = {
+ "id": 123,
+ "start_date": 123689126736,
+ "stripe_plan_id": "plan_pro_yearly",
+ "quantity": 6,
+ }
+ phases = [
+ {
+ "start_date": 123689126536,
+ "items": [{"plan": "test_plan_123", "quantity": 4}],
+ },
+ {
+ "start_date": 123689126636,
+ "items": [{"plan": "test_plan_456", "quantity": 5}],
+ },
+ {
+ "start_date": schedule_params["start_date"],
+ "items": [
+ {
+ "plan": schedule_params["stripe_plan_id"],
+ "quantity": schedule_params["quantity"],
+ }
+ ],
+ },
+ ]
+
+ mock_retrieve_schedule.return_value = MockSchedule(schedule_params, phases)
+
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "activated_user_count": 0,
+ "root_organization": None,
+ "integration_id": owner.integration_id,
+ "plan_auto_activate": owner.plan_auto_activate,
+ "inactive_user_count": 1,
+ "plan": {
+ "marketing_name": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billing_rate": None,
+ "base_unit_price": 0,
+ "benefits": [
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "quantity": 1,
+ },
+ "subscription_detail": {
+ "latest_invoice": None,
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "customer": {"id": "cus_LK&*Hli8YLIO", "discount": None, "email": None},
+ "collection_method": "charge_automatically",
+ "trial_end": 1633512445,
+ "tax_ids": None,
+ },
+ "checkout_session_id": None,
+ "name": owner.name,
+ "email": owner.email,
+ "nb_active_private_repos": 0,
+ "repo_total_credits": 99999999,
+ "plan_provider": owner.plan_provider,
+ "activated_student_count": 0,
+ "student_count": 0,
+ "schedule_detail": {
+ "id": "123",
+ "scheduled_phase": {
+ "plan": "Pro",
+ "quantity": schedule_params["quantity"],
+ "start_date": schedule_params["start_date"],
+ },
+ },
+ "uses_invoice": False,
+ "delinquent": None,
+ }
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_retrieve_account_gets_none_for_schedule_details_when_schedule_is_nonexistent(
+ self, mock_retrieve_subscription
+ ):
+ owner = OwnerFactory(
+ admins=[self.current_owner.ownerid], stripe_subscription_id="sub_123"
+ )
+ self.current_owner.organizations = [owner.ownerid]
+ self.current_owner.save()
+
+ subscription_params = {
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": None,
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ mock_retrieve_subscription.return_value = MockSubscription(subscription_params)
+
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "activated_user_count": 0,
+ "root_organization": None,
+ "integration_id": owner.integration_id,
+ "plan_auto_activate": owner.plan_auto_activate,
+ "inactive_user_count": 1,
+ "plan": {
+ "marketing_name": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billing_rate": None,
+ "base_unit_price": 0,
+ "benefits": [
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "quantity": 1,
+ },
+ "subscription_detail": {
+ "latest_invoice": None,
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "customer": {"id": "cus_LK&*Hli8YLIO", "discount": None, "email": None},
+ "collection_method": "charge_automatically",
+ "trial_end": None,
+ "tax_ids": None,
+ },
+ "checkout_session_id": None,
+ "name": owner.name,
+ "email": owner.email,
+ "nb_active_private_repos": 0,
+ "repo_total_credits": 99999999,
+ "plan_provider": owner.plan_provider,
+ "activated_student_count": 0,
+ "student_count": 0,
+ "schedule_detail": None,
+ "uses_invoice": False,
+ "delinquent": None,
+ }
+
+ def test_retrieve_account_gets_account_students(self):
+ owner = OwnerFactory(
+ admins=[self.current_owner.ownerid],
+ plan_activated_users=[OwnerFactory(student=True).ownerid],
+ )
+ self.current_owner.organizations = [owner.ownerid]
+ self.current_owner.save()
+ OwnerFactory(organizations=[owner.ownerid], student=True)
+ OwnerFactory(organizations=[owner.ownerid], student=True)
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "activated_user_count": 0,
+ "root_organization": None,
+ "integration_id": owner.integration_id,
+ "plan_auto_activate": owner.plan_auto_activate,
+ "inactive_user_count": 1,
+ "plan": response.data["plan"],
+ "subscription_detail": None,
+ "checkout_session_id": None,
+ "name": owner.name,
+ "email": owner.email,
+ "nb_active_private_repos": 0,
+ "repo_total_credits": 99999999,
+ "plan_provider": owner.plan_provider,
+ "activated_student_count": 1,
+ "student_count": 3,
+ "schedule_detail": None,
+ "uses_invoice": False,
+ "delinquent": None,
+ }
+
+ def test_account_with_free_user_plan(self):
+ self.current_owner.plan = DEFAULT_FREE_PLAN
+ self.current_owner.save()
+ response = self._retrieve()
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["plan"] == {
+ "marketing_name": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billing_rate": None,
+ "base_unit_price": 0,
+ "benefits": [
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "quantity": self.current_owner.plan_user_count,
+ }
+
+ def test_account_with_paid_user_plan_billed_monthly(self):
+ self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value
+ self.current_owner.save()
+ response = self._retrieve()
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["plan"] == {
+ "marketing_name": "Pro",
+ "value": PlanName.CODECOV_PRO_MONTHLY.value,
+ "billing_rate": "monthly",
+ "base_unit_price": 12,
+ "benefits": [
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ "quantity": self.current_owner.plan_user_count,
+ }
+
+ def test_account_with_paid_user_plan_billed_annually(self):
+ self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.current_owner.save()
+ response = self._retrieve()
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["plan"] == {
+ "marketing_name": "Pro",
+ "value": PlanName.CODECOV_PRO_YEARLY.value,
+ "billing_rate": "annually",
+ "base_unit_price": 10,
+ "benefits": [
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ "quantity": self.current_owner.plan_user_count,
+ }
+
+ def test_retrieve_account_returns_401_if_not_authenticated(self):
+ owner = OwnerFactory()
+ self.client.logout()
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_retrieve_account_returns_401_if_no_current_owner(self):
+ owner = OwnerFactory()
+ user = UserFactory()
+ self.client.logout()
+ self.client.force_login(user)
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == 403
+
+ def test_retrieve_account_returns_404_if_user_not_member(self):
+ owner = OwnerFactory()
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_retrieve_subscription_with_stripe_invoice_data(self, mock_subscription):
+ f = open("./services/tests/samples/stripe_invoice.json")
+
+ default_payment_method = {
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ "should be": "removed",
+ }
+ }
+
+ subscription_params = {
+ "default_payment_method": default_payment_method,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": json.load(f)["data"][0],
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ mock_subscription.return_value = MockSubscription(subscription_params)
+
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ response = self._retrieve()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["subscription_detail"] == {
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": self.expected_invoice,
+ "default_payment_method": {
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ }
+ },
+ "customer": {"id": "cus_LK&*Hli8YLIO", "discount": None, "email": None},
+ "collection_method": "charge_automatically",
+ "trial_end": None,
+ "tax_ids": None,
+ }
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_retrieve_handles_stripe_error(self, mock_get_subscription):
+ code, message = 404, "Didn't find that"
+ mock_get_subscription.side_effect = StripeError(
+ message=message, http_status=code
+ )
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ response = self._retrieve()
+
+ assert response.status_code == code
+ assert response.data["detail"] == message
+
+ def test_update_can_set_plan_auto_activate_to_true(self):
+ self.current_owner.plan_auto_activate = False
+ self.current_owner.save()
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan_auto_activate": True},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.current_owner.refresh_from_db()
+
+ assert self.current_owner.plan_auto_activate is True
+ assert response.data["plan_auto_activate"] is True
+
+ def test_update_can_set_plan_auto_activate_to_false(self):
+ self.current_owner.plan_auto_activate = True
+ self.current_owner.save()
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan_auto_activate": False},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.current_owner.refresh_from_db()
+
+ assert self.current_owner.plan_auto_activate is False
+ assert response.data["plan_auto_activate"] is False
+
+ def test_update_can_set_plan_auto_activate_on_org_with_account(self):
+ self.current_owner.account = AccountFactory()
+ self.current_owner.plan_auto_activate = True
+ self.current_owner.save()
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan_auto_activate": False},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.current_owner.refresh_from_db()
+
+ assert self.current_owner.plan_auto_activate is False
+ assert response.data["plan_auto_activate"] is False
+
+ def test_update_can_set_plan_to_users_developer_should_set_to_developer(self):
+ self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.current_owner.save()
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": {"value": DEFAULT_FREE_PLAN}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.current_owner.refresh_from_db()
+
+ assert self.current_owner.plan == DEFAULT_FREE_PLAN
+ assert self.current_owner.plan_activated_users is None
+ assert self.current_owner.plan_user_count == 1
+ assert response.data["plan_auto_activate"] is True
+
+ @patch("services.billing.stripe.checkout.Session.create")
+ def test_update_can_upgrade_to_paid_plan_for_new_customer_and_return_checkout_session_id(
+ self, create_checkout_session_mock
+ ):
+ expected_id = "this is the id"
+ create_checkout_session_mock.return_value = {"id": expected_id}
+ self.current_owner.stripe_subscription_id = None
+ self.current_owner.save()
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": {"quantity": 25, "value": PlanName.CODECOV_PRO_YEARLY.value}},
+ )
+
+ create_checkout_session_mock.assert_called_once()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["checkout_session_id"] == expected_id
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ def test_update_can_upgrade_to_paid_plan_for_existing_customer_and_set_plan_info(
+ self, modify_subscription_mock, retrieve_subscription_mock
+ ):
+ desired_plan = {"value": PlanName.CODECOV_PRO_MONTHLY.value, "quantity": 12}
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value
+ self.current_owner.plan_user_count = 8
+ self.current_owner.save()
+
+ f = open("./services/tests/samples/stripe_invoice.json")
+
+ default_payment_method = {
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ "should be": "removed",
+ }
+ }
+
+ subscription_params = {
+ "default_payment_method": default_payment_method,
+ "latest_invoice": json.load(f)["data"][0],
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ modify_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ modify_subscription_mock.assert_called_once()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["plan"]["value"] == desired_plan["value"]
+ assert response.data["plan"]["quantity"] == desired_plan["quantity"]
+
+ self.current_owner.refresh_from_db()
+ assert self.current_owner.plan == desired_plan["value"]
+ assert self.current_owner.plan_user_count == desired_plan["quantity"]
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ def test_upgrade_payment_failure(
+ self, modify_subscription_mock, retrieve_subscription_mock
+ ):
+ desired_plan = {"value": PlanName.CODECOV_PRO_MONTHLY.value, "quantity": 12}
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value
+ self.current_owner.plan_user_count = 8
+ self.current_owner.delinquent = False
+ self.current_owner.save()
+
+ f = open("./services/tests/samples/stripe_invoice.json")
+
+ default_payment_method = {
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ "should be": "removed",
+ }
+ }
+ subscription_params = {
+ "default_payment_method": default_payment_method,
+ "latest_invoice": json.load(f)["data"][0],
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ "pending_update": {
+ "expires_at": 1571194285,
+ "subscription_items": [
+ {
+ "id": "si_09IkI4u3ZypJUk5onGUZpe8O",
+ "price": "price_CBb6IXqvTLXp3f",
+ }
+ ],
+ },
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ modify_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ modify_subscription_mock.assert_called_once()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["plan"]["value"] == desired_plan["value"]
+ assert response.data["plan"]["quantity"] == 8
+
+ self.current_owner.refresh_from_db()
+ assert self.current_owner.plan == desired_plan["value"]
+ assert self.current_owner.plan_user_count == 8
+ assert self.current_owner.delinquent == True
+
+ def test_update_requires_quantity_if_updating_to_paid_plan(self):
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value}
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_update_quantity_must_be_greater_or_equal_to_current_activated_users_if_paid_plan(
+ self,
+ ):
+ self.current_owner.plan_activated_users = [1] * 15
+ self.current_owner.save()
+ desired_plan = {"value": PlanName.CODECOV_PRO_MONTHLY.value, "quantity": 14}
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.stripe.checkout.Session.create")
+ def test_update_must_validate_active_users_without_counting_active_students(
+ self, create_checkout_session_mock
+ ):
+ expected_id = "sample id"
+ create_checkout_session_mock.return_value = {"id": expected_id}
+ self.current_owner.stripe_subscription_id = None
+ self.current_owner.plan_activated_users = [
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=True).ownerid,
+ OwnerFactory(student=True).ownerid,
+ OwnerFactory(student=True).ownerid,
+ ]
+ self.current_owner.save()
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 8}
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ create_checkout_session_mock.assert_called_once()
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_update_must_fail_if_quantity_is_lower_than_activated_user_count(self):
+ self.current_owner.plan_activated_users = [
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ OwnerFactory(student=False).ownerid,
+ ]
+ self.current_owner.save()
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 8}
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ response.data["plan"]["non_field_errors"][0]
+ == "Quantity cannot be lower than currently activated user count"
+ )
+
+ def test_update_must_fail_if_quantity_and_plan_are_equal_to_the_owners_current_ones(
+ self,
+ ):
+ self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.current_owner.plan_user_count = 14
+ self.current_owner.save()
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 14}
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ response.data["plan"]["non_field_errors"][0]
+ == "Quantity or plan for paid plan must be different from the existing one"
+ )
+
+ def test_update_team_plan_must_fail_if_too_many_activated_users_during_trial(self):
+ self.current_owner.plan = DEFAULT_FREE_PLAN
+ self.current_owner.plan_user_count = 1
+ self.current_owner.trial_status = TrialStatus.ONGOING.value
+ self.current_owner.plan_activated_users = list(range(11))
+ self.current_owner.save()
+
+ desired_plans = [
+ {"value": PlanName.TEAM_MONTHLY.value, "quantity": 10},
+ {"value": PlanName.TEAM_YEARLY.value, "quantity": 10},
+ ]
+
+ for desired_plan in desired_plans:
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "Invalid value for plan:" in response.json()["plan"]["value"][0]
+
+ def test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users(self):
+ self.current_owner.plan = PlanName.TEAM_MONTHLY.value
+ self.current_owner.plan_user_count = 1
+ self.current_owner.save()
+
+ desired_plans = [
+ {"value": PlanName.TEAM_MONTHLY.value, "quantity": 11},
+ {"value": PlanName.TEAM_YEARLY.value, "quantity": 11},
+ ]
+
+ for desired_plan in desired_plans:
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ response.data["plan"]["non_field_errors"][0]
+ == "Quantity for Team plan cannot exceed 10"
+ )
+
+ def test_update_must_fail_if_team_plan_and_too_many_users(self):
+ desired_plans = [
+ {"value": PlanName.TEAM_MONTHLY.value, "quantity": 11},
+ {"value": PlanName.TEAM_YEARLY.value, "quantity": 11},
+ ]
+
+ for desired_plan in desired_plans:
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ response.data["plan"]["non_field_errors"][0]
+ == "Quantity for Team plan cannot exceed 10"
+ )
+
+ def test_update_quantity_must_fail_if_account(self):
+ desired_plans = [
+ {"quantity": 10},
+ ]
+ self.current_owner.account = AccountFactory()
+ self.current_owner.save()
+ for desired_plan in desired_plans:
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ str(response.data["plan"]["non_field_errors"][0])
+ == "You cannot update your plan manually, for help or changes to plan, connect with sales@codecov.io"
+ )
+
+ def test_update_plan_must_fail_if_account(self):
+ desired_plans = [
+ {"value": PlanName.CODECOV_PRO_YEARLY.value},
+ ]
+ self.current_owner.account = AccountFactory()
+ self.current_owner.save()
+ for desired_plan in desired_plans:
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ str(response.data["plan"]["non_field_errors"][0])
+ == "You cannot update your plan manually, for help or changes to plan, connect with sales@codecov.io"
+ )
+
+ def test_update_quantity_must_be_at_least_2_if_paid_plan(self):
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 1}
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert (
+ response.data["plan"]["non_field_errors"][0]
+ == "Quantity for paid plan must be greater than 1"
+ )
+
+ def test_update_payment_method_without_body(self):
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ url = reverse("account_details-update-payment", kwargs=kwargs)
+ response = self.client.patch(url, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.StripeService._is_unverified_payment_method")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ def test_update_payment_method(
+ self,
+ modify_subscription_mock,
+ modify_customer_mock,
+ attach_payment_mock,
+ retrieve_subscription_mock,
+ is_unverified_payment_method_mock,
+ ):
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+ f = open("./services/tests/samples/stripe_invoice.json")
+
+ is_unverified_payment_method_mock.return_value = False
+
+ default_payment_method = {
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ "should be": "removed",
+ }
+ }
+
+ subscription_params = {
+ "default_payment_method": default_payment_method,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": json.load(f)["data"][0],
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ payment_method_id = "pm_123"
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"payment_method": payment_method_id}
+ url = reverse("account_details-update-payment", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_200_OK
+ attach_payment_mock.assert_called_once_with(
+ payment_method_id, customer=self.current_owner.stripe_customer_id
+ )
+ modify_customer_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": payment_method_id},
+ )
+
+ modify_subscription_mock.assert_called_once_with(
+ self.current_owner.stripe_subscription_id,
+ default_payment_method=payment_method_id,
+ )
+
+ @patch("services.billing.StripeService.update_payment_method")
+ def test_update_payment_method_handles_stripe_error(self, upm_mock):
+ code, message = 402, "Oops, nope"
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ upm_mock.side_effect = StripeError(message=message, http_status=code)
+
+ payment_method_id = "pm_123"
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"payment_method": payment_method_id}
+ url = reverse("account_details-update-payment", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == code
+ assert response.data["detail"] == message
+
+ def test_update_email_address_without_body(self):
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ url = reverse("account_details-update-email", kwargs=kwargs)
+ response = self.client.patch(url, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.StripeService.update_email_address")
+ def test_update_email_address_handles_stripe_error(self, stripe_mock):
+ code, message = 402, "Oops, nope"
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ stripe_mock.side_effect = StripeError(message=message, http_status=code)
+
+ new_email = "test@gmail.com"
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"new_email": new_email}
+ url = reverse("account_details-update-email", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == code
+ assert response.data["detail"] == message
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_email_address(self, modify_customer_mock, retrieve_mock):
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ new_email = "test@gmail.com"
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"new_email": new_email}
+ url = reverse("account_details-update-email", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_200_OK
+
+ modify_customer_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id, email=new_email
+ )
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.PaymentMethod.modify")
+ @patch("services.billing.stripe.Customer.retrieve")
+ def test_update_email_address_with_propagate(
+ self,
+ customer_retrieve_mock,
+ payment_method_mock,
+ modify_customer_mock,
+ retrieve_mock,
+ ):
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ payment_method_id = "pm_123"
+ customer_retrieve_mock.return_value = {
+ "invoice_settings": {"default_payment_method": payment_method_id}
+ }
+
+ new_email = "test@gmail.com"
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"new_email": new_email, "apply_to_default_payment_method": True}
+ url = reverse("account_details-update-email", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_200_OK
+
+ modify_customer_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id, email=new_email
+ )
+ customer_retrieve_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id
+ )
+ payment_method_mock.assert_called_once_with(
+ payment_method_id, billing_details={"email": new_email}
+ )
+
+ def test_update_billing_address_without_body(self):
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ url = reverse("account_details-update-billing-address", kwargs=kwargs)
+ response = self.client.patch(url, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_update_billing_address_without_name(self):
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ billing_address = {
+ "line_1": "45 Fremont St.",
+ "line_2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ }
+ data = {"billing_address": billing_address}
+ url = reverse("account_details-update-billing-address", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_update_billing_address_without_address(self):
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"name": "John Doe"}
+ url = reverse("account_details-update-billing-address", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.StripeService.update_billing_address")
+ def test_update_billing_address_handles_stripe_error(self, stripe_mock):
+ code, message = 402, "Oops, nope"
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ stripe_mock.side_effect = StripeError(message=message, http_status=code)
+
+ billing_address = {
+ "line_1": "45 Fremont St.",
+ "line_2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ }
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"name": "John Doe", "billing_address": billing_address}
+ url = reverse("account_details-update-billing-address", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == code
+ assert response.data["detail"] == message
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.PaymentMethod.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_billing_address(
+ self,
+ modify_customer_mock,
+ modify_payment_mock,
+ retrieve_customer_mock,
+ retrieve_sub_mock,
+ ):
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+ f = open("./services/tests/samples/stripe_invoice.json")
+
+ billing_address = {
+ "line_1": "45 Fremont St.",
+ "line_2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ }
+
+ formatted_address = {
+ "line1": "45 Fremont St.",
+ "line2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ }
+
+ default_payment_method = {
+ "id": "pm_123",
+ "card": {
+ "brand": "visa",
+ "exp_month": 12,
+ "exp_year": 2024,
+ "last4": "abcd",
+ },
+ }
+
+ subscription_params = {
+ "default_payment_method": default_payment_method,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": json.load(f)["data"][0],
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ retrieve_sub_mock.return_value = MockSubscription(subscription_params)
+
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ data = {"name": "John Doe", "billing_address": billing_address}
+ url = reverse("account_details-update-billing-address", kwargs=kwargs)
+ response = self.client.patch(url, data=data, format="json")
+ assert response.status_code == status.HTTP_200_OK
+
+ retrieve_customer_mock.assert_called_once()
+ modify_payment_mock.assert_called_once()
+ modify_customer_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id, address=formatted_address
+ )
+
+ @patch("api.shared.permissions.get_provider")
+ def test_update_without_admin_permissions_returns_404(self, get_provider_mock):
+ get_provider_mock.return_value = GetAdminProviderAdapter()
+ owner = OwnerFactory()
+ response = self._update(
+ kwargs={"service": owner.service, "owner_username": owner.username}, data={}
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_update_can_change_name_and_email(self):
+ expected_name, expected_email = "Scooby Doo", "scoob@snack.com"
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"name": expected_name, "email": expected_email},
+ )
+
+ assert response.data["name"] == expected_name
+ assert response.data["email"] == expected_email
+ self.current_owner.refresh_from_db()
+ assert self.current_owner.name == expected_name
+ assert self.current_owner.email == expected_email
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_handles_stripe_error(self, retrieve_sub_mock, modify_sub_mock):
+ code, message = 402, "Not right, wrong in fact"
+ desired_plan = {"value": PlanName.CODECOV_PRO_MONTHLY.value, "quantity": 12}
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+ retrieve_sub_mock.return_value = MockSubscription({})
+ modify_sub_mock.side_effect = StripeError(message=message, http_status=code)
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+
+ assert response.status_code == code
+ assert response.data["detail"] == message
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("api.internal.owner.serializers.send_sentry_webhook")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_sentry_plan_monthly(
+ self, modify_sub_mock, send_sentry_webhook, retrieve_sub_mock
+ ):
+ desired_plan = {"value": PlanName.SENTRY_MONTHLY.value, "quantity": 12}
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.sentry_user_id = "sentry-user-id"
+ self.current_owner.save()
+
+ self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+ send_sentry_webhook.assert_called_once_with(
+ self.current_owner, self.current_owner
+ )
+
+ @patch("api.internal.owner.serializers.send_sentry_webhook")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_sentry_plan_monthly_with_users_org(
+ self, modify_sub_mock, send_sentry_webhook
+ ):
+ desired_plan = {"value": PlanName.SENTRY_MONTHLY.value, "quantity": 12}
+ org = OwnerFactory(
+ service=Service.GITHUB.value,
+ service_id="923836740",
+ )
+
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.sentry_user_id = "sentry-user-id"
+ self.current_owner.organizations = [org.ownerid]
+ self.current_owner.save()
+
+ self._update(
+ kwargs={"service": org.service, "owner_username": org.username},
+ data={"plan": desired_plan},
+ )
+ send_sentry_webhook.assert_called_once_with(self.current_owner, org)
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("api.internal.owner.serializers.send_sentry_webhook")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_sentry_plan_annual(
+ self,
+ modify_sub_mock,
+ send_sentry_webhook,
+ retrieve_sub_mock,
+ ):
+ desired_plan = {"value": PlanName.SENTRY_YEARLY.value, "quantity": 12}
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.sentry_user_id = "sentry-user-id"
+ self.current_owner.save()
+
+ self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"plan": desired_plan},
+ )
+ send_sentry_webhook.assert_called_once_with(
+ self.current_owner, self.current_owner
+ )
+
+ @patch("api.internal.owner.serializers.send_sentry_webhook")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_sentry_plan_annual_with_users_org(
+ self, modify_sub_mock, send_sentry_webhook
+ ):
+ desired_plan = {"value": PlanName.SENTRY_YEARLY.value, "quantity": 12}
+ org = OwnerFactory(
+ service=Service.GITHUB.value,
+ service_id="923836740",
+ )
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.sentry_user_id = "sentry-user-id"
+ self.current_owner.organizations = [org.ownerid]
+ self.current_owner.save()
+
+ self._update(
+ kwargs={"service": org.service, "owner_username": org.username},
+ data={"plan": desired_plan},
+ )
+ send_sentry_webhook.assert_called_once_with(self.current_owner, org)
+
+ @patch("api.internal.owner.serializers.send_sentry_webhook")
+ @patch("services.billing.StripeService.modify_subscription")
+ def test_update_sentry_plan_non_sentry_user(
+ self, modify_sub_mock, send_sentry_webhook
+ ):
+ desired_plan = {"value": PlanName.SENTRY_MONTHLY.value, "quantity": 5}
+ org = OwnerFactory(
+ service=Service.GITHUB.value,
+ service_id="923836740",
+ )
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.sentry_user_id = None
+ self.current_owner.organizations = [org.ownerid]
+ self.current_owner.save()
+
+ res = self._update(
+ kwargs={"service": org.service, "owner_username": org.username},
+ data={"plan": desired_plan},
+ )
+
+ # cannot upgrade to Sentry plan
+ assert res.status_code == 400
+ assert res.json() == {
+ "plan": {
+ "value": [
+ f"Invalid value for plan: users-sentrym; must be one of ['users-pr-inappm', 'users-pr-inappy', 'users-teamm', 'users-teamy', '{DEFAULT_FREE_PLAN}']"
+ ]
+ }
+ }
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_apply_cancellation_discount(
+ self, modify_customer_mock, retrieve_subscription_mock, coupon_create_mock
+ ):
+ coupon_create_mock.return_value = MagicMock(id="test-coupon-id")
+
+ self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ subscription_params = {
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": None,
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "customer_coupon": {
+ "name": "30% off for 6 months",
+ "percent_off": 30.0,
+ "duration_in_months": 6,
+ "created": int(datetime(2023, 1, 1, 0, 0, 0).timestamp()),
+ },
+ "tax_ids": None,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"apply_cancellation_discount": True},
+ )
+
+ modify_customer_mock.assert_called_once_with(
+ self.current_owner.stripe_customer_id,
+ coupon="test-coupon-id",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["subscription_detail"]["customer"]["discount"] == {
+ "name": "30% off for 6 months",
+ "percent_off": 30.0,
+ "duration_in_months": 6,
+ "expires": int(datetime(2023, 7, 1, 0, 0, 0).timestamp()),
+ }
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_apply_cancellation_discount_yearly(
+ self, modify_customer_mock, retrieve_subscription_mock, coupon_create_mock
+ ):
+ coupon_create_mock.return_value = MagicMock(id="test-coupon-id")
+
+ self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.current_owner.stripe_customer_id = "flsoe"
+ self.current_owner.stripe_subscription_id = "djfos"
+ self.current_owner.save()
+
+ subscription_params = {
+ "default_payment_method": None,
+ "cancel_at_period_end": False,
+ "current_period_end": 1633512445,
+ "latest_invoice": None,
+ "schedule_id": None,
+ "collection_method": "charge_automatically",
+ "tax_ids": None,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ response = self._update(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ },
+ data={"apply_cancellation_discount": True},
+ )
+
+ assert not modify_customer_mock.called
+ assert not coupon_create_mock.called
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["subscription_detail"]["customer"]["discount"] is None
+
+ @patch("services.task.TaskService.delete_owner")
+ def test_destroy_triggers_delete_owner_task(self, delete_owner_mock):
+ response = self._destroy(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ )
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ delete_owner_mock.assert_called_once_with(self.current_owner.ownerid)
+
+ def test_destroy_not_own_account_returns_404(self):
+ owner = OwnerFactory(admins=[self.current_owner.ownerid])
+ response = self._destroy(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_retrieve_org_with_account(self):
+ account = AccountFactory(
+ name="Hello World",
+ plan_seat_count=5,
+ free_seat_count=3,
+ plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value,
+ is_delinquent=False,
+ )
+ InvoiceBillingFactory(is_active=True, account=account)
+ org_1 = OwnerFactory(
+ account=account,
+ service=Service.GITHUB.value,
+ username="Test",
+ delinquent=True,
+ uses_invoice=False,
+ )
+ org_2 = OwnerFactory(
+ account=account,
+ service=Service.GITHUB.value,
+ )
+ activated_owner = OwnerFactory(
+ user=UserFactory(), organizations=[org_1.ownerid, org_2.ownerid]
+ )
+ account.users.add(activated_owner.user)
+ student_owner = OwnerFactory(
+ user=UserFactory(),
+ student=True,
+ organizations=[org_1.ownerid, org_2.ownerid],
+ )
+ account.users.add(student_owner.user)
+ other_activated_owner = OwnerFactory(
+ user=UserFactory(), organizations=[org_2.ownerid]
+ )
+ account.users.add(other_activated_owner.user)
+ other_student_owner = OwnerFactory(
+ user=UserFactory(),
+ student=True,
+ organizations=[org_2.ownerid],
+ )
+ account.users.add(other_student_owner.user)
+ org_1.plan_activated_users = [activated_owner.ownerid, student_owner.ownerid]
+ org_1.admins = [activated_owner.ownerid]
+ org_1.save()
+ org_2.plan_activated_users = [
+ activated_owner.ownerid,
+ student_owner.ownerid,
+ other_activated_owner.ownerid,
+ other_student_owner.ownerid,
+ ]
+ org_2.save()
+
+ self.client.force_login_owner(activated_owner)
+ response = self._retrieve(
+ kwargs={"service": Service.GITHUB.value, "owner_username": org_1.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ # these fields are all overridden by account fields if the org has an account
+ self.assertEqual(org_1.activated_user_count, 1)
+ self.assertEqual(org_1.activated_student_count, 1)
+ self.assertTrue(org_1.delinquent)
+ self.assertFalse(org_1.uses_invoice)
+ self.assertEqual(org_1.plan_user_count, 1)
+ expected_response = {
+ "activated_user_count": 2,
+ "activated_student_count": 2,
+ "delinquent": False,
+ "uses_invoice": True,
+ "plan": {
+ "marketing_name": "Enterprise Cloud",
+ "value": PlanName.ENTERPRISE_CLOUD_YEARLY.value,
+ "billing_rate": "annually",
+ "base_unit_price": 10,
+ "benefits": [
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ "quantity": 5,
+ },
+ "root_organization": None,
+ "integration_id": org_1.integration_id,
+ "plan_auto_activate": org_1.plan_auto_activate,
+ "inactive_user_count": 0,
+ "subscription_detail": None,
+ "checkout_session_id": None,
+ "name": org_1.name,
+ "email": org_1.email,
+ "nb_active_private_repos": 0,
+ "repo_total_credits": 99999999,
+ "plan_provider": org_1.plan_provider,
+ "student_count": 1,
+ "schedule_detail": None,
+ }
+ self.assertDictEqual(response.data["plan"], expected_response["plan"])
+ self.assertDictEqual(response.data, expected_response)
+
+
+@override_settings(IS_ENTERPRISE=True)
+class EnterpriseAccountViewSetTests(APITestCase):
+ def _retrieve(self, kwargs={}):
+ if not kwargs:
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ return self.client.get(reverse("account_details-detail", kwargs=kwargs))
+
+ def _update(self, kwargs, data):
+ return self.client.patch(
+ reverse("account_details-detail", kwargs=kwargs), data=data, format="json"
+ )
+
+ def _destroy(self, kwargs):
+ return self.client.delete(reverse("account_details-detail", kwargs=kwargs))
+
+ def setUp(self):
+ self.service = "gitlab"
+ self.current_owner = OwnerFactory(
+ stripe_customer_id=1000,
+ service=Service.GITHUB.value,
+ service_id="10238974029348",
+ )
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_retrieve_own_account_give_200(self):
+ response = self._retrieve(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ )
+ assert response.status_code == status.HTTP_200_OK
diff --git a/apps/codecov-api/api/internal/tests/views/test_compare_viewset.py b/apps/codecov-api/api/internal/tests/views/test_compare_viewset.py
new file mode 100644
index 0000000000..b7fcf603c7
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_compare_viewset.py
@@ -0,0 +1,462 @@
+from unittest.mock import PropertyMock, patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.api_report_service import SerializableReport
+from shared.reports.resources import ReportFile
+from shared.reports.types import ReportTotals
+from shared.utils.merge import LineType
+
+import services.comparison as comparison
+from api.shared.commit.serializers import ReportTotalsSerializer
+from utils.test_utils import Client
+
+
+class MockSerializableReport(SerializableReport):
+ """
+ Stubs the 'get' method of SerializableReport, which usually constructs
+ report files on the fly from information not provided by these test, like the chunks
+ for example.
+ """
+
+ def get(self, file_name):
+ return self.mocked_files.get(file_name)
+
+ @property
+ def files(self):
+ return self.mocked_files.keys()
+
+ def __contains__(self, f):
+ return f in self.mocked_files.keys()
+
+
+class MockedComparisonAdapter:
+ def __init__(self, test_diff, test_lines=[]):
+ self.test_lines = test_lines
+ self.test_diff = test_diff
+
+ async def get_source(self, file_name, commitid):
+ return {"content": self.test_lines}
+
+ async def get_compare(self, base, head):
+ return self.test_diff
+
+ async def get_authenticated(self):
+ return False, False
+
+
+@patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+@patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+class TestCompareViewSetRetrieve(APITestCase):
+ """
+ Tests for retrieving a comparison. Does not test data that will be deprecated,
+ eg base and head report fields. Tests for commits etc will be added as the
+ compare-api refactor progresses.
+ """
+
+ def setUp(self):
+ self.file_name = "myfile.py"
+
+ self.mock_git_compare_data = {
+ "commits": [],
+ "diff": {
+ "files": {
+ self.file_name: {
+ "type": "modified",
+ "segments": [
+ {
+ "header": ["4", "43", "4", "3"],
+ "lines": ["", "", ""] + ["-this line is removed"] * 40,
+ }
+ ],
+ "stats": {"removed": 40, "added": 0},
+ "totals": ReportTotals.default_totals(),
+ }
+ }
+ },
+ }
+
+ self.mocked_compare_adapter = MockedComparisonAdapter(
+ self.mock_git_compare_data
+ )
+
+ self.base_file = ReportFile(
+ name=self.file_name, totals=[46, 46, 0, 0, 100, 0, 0, 0, 1, 0, 0, 0]
+ )
+ self.base_file._parsed_lines = [[1, "", [[1, 1, 0, 0, 0]], 0, 0]] * 46
+ self.base_report = MockSerializableReport()
+ self.base_report.mocked_files = {self.file_name: self.base_file}
+
+ self.head_file = ReportFile(
+ name=self.file_name, totals=[6, 6, 0, 0, 100, 0, 0, 0, 1, 0, 0, 0]
+ )
+ self.head_file._parsed_lines = [[1, "", [[1, 1, 0, 0, 0]], 0, 0]] * 6
+ self.head_file.totals.diff = ReportTotals.default_totals()
+ self.head_report = MockSerializableReport()
+ self.head_report.mocked_files = {self.file_name: self.head_file}
+
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.base, self.head = (
+ CommitFactory(repository=self.repo),
+ CommitFactory(repository=self.repo),
+ )
+ self.current_owner = OwnerFactory(
+ service=self.org.service,
+ permission=[self.repo.repoid],
+ organizations=[self.org.ownerid],
+ )
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ self.expected_files = [
+ {
+ "name": {"base": self.file_name, "head": self.file_name},
+ "totals": {
+ "base": ReportTotalsSerializer(self.base_file.totals).data,
+ "head": ReportTotalsSerializer(self.head_file.totals).data,
+ "patch": ReportTotalsSerializer(ReportTotals.default_totals()).data,
+ },
+ "has_diff": True,
+ "stats": {"added": 0, "removed": 40},
+ "change_summary": {},
+ "lines": [
+ {
+ "value": "",
+ "number": {"base": idx, "head": idx},
+ "coverage": {"base": LineType.hit, "head": LineType.hit},
+ "added": False,
+ "removed": False,
+ "is_diff": True,
+ "sessions": 1,
+ }
+ for idx in range(4, 7)
+ ]
+ + [
+ {
+ "value": "-this line is removed",
+ "number": {"base": idx, "head": None},
+ "coverage": {"base": LineType.hit, "head": None},
+ "added": False,
+ "removed": True,
+ "is_diff": True,
+ "sessions": None,
+ }
+ for idx in range(7, 47)
+ ],
+ }
+ ]
+
+ def _get_comparison(self, kwargs={}, query_params={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ if query_params == {}:
+ query_params = {"base": self.base.commitid, "head": self.head.commitid}
+
+ return self.client.get(
+ reverse("compare-detail", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+
+ def _get_file_comparison(self, file_name="", kwargs={}, query_params={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "file_path": file_name or self.file_name,
+ }
+ if query_params == {}:
+ query_params = {"base": self.base.commitid, "head": self.head.commitid}
+
+ return self.client.get(
+ reverse("compare-file", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+
+ def test_can_return_public_repo_comparison_with_not_authenticated(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ public_repo = RepositoryFactory(author=self.org, private=False)
+ base, head = (
+ CommitFactory(repository=public_repo),
+ CommitFactory(repository=public_repo),
+ )
+
+ self.client.logout()
+ response = self._get_comparison(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": public_repo.name,
+ },
+ query_params={"base": base.commitid, "head": head.commitid},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_returns_200_and_expected_files_on_success(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ def test_returns_404_if_base_or_head_references_not_found(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ response = self._get_comparison(query_params={"base": 12345, "head": 678})
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_returns_404_if_user_doesnt_have_permissions(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ other_user = OwnerFactory()
+ self.client.force_login(user=other_user)
+
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == 404
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ def test_accepts_pullid_query_param(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ def test_pullid_with_nonexistent_base_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base="123456",
+ head=self.head.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_pullid_with_nonexistent_head_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head="123456",
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_diffs_larger_than_MAX_DIFF_SIZE_doesnt_include_lines(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ previous_max = comparison.MAX_DIFF_SIZE
+ comparison.MAX_DIFF_SIZE = (
+ len(
+ self.mock_git_compare_data["diff"]["files"][self.file_name]["segments"][
+ 0
+ ]["lines"]
+ )
+ - 1
+ )
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert (
+ response.data["files"][0]["lines"] is None
+ ) # None means diff was truncated
+
+ comparison.MAX_DIFF_SIZE = previous_max
+
+ def test_file_returns_compare_file_with_diff_and_src_data(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ src = b"first\nfirst\nfirst\nfirst\nfirst\nfirst"
+
+ adapter_mock.return_value = MockedComparisonAdapter(
+ test_diff=self.mock_git_compare_data, test_lines=src
+ )
+
+ response = self._get_file_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+
+ expected_lines = [
+ {
+ "value": "first",
+ "number": {"base": idx, "head": idx},
+ "coverage": {"base": LineType.hit, "head": LineType.hit},
+ "added": False,
+ "removed": False,
+ "is_diff": False,
+ "sessions": 1,
+ }
+ for idx in range(1, 4)
+ ] + self.expected_files[0]["lines"]
+
+ assert response.data["lines"] == expected_lines
+
+ def test_file_ignores_MAX_DIFF_SIZE(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ previous_max = comparison.MAX_DIFF_SIZE
+ comparison.MAX_DIFF_SIZE = -1
+
+ src = b"first\nfirst\nfirst\nfirst\nfirst\nfirst"
+ adapter_mock.return_value = MockedComparisonAdapter(
+ test_diff=self.mock_git_compare_data, test_lines=src
+ )
+
+ response = self._get_file_comparison()
+
+ comparison.MAX_DIFF_SIZE = previous_max
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["lines"]) == 46
+
+ def test_missing_base_report_returns_none_base_totals(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = None
+ head_report_mock.return_value = self.head_report
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["totals"]["base"] is None
+
+ def test_no_raw_reports_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = None
+ head_report_mock.side_effect = comparison.MissingComparisonReport(
+ "Missing head report"
+ )
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_returns_403_if_user_inactive(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ self.org.plan = "users-inappy"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self._get_comparison()
+ assert response.status_code == 403
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ @patch(
+ "services.comparison.PullRequestComparison.pseudo_diff_adjusts_tracked_lines",
+ new_callable=PropertyMock,
+ )
+ @patch(
+ "services.comparison.PullRequestComparison.update_base_report_with_pseudo_diff"
+ )
+ def test_pull_request_pseudo_comparison_can_update_base_report(
+ self,
+ update_base_report_mock,
+ pseudo_diff_adjusts_tracked_lines_mock,
+ adapter_mock,
+ base_report_mock,
+ head_report_mock,
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ pseudo_diff_adjusts_tracked_lines_mock.return_value = True
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ update_base_report_mock.assert_called_once()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
diff --git a/apps/codecov-api/api/internal/tests/views/test_coverage_viewset.py b/apps/codecov-api/api/internal/tests/views/test_coverage_viewset.py
new file mode 100644
index 0000000000..b3248490e5
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_coverage_viewset.py
@@ -0,0 +1,348 @@
+from unittest.mock import patch
+from urllib.parse import urlencode
+
+from django.db import connection
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from services.components import Component
+from utils.test_utils import Client
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ third_file = ReportFile("file3.py")
+ third_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.append(third_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+class CoverageViewSetTests(APITestCase):
+ def _tree(self, **params):
+ url = reverse(
+ "coverage-tree",
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+
+ return self.client.get(url)
+
+ def setUp(self):
+ self.current_owner = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.current_owner)
+
+ # the order in which these commits are created matters
+ # because the branch head is the one that is created
+ # later
+ self.commit1 = CommitFactory(
+ author=self.current_owner,
+ repository=self.repo,
+ )
+ self.commit2 = CommitFactory(
+ author=self.current_owner,
+ repository=self.repo,
+ )
+ self.branch = BranchFactory(repository=self.repo, name="test-branch")
+
+ self.commit3 = CommitFactory(
+ author=self.current_owner,
+ repository=self.repo,
+ branch=self.branch.name,
+ )
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "UPDATE branches SET head = %s WHERE branches.repoid = %s AND branches.branch = %s",
+ [self.commit3.commitid, self.repo.repoid, self.branch.name],
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.components.commit_components")
+ def test_tree(self, commit_components_mock, build_report_from_commit):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": [".*/*.py"],
+ }
+ ),
+ ]
+ build_report_from_commit.return_value = sample_report()
+ res = self._tree(components="Global")
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "foo",
+ "full_path": "foo",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ "children": [
+ {
+ "name": "file1.py",
+ "full_path": "foo/file1.py",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ }
+ ],
+ },
+ {
+ "name": "bar",
+ "full_path": "bar",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ "children": [
+ {
+ "name": "file2.py",
+ "full_path": "bar/file2.py",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ }
+ ],
+ },
+ {
+ "name": "file3.py",
+ "full_path": "file3.py",
+ "coverage": 100.0,
+ "lines": 1,
+ "hits": 1,
+ "partials": 0,
+ "misses": 0,
+ },
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+ commit_components_mock.assert_called_once_with(self.commit1, self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_sha(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(sha=self.commit2.commitid)
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "foo",
+ "full_path": "foo",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ "children": [
+ {
+ "name": "file1.py",
+ "full_path": "foo/file1.py",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ }
+ ],
+ },
+ {
+ "name": "bar",
+ "full_path": "bar",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ "children": [
+ {
+ "name": "file2.py",
+ "full_path": "bar/file2.py",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ }
+ ],
+ },
+ {
+ "name": "file3.py",
+ "full_path": "file3.py",
+ "coverage": 100.0,
+ "lines": 1,
+ "hits": 1,
+ "partials": 0,
+ "misses": 0,
+ },
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit2)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_missing_sha(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(sha="wrong")
+ assert res.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_branch(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(branch="test-branch")
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "foo",
+ "full_path": "foo",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ "children": [
+ {
+ "name": "file1.py",
+ "full_path": "foo/file1.py",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ }
+ ],
+ },
+ {
+ "name": "bar",
+ "full_path": "bar",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ "children": [
+ {
+ "name": "file2.py",
+ "full_path": "bar/file2.py",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ }
+ ],
+ },
+ {
+ "name": "file3.py",
+ "full_path": "file3.py",
+ "coverage": 100.0,
+ "lines": 1,
+ "hits": 1,
+ "partials": 0,
+ "misses": 0,
+ },
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit3)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_missing_branch(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(branch="wrong-branch")
+ assert res.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_missing_report(self, build_report_from_commit):
+ build_report_from_commit.return_value = None
+
+ res = self._tree()
+ assert res.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.components.commit_components")
+ def test_tree_no_data_for_components(
+ self, commit_components_mock, build_report_from_commit
+ ):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["dne.py"],
+ }
+ ),
+ ]
+ build_report_from_commit.return_value = sample_report()
+ res = self._tree(components="ComponentOne")
+ assert res.json() == []
+ commit_components_mock.assert_called_once_with(self.commit1, self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.components.commit_components")
+ def test_tree_not_found_for_components(
+ self, commit_components_mock, build_report_from_commit
+ ):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["dne.py"],
+ }
+ ),
+ ]
+ build_report_from_commit.return_value = sample_report()
+ res = self._tree(components="Does_not_exist")
+ assert res.status_code == 404
+ commit_components_mock.assert_called_once_with(self.commit1, self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_no_data_for_flags(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+ res = self._tree(flags="Does_not_exist")
+ assert res.json() == []
diff --git a/apps/codecov-api/api/internal/tests/views/test_current_user_view.py b/apps/codecov-api/api/internal/tests/views/test_current_user_view.py
new file mode 100644
index 0000000000..4a8ac2044c
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_current_user_view.py
@@ -0,0 +1,36 @@
+from django.utils import timezone
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, UserFactory
+
+
+class CurrentUserViewTests(APITestCase):
+ def setUp(self):
+ self.user = UserFactory(terms_agreement=True, terms_agreement_at=timezone.now())
+ self.owner1 = OwnerFactory(user=self.user)
+ self.owner2 = OwnerFactory(user=self.user)
+ self.owner3 = OwnerFactory()
+
+ def test_current_user_unauthenticated(self):
+ res = self.client.get(reverse("current-user"))
+ assert res.status_code == 401
+
+ def test_current_user_authenticated(self):
+ self.client.force_login(self.user)
+ res = self.client.get(reverse("current-user"))
+ assert res.status_code == 200
+ data = res.data
+ ownerids = [owner["ownerid"] for owner in data.pop("owners")]
+ assert data == {
+ "email": self.user.email,
+ "name": self.user.name,
+ "external_id": str(self.user.external_id),
+ "terms_agreement": self.user.terms_agreement,
+ "terms_agreement_at": self.user.terms_agreement_at.strftime(
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ),
+ }
+ assert len(ownerids) == 2
+ assert self.owner1.ownerid in ownerids
+ assert self.owner2.ownerid in ownerids
+ assert self.owner3.ownerid not in ownerids
diff --git a/apps/codecov-api/api/internal/tests/views/test_license_view.py b/apps/codecov-api/api/internal/tests/views/test_license_view.py
new file mode 100644
index 0000000000..4a7189fc0d
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_license_view.py
@@ -0,0 +1,78 @@
+from datetime import datetime
+from unittest.mock import patch
+
+from django.test import RequestFactory, override_settings
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.license import LicenseInformation
+
+from api.internal.license.views import LicenseView
+from codecov.tests.base_test import InternalAPITest
+from utils.test_utils import Client
+
+
+class LicenseViewTest(InternalAPITest):
+ def setUp(self):
+ self.current_owner = OwnerFactory()
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("api.internal.license.views.get_current_license")
+ def test_license_view(self, mocked_license):
+ mocked_license.return_value = LicenseInformation(
+ is_valid=True,
+ message=None,
+ url="https://codeov.mysite.com",
+ number_allowed_users=5,
+ number_allowed_repos=10,
+ expires=datetime.strptime("2020-05-09 00:00:00", "%Y-%m-%d %H:%M:%S"),
+ is_trial=True,
+ is_pr_billing=False,
+ )
+
+ request = RequestFactory().get("/")
+ view = LicenseView()
+ view.setup(request)
+ response = view.get(request)
+
+ expected_result = {
+ "trial": True,
+ "url": "https://codeov.mysite.com",
+ "users": 5,
+ "repos": 10,
+ "expires_at": "2020-05-09T00:00:00Z",
+ "pr_billing": False,
+ }
+
+ assert response.data == expected_result
+
+ @override_settings(ROOT_URLCONF="api.internal.enterprise_urls")
+ @patch("api.internal.license.views.get_current_license")
+ def test_license_url(self, mocked_license):
+ mocked_license.return_value = LicenseInformation(
+ is_valid=True,
+ message=None,
+ url=None,
+ number_allowed_users=5,
+ number_allowed_repos=None,
+ expires=datetime.strptime("2020-05-09 00:00:00", "%Y-%m-%d %H:%M:%S"),
+ is_trial=True,
+ is_pr_billing=False,
+ )
+
+ response = self.client.get(
+ reverse(
+ "license",
+ )
+ )
+
+ expected_result = {
+ "trial": True,
+ "url": None,
+ "users": 5,
+ "repos": None,
+ "expires_at": "2020-05-09T00:00:00Z",
+ "pr_billing": False,
+ }
+
+ assert response.data == expected_result
diff --git a/apps/codecov-api/api/internal/tests/views/test_owner_viewset.py b/apps/codecov-api/api/internal/tests/views/test_owner_viewset.py
new file mode 100644
index 0000000000..282f389fb2
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_owner_viewset.py
@@ -0,0 +1,66 @@
+from rest_framework import status
+from rest_framework.exceptions import ErrorDetail
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+
+class OwnerViewSetTests(APITestCase):
+ def _retrieve(self, kwargs):
+ return self.client.get(reverse("owners-detail", kwargs=kwargs))
+
+ def setUp(self):
+ self.service = "bitbucket"
+ self.user = OwnerFactory(service="github", stripe_customer_id=1000)
+
+ def test_retrieve_returns_owner_with_username(self):
+ owner = OwnerFactory()
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": owner.service,
+ "username": owner.username,
+ "name": owner.name,
+ "stats": owner.cache["stats"],
+ "avatar_url": owner.avatar_url,
+ "ownerid": owner.ownerid,
+ "integration_id": owner.integration_id,
+ }
+
+ def test_retrieve_returns_owner_with_period_username(self):
+ owner = OwnerFactory(username="codecov.test")
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": owner.service,
+ "username": owner.username,
+ "name": owner.name,
+ "stats": owner.cache["stats"],
+ "avatar_url": owner.avatar_url,
+ "ownerid": owner.ownerid,
+ "integration_id": owner.integration_id,
+ }
+
+ def test_retrieve_returns_404_if_no_matching_username(self):
+ response = self._retrieve(kwargs={"service": "github", "owner_username": "fff"})
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data == {
+ "detail": ErrorDetail(
+ string="No Owner matches the given query.", code="not_found"
+ )
+ }
+
+ def test_retrieve_owner_unknown_service_returns_404(self):
+ response = self._retrieve(
+ kwargs={"service": "not-real", "owner_username": "anything"}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data == {
+ "detail": ErrorDetail(
+ string="Service not found: not-real", code="not_found"
+ )
+ }
diff --git a/apps/codecov-api/api/internal/tests/views/test_repo_view.py b/apps/codecov-api/api/internal/tests/views/test_repo_view.py
new file mode 100644
index 0000000000..78634528fd
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_repo_view.py
@@ -0,0 +1,941 @@
+from unittest.mock import patch
+
+from django.utils import timezone
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from api.internal.commit.serializers import CommitTotalsSerializer
+from api.internal.tests.test_utils import GetAdminProviderAdapter
+from codecov.tests.base_test import InternalAPITest
+from core.models import Repository
+from utils.test_utils import Client
+
+
+class RepositoryViewSetTestSuite(InternalAPITest):
+ def _list(self, kwargs={}, query_params={}):
+ if kwargs == {}:
+ kwargs = {"service": self.org.service, "owner_username": self.org.username}
+
+ return self.client.get(reverse("repos-list", kwargs=kwargs), data=query_params)
+
+ def _retrieve(self, kwargs={}, data={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ return self.client.get(reverse("repos-detail", kwargs=kwargs), data=data)
+
+ def _update(self, kwargs={}, data={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ return self.client.patch(
+ reverse("repos-detail", kwargs=kwargs),
+ data=data,
+ content_type="application/json",
+ )
+
+ def _destroy(self, kwargs={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ return self.client.delete(reverse("repos-detail", kwargs=kwargs))
+
+
+class TestRepositoryViewSetList(RepositoryViewSetTestSuite):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+
+ self.repo1 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="A"
+ )
+ self.repo2 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="B"
+ )
+
+ repos_with_permission = [self.repo1.repoid, self.repo2.repoid]
+
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repos_with_permission,
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_can_retrieve_repo_list_if_not_authenticated(self):
+ self.client.logout()
+ response = self._list()
+ assert response.status_code == 200
+
+ def test_order_by_updatestamp(self):
+ response = self._list(query_params={"ordering": "updatestamp"})
+
+ assert response.data["results"][0]["repoid"] == self.repo1.repoid
+ assert response.data["results"][1]["repoid"] == self.repo2.repoid
+
+ reverse_response = self._list(query_params={"ordering": "-updatestamp"})
+
+ assert reverse_response.data["results"][0]["repoid"] == self.repo2.repoid
+ assert reverse_response.data["results"][1]["repoid"] == self.repo1.repoid
+
+ def test_order_by_name(self):
+ response = self._list(query_params={"ordering": "name"})
+
+ assert response.data["results"][0]["repoid"] == self.repo1.repoid
+ assert response.data["results"][1]["repoid"] == self.repo2.repoid
+
+ reverse_response = self._list(query_params={"ordering": "-name"})
+
+ assert reverse_response.data["results"][0]["repoid"] == self.repo2.repoid
+ assert reverse_response.data["results"][1]["repoid"] == self.repo1.repoid
+
+ def test_order_by_coverage(self):
+ default_totals = {
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ }
+
+ CommitFactory(repository=self.repo1, totals={**default_totals, "c": 25})
+ CommitFactory(repository=self.repo1, totals={**default_totals, "c": 41})
+ CommitFactory(repository=self.repo2, totals={**default_totals, "c": 32})
+
+ response = self._list(query_params={"ordering": "coverage"})
+
+ assert response.data["results"][0]["repoid"] == self.repo2.repoid
+ assert response.data["results"][1]["repoid"] == self.repo1.repoid
+
+ reverse_response = self._list(query_params={"ordering": "-coverage"})
+
+ assert reverse_response.data["results"][0]["repoid"] == self.repo1.repoid
+ assert reverse_response.data["results"][1]["repoid"] == self.repo2.repoid
+
+ def test_order_by_lines(self):
+ default_totals = {
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ }
+
+ CommitFactory(repository=self.repo1, totals={**default_totals, "n": 25})
+ CommitFactory(repository=self.repo2, totals={**default_totals, "n": 32})
+
+ response = self._list(query_params={"ordering": "lines"})
+
+ assert response.data["results"][0]["repoid"] == self.repo1.repoid
+ assert response.data["results"][1]["repoid"] == self.repo2.repoid
+
+ reverse_response = self._list(query_params={"ordering": "-lines"})
+
+ assert reverse_response.data["results"][0]["repoid"] == self.repo2.repoid
+ assert reverse_response.data["results"][1]["repoid"] == self.repo1.repoid
+
+ def test_totals_serializer(self):
+ default_totals = {
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ }
+
+ CommitFactory(repository=self.repo1, totals=default_totals)
+ # Make sure we only get the commit from the default branch
+ CommitFactory(
+ repository=self.repo1, totals={**default_totals, "c": 90.0}, branch="other"
+ )
+
+ response = self._list(query_params={"names": "A"})
+
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["files"]
+ == default_totals["f"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["lines"]
+ == default_totals["n"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["hits"]
+ == default_totals["h"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["misses"]
+ == default_totals["m"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["partials"]
+ == default_totals["p"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["coverage"]
+ == default_totals["c"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["branches"]
+ == default_totals["b"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["methods"]
+ == default_totals["d"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["sessions"]
+ == default_totals["s"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["complexity"]
+ == default_totals["C"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["complexity_total"]
+ == default_totals["N"]
+ )
+ assert (
+ response.data["results"][0]["latest_commit_totals"]["complexity_ratio"] == 0
+ )
+
+ def test_get_totals_with_timestamp(self):
+ default_totals = {
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ }
+ older_coverage = 90.0
+
+ CommitFactory(
+ repository=self.repo1, totals={**default_totals, "c": older_coverage}
+ )
+ # We're testing that the lte works as expected, so we're not sending the exact same timestamp
+ fetching_time = timezone.now().isoformat()
+
+ CommitFactory(repository=self.repo1, totals=default_totals)
+
+ response = self._list(query_params={"names": "A", "before_date": fetching_time})
+
+ # The fetching truncates the time, so it will not take into account the time part of the date time
+ assert response.data["results"][0]["latest_commit_totals"]["coverage"] == 100.0
+
+ def test_get_repos_with_totals(self):
+ default_totals = {
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ }
+
+ CommitFactory(repository=self.repo1, totals=default_totals)
+
+ response = self._list(query_params={"exclude_uncovered": True})
+
+ assert response.data["count"] == 1
+
+ def test_get_active_repos(self):
+ RepositoryFactory(author=self.org, name="C")
+ response = self._list(query_params={"active": True})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ len(response.data["results"]),
+ 2,
+ "got the wrong number of repos: {}".format(len(response.data["results"])),
+ )
+
+ def test_get_inactive_repos(self):
+ RepositoryFactory(author=self.org, name="C", private=False)
+
+ response = self._list(query_params={"active": False})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ len(response.data["results"]),
+ 1,
+ "got the wrong number of repos: {}".format(len(response.data["results"])),
+ )
+
+ def test_get_all_repos(self):
+ RepositoryFactory(author=self.org, name="C", private=False)
+
+ response = self._list()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ len(response.data["results"]),
+ 3,
+ "got the wrong number of repos: {}".format(len(response.data["results"])),
+ )
+
+ def test_get_all_repos_by_name(self):
+ RepositoryFactory(author=self.org, name="C", private=False)
+
+ response = self._list(query_params={"names": ["A", "B"]})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ len(response.data["results"]),
+ 2,
+ "got the wrong number of repos: {}".format(len(response.data["results"])),
+ )
+
+ def test_returns_private_repos_if_user_has_permission(self):
+ new_repo = RepositoryFactory(author=self.org, name="C")
+ self.current_owner.permission.append(new_repo.repoid)
+ self.current_owner.save()
+
+ response = self._list()
+
+ assert response.status_code == 200
+ assert len(response.data["results"]) == 3
+
+ def test_returns_private_repos_if_user_owns_repo(self):
+ new_repo = RepositoryFactory(author=self.current_owner, name="C")
+
+ response = self._list(
+ {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ )
+
+ assert response.status_code == 200
+ assert new_repo.name in [repo["name"] for repo in response.data["results"]]
+
+ def test_returns_public_repos_if_not_owned_by_user_and_not_in_permissions_array(
+ self,
+ ):
+ new_repo = RepositoryFactory(author=self.org, name="C", private=False)
+
+ response = self._list()
+
+ assert response.status_code == 200
+ assert new_repo.name in [repo["name"] for repo in response.data["results"]]
+
+ def test_doesnt_return_private_repos_if_above_conditions_not_met(self):
+ # Private repo, not owned by user, not in users permissions array
+ private_repo = RepositoryFactory(author=self.org, name="C", private=True)
+
+ response = self._list()
+
+ assert response.status_code == 200
+ assert private_repo.name not in [
+ repo["name"] for repo in response.data["results"]
+ ]
+
+ def test_returns_latest_coverage_change(self):
+ CommitFactory(
+ totals={
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo1,
+ )
+ CommitFactory(
+ totals={
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 70.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo1,
+ )
+
+ response = self._list()
+ repo1 = [repo for repo in response.data["results"] if repo["name"] == "A"][0]
+ assert repo1["latest_coverage_change"] == -30
+
+ def test_latest_commit_null(self):
+ response = self._list()
+ repo1 = [repo for repo in response.data["results"] if repo["name"] == "A"][0]
+
+ # When the commit is missing, its set to None or empty string.
+ assert repo1["latest_commit_totals"] is None
+
+ def test_returns_latest_commit_totals(self):
+ commit = CommitFactory(repository=self.repo1)
+ response = self._list()
+ repo1 = [repo for repo in response.data["results"] if repo["name"] == "A"][0]
+
+ assert (
+ repo1["latest_commit_totals"] == CommitTotalsSerializer(commit.totals).data
+ )
+
+
+class TestRepositoryViewSetExtraActions(RepositoryViewSetTestSuite):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+
+ self.repo1 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="A"
+ )
+ self.repo2 = RepositoryFactory(
+ author=self.org, active=True, private=True, name="B"
+ )
+ self.repo1Commit1 = CommitFactory(
+ totals={
+ "f": 1,
+ "n": 4,
+ "h": 4,
+ "m": 0,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo1,
+ )
+ self.repo1Commit2 = CommitFactory(
+ totals={
+ "f": 1,
+ "n": 4,
+ "h": 0,
+ "m": 0,
+ "p": 4,
+ "c": 70.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo1,
+ )
+ self.repo2Commit1 = CommitFactory(
+ totals={
+ "f": 1,
+ "n": 8,
+ "h": 4,
+ "m": 4,
+ "p": 0,
+ "c": 100.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo2,
+ )
+ self.repo2Commit2 = CommitFactory(
+ totals={
+ "f": 1,
+ "n": 8,
+ "h": 3,
+ "m": 5,
+ "p": 0,
+ "c": 60.0,
+ "b": 0,
+ "d": 0,
+ "s": 1,
+ "C": 0.0,
+ "N": 0.0,
+ "diff": "",
+ },
+ repository=self.repo2,
+ )
+
+ repos_with_permission = [self.repo1.repoid, self.repo2.repoid]
+
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repos_with_permission,
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class TestRepositoryViewSetDetailActions(RepositoryViewSetTestSuite):
+ def setUp(self):
+ self.org = OwnerFactory(
+ username="codecov", service="github", service_id="5767537"
+ )
+ self.repo = RepositoryFactory(
+ author=self.org,
+ active=True,
+ private=True,
+ name="repo1",
+ service_id="201298242",
+ )
+
+ self.current_owner = OwnerFactory(
+ username="codecov-user", service="github", organizations=[self.org.ownerid]
+ )
+
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_can_retrieve_repo_if_not_authenticated(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+ self.client.logout()
+ author = OwnerFactory()
+ public_repo = RepositoryFactory(author=author, private=False)
+ response = self._retrieve(
+ kwargs={
+ "service": public_repo.author.service,
+ "owner_username": public_repo.author.username,
+ "repo_name": public_repo.name,
+ }
+ )
+ assert response.status_code == 200
+
+ def test_cant_access_private_repo_if_not_authenticated(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = False, False
+ self.client.logout()
+ author = OwnerFactory()
+ public_repo = RepositoryFactory(author=author, private=True)
+ response = self._retrieve(
+ kwargs={
+ "service": public_repo.author.service,
+ "owner_username": public_repo.author.username,
+ "repo_name": public_repo.name,
+ }
+ )
+ assert response.status_code == 404
+
+ def test_retrieve_with_view_and_edit_permissions_succeeds(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+ response = self._retrieve()
+ self.assertEqual(response.status_code, 200)
+ assert "upload_token" in response.data
+
+ def test_retrieve_without_read_permissions_returns_404(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = False, False
+ response = self._retrieve()
+ assert response.status_code == 404
+
+ def test_retrieve_for_inactive_user_returns_403(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+ self.org.plan = "users-inappy"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self._retrieve()
+ assert response.status_code == 403
+ assert response.data["detail"] == "User not activated"
+
+ def test_retrieve_without_edit_permissions_returns_detail_view_without_upload_token(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, False
+ response = self._retrieve()
+ assert response.status_code == 200
+ assert "upload_token" not in response.data
+
+ def test_destroy_repo_with_admin_rights_succeeds(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+ self.org.admins = [self.current_owner.ownerid]
+ self.org.save()
+ response = self._destroy()
+ assert response.status_code == 204
+ assert not Repository.objects.filter(name="repo1").exists()
+
+ @patch("api.shared.permissions.get_provider")
+ def test_destroy_repo_with_provider_admin_rights_succeedes(
+ self, mocked_get_provider, mocked_get_permissions
+ ):
+ mocked_get_provider.return_value = GetAdminProviderAdapter(result=True)
+ mocked_get_permissions.return_value = True, True
+ response = self._destroy()
+ assert response.status_code == 204
+ assert not Repository.objects.filter(name="repo1").exists()
+
+ @patch("api.shared.permissions.get_provider")
+ def test_destroy_repo_without_admin_rights_returns_403(
+ self, mocked_get_provider, mocked_get_permissions
+ ):
+ mocked_get_provider.return_value = GetAdminProviderAdapter()
+ mocked_get_permissions.return_value = True, True
+
+ assert self.current_owner.ownerid not in self.org.admins
+
+ response = self._destroy()
+ assert response.status_code == 403
+ assert Repository.objects.filter(name="repo1").exists()
+
+ def test_destroy_repo_as_inactive_user_returns_403(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+ self.org.admins = [self.current_owner.ownerid]
+ self.org.plan = "users-inappy"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self._destroy()
+ assert response.status_code == 403
+ assert response.data["detail"] == "User not activated"
+ assert Repository.objects.filter(name="repo1").exists()
+
+ def test_update_default_branch_with_permissions_succeeds(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+ new_default_branch = "dev"
+
+ response = self._update(data={"branch": new_default_branch})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.data["branch"],
+ "dev",
+ "got unexpected response: {}".format(response.data["branch"]),
+ )
+ self.repo.refresh_from_db()
+ assert self.repo.branch == new_default_branch
+
+ def test_update_default_branch_without_write_permissions_returns_403(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, False
+
+ response = self._update(data={"branch": "dev"})
+ self.assertEqual(response.status_code, 403)
+
+ def test_retrieve_returns_yaml(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, False
+
+ yaml = {"yaml": "val"}
+ self.repo.yaml = yaml
+ self.repo.save()
+
+ response = self._retrieve()
+ assert response.status_code == 200
+ assert response.data["yaml"] == yaml
+
+ def test_activation_checks_if_credits_available_for_legacy_users(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+
+ self.org.plan = "v4-10m"
+ self.org.save()
+
+ for i in range(9): # including the one used by other tests, should be 10 total
+ RepositoryFactory(
+ name=str(i) + "random", author=self.org, private=True, active=True
+ )
+
+ RepositoryFactory(author=self.org, private=True, active=False)
+
+ activation_data = {"active": True}
+ response = self._update(data=activation_data)
+
+ assert response.status_code == 403
+
+ def test_repo_bot_returns_username_if_bot_not_null(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+ username = "huecoTanks"
+ self.repo.bot = OwnerFactory(username=username)
+ self.repo.save()
+
+ response = self._retrieve()
+
+ assert "bot" in response.data
+ assert response.data["bot"] == username
+
+ def test_retrieve_with_no_commits_doesnt_crash(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+
+ self.repo.commits.all().delete()
+
+ response = self._retrieve()
+ assert response.status_code == 200
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, _: "")
+ def test_retrieve_returns_latest_commit_data(self, mocked_get_permissions):
+ self.maxDiff = None
+ mocked_get_permissions.return_value = True, True
+ commit = CommitFactory(
+ repository=self.repo,
+ _report={
+ "files": {
+ "test_file_1.py": [
+ 2,
+ [1, 10, 8, 2, 5, "80.00000", 6, 7, 9, 8, 20, 40, 13],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ],
+ "test_file_2.py": [
+ 0,
+ [1, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ },
+ "sessions": {},
+ },
+ )
+
+ from api.internal.commit.serializers import CommitWithFileLevelReportSerializer
+
+ expected_commit_payload = CommitWithFileLevelReportSerializer(commit).data
+
+ response = self._retrieve()
+ assert response.status_code == 200
+ assert (
+ response.data["latest_commit"]["report"]["totals"]
+ == expected_commit_payload["report"]["totals"]
+ )
+ self.assertEqual(
+ response.data["latest_commit"]["report"]["files"],
+ [
+ {
+ "name": "test_file_1.py",
+ "totals": {
+ "files": 1,
+ "lines": 10,
+ "hits": 8,
+ "misses": 2,
+ "partials": 5,
+ "coverage": 80.0,
+ "branches": 6,
+ "methods": 7,
+ "sessions": 8,
+ "complexity": 20.0,
+ "complexity_total": 40.0,
+ "complexity_ratio": 50.0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "test_file_2.py",
+ "totals": {
+ "files": 1,
+ "lines": 3,
+ "hits": 2,
+ "misses": 1,
+ "partials": 0,
+ "coverage": 66.66,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ )
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, _: "")
+ def test_retrieve_returns_latest_commit_of_default_branch_if_branch_not_specified(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+
+ commit = CommitFactory(repository=self.repo)
+ more_recent_commit = CommitFactory(repository=self.repo, branch="other-branch")
+
+ response = self._retrieve()
+
+ assert response.data["latest_commit"]["commitid"] == commit.commitid
+ assert response.data["latest_commit"]["commitid"] != more_recent_commit.commitid
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, _: "")
+ def test_retrieve_accepts_branch_query_param_to_specify_latest_commit(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+
+ commit = CommitFactory(repository=self.repo, branch="other-branch")
+ more_recent_commit = CommitFactory(repository=self.repo)
+
+ response = self._retrieve(data={"branch": "other-branch"})
+
+ assert response.data["latest_commit"]["commitid"] == commit.commitid
+ assert response.data["latest_commit"]["commitid"] != more_recent_commit.commitid
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, _: "")
+ def test_latest_commit_is_none_if_dne(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+
+ response = self._retrieve()
+
+ assert response.data["latest_commit"] is None
+
+ def test_can_retrieve_repo_name_containing_dot(self, mocked_get_permissions):
+ mocked_get_permissions.return_value = True, True
+
+ self.repo.name = "codecov.io"
+ self.repo.save()
+
+ response = self._retrieve()
+ self.assertEqual(response.status_code, 200)
+
+ # Note (Matt): the only special char that github isn't
+ # filtering is .
+ def test_can_retrieve_repo_name_containing_special_char(
+ self, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+ self.repo.name = "codec@v.i"
+ self.repo.save()
+
+ response = self._retrieve()
+ self.assertEqual(response.status_code, 200)
+
+ def test_permissions_check_handles_torngit_error(self, mocked_get_permissions):
+ err_code, err_message = 403, "yo, no."
+ mocked_get_permissions.side_effect = TorngitClientGeneralError(
+ err_code, message=err_message, response_data=None
+ )
+ response = self._retrieve()
+ assert response.status_code == err_code
+ assert response.data == {"detail": err_message}
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_details")
+ def test_get_object_handles_torngit_error(
+ self, mocked_get_details, mocked_get_perms
+ ):
+ mocked_get_perms.return_value = True, True
+ err_code, err_message = 403, "yo, no."
+ mocked_get_details.side_effect = TorngitClientGeneralError(
+ status_code=err_code, message=err_message, response_data=None
+ )
+ response = self._retrieve()
+ assert response.status_code == err_code
+ assert response.data == {"detail": err_message}
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_details")
+ @patch(
+ "api.shared.repo.repository_accessors.RepoAccessors.fetch_from_git_and_create_repo"
+ )
+ def test_create_repo_on_fetch_if_dne(
+ self, mocked_fetch_and_create, mocked_get_repo_details, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+ mocked_get_repo_details.return_value = None
+ mocked_fetch_and_create.return_value = self.repo
+
+ response = self._retrieve(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ )
+ mocked_fetch_and_create.assert_called()
+ mocked_fetch_and_create.assert_called()
+ self.assertEqual(response.status_code, 200)
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_details")
+ @patch(
+ "api.shared.repo.repository_accessors.RepoAccessors.fetch_from_git_and_create_repo"
+ )
+ def test_unable_to_fetch_git_repo(
+ self, mocked_fetch_and_create, mocked_get_repo_details, mocked_get_permissions
+ ):
+ mocked_get_permissions.return_value = True, True
+ mocked_get_repo_details.return_value = None
+ mocked_fetch_and_create.side_effect = TorngitClientGeneralError(
+ 403, response_data=None, message="Forbidden"
+ )
+
+ response = self._retrieve(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": "new-repo",
+ }
+ )
+ mocked_fetch_and_create.assert_called()
+ mocked_fetch_and_create.assert_called()
+ self.assertEqual(response.status_code, 403)
+
+ def test_fetch_repo_with_fork_doesnt_crash(self, mocked_get_perms):
+ mocked_get_perms.return_value = True, True
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author, fork=RepositoryFactory())
+ self._retrieve(
+ kwargs={
+ "service": repo.author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ }
+ )
diff --git a/apps/codecov-api/api/internal/tests/views/test_self_hosted_settings_viewset.py b/apps/codecov-api/api/internal/tests/views/test_self_hosted_settings_viewset.py
new file mode 100644
index 0000000000..404e5437ff
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_self_hosted_settings_viewset.py
@@ -0,0 +1,26 @@
+from django.test import TestCase, override_settings
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from utils.test_utils import APIClient
+
+
+@override_settings(IS_ENTERPRISE=True, ROOT_URLCONF="api.internal.enterprise_urls")
+class SettingsViewsetUnauthenticatedTestCase(TestCase):
+ def test_settings(self):
+ res = self.client.get(reverse("selfhosted-users-list"))
+ # not authenticated
+ assert res.status_code == 401
+
+
+@override_settings(IS_ENTERPRISE=True, ROOT_URLCONF="api.internal.enterprise_urls")
+class SettingsViewsetNonadminTestCase(TestCase):
+ def setUp(self):
+ self.current_owner = OwnerFactory()
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_settings(self):
+ res = self.client.get(reverse("selfhosted-users-list"))
+ # not authenticated
+ assert res.status_code == 403
diff --git a/apps/codecov-api/api/internal/tests/views/test_self_hosted_user_viewset.py b/apps/codecov-api/api/internal/tests/views/test_self_hosted_user_viewset.py
new file mode 100644
index 0000000000..9bbcdc486c
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_self_hosted_user_viewset.py
@@ -0,0 +1,293 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import Owner
+from services.self_hosted import activate_owner, is_activated_owner
+from utils.test_utils import APIClient
+
+
+@override_settings(IS_ENTERPRISE=True, ROOT_URLCONF="api.internal.enterprise_urls")
+class UserViewsetUnauthenticatedTestCase(TestCase):
+ def test_list_users(self):
+ res = self.client.get(reverse("selfhosted-users-list"))
+ # not authenticated
+ assert res.status_code == 401
+
+
+@override_settings(IS_ENTERPRISE=True, ROOT_URLCONF="api.internal.enterprise_urls")
+class UserViewsetTestCase(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+ self.current_owner = OwnerFactory(organizations=[self.owner.ownerid])
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+
+class UserViewsetAuthenticatedTestCase(UserViewsetTestCase):
+ def test_list_users(self):
+ res = self.client.get(reverse("selfhosted-users-list"))
+ # not an admin
+ assert res.status_code == 403
+
+ def test_detail(self):
+ other_owner = OwnerFactory()
+
+ res = self.client.get(
+ reverse("selfhosted-users-detail", kwargs={"pk": other_owner.pk})
+ )
+ assert res.status_code == 403
+
+ def test_detail_self(self):
+ res = self.client.get(
+ reverse("selfhosted-users-detail", kwargs={"pk": self.current_owner.pk})
+ )
+ assert res.status_code == 403
+
+ def test_current(self):
+ res = self.client.get(reverse("selfhosted-users-current"))
+ assert res.status_code == 200
+ assert res.json() == {
+ "ownerid": self.current_owner.pk,
+ "username": self.current_owner.username,
+ "email": self.current_owner.email,
+ "name": self.current_owner.name,
+ "is_admin": False,
+ "activated": False,
+ }
+
+ @patch("services.self_hosted.license_seats")
+ def test_current_update(self, license_seats):
+ license_seats.return_value = 5
+
+ org = OwnerFactory()
+ self.current_owner.organizations = [org.pk]
+ self.current_owner.save()
+
+ res = self.client.patch(
+ reverse("selfhosted-users-current"), data={"activated": True}, format="json"
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "ownerid": self.current_owner.pk,
+ "username": self.current_owner.username,
+ "email": self.current_owner.email,
+ "name": self.current_owner.name,
+ "is_admin": False,
+ "activated": True,
+ }
+ assert is_activated_owner(self.current_owner) == True
+
+
+class UserViewsetAdminTestCase(UserViewsetTestCase):
+ @patch("services.self_hosted.admin_owners")
+ def test_list_users(self, admin_owners):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+
+ OwnerFactory()
+ OwnerFactory(oauth_token=None, organizations=[self.owner.ownerid])
+ activated_owner = OwnerFactory(
+ oauth_token=None,
+ organizations=None,
+ )
+ self.owner.plan_activated_users = [activated_owner.pk]
+ self.owner.save()
+
+ res = self.client.get(reverse("selfhosted-users-list"))
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "ownerid": self.current_owner.pk,
+ "username": self.current_owner.username,
+ "email": self.current_owner.email,
+ "name": self.current_owner.name,
+ "is_admin": True,
+ "activated": False,
+ },
+ {
+ "ownerid": activated_owner.pk,
+ "username": activated_owner.username,
+ "email": activated_owner.email,
+ "name": activated_owner.name,
+ "is_admin": False,
+ "activated": True,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("services.self_hosted.admin_owners")
+ def test_list_users_filter_admin(self, admin_owners):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+
+ OwnerFactory()
+
+ res = self.client.get(reverse("selfhosted-users-list"), {"is_admin": True})
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "ownerid": self.current_owner.pk,
+ "username": self.current_owner.username,
+ "email": self.current_owner.email,
+ "name": self.current_owner.name,
+ "is_admin": True,
+ "activated": False,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("services.self_hosted.activated_owners")
+ @patch("services.self_hosted.admin_owners")
+ def test_list_users_filter_activated(self, admin_owners, activated_owners):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+
+ other_owner = OwnerFactory(organizations=[self.owner.ownerid])
+ activated_owners.return_value = Owner.objects.filter(pk__in=[other_owner.pk])
+
+ res = self.client.get(reverse("selfhosted-users-list"), {"activated": True})
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "ownerid": other_owner.pk,
+ "username": other_owner.username,
+ "email": other_owner.email,
+ "name": other_owner.name,
+ "is_admin": False,
+ "activated": True,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("services.self_hosted.admin_owners")
+ def test_list_users_search(self, admin_owners):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+
+ other_owner = OwnerFactory(
+ username="foobar", organizations=[self.owner.ownerid]
+ )
+
+ res = self.client.get(reverse("selfhosted-users-list"), {"search": "foo"})
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "ownerid": other_owner.pk,
+ "username": other_owner.username,
+ "email": other_owner.email,
+ "name": other_owner.name,
+ "is_admin": False,
+ "activated": False,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("services.self_hosted.admin_owners")
+ def test_detail(self, admin_owners):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+
+ other_owner = OwnerFactory(organizations=[self.owner.ownerid])
+
+ res = self.client.get(
+ reverse("selfhosted-users-detail", kwargs={"pk": other_owner.pk})
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "ownerid": other_owner.pk,
+ "username": other_owner.username,
+ "email": other_owner.email,
+ "name": other_owner.name,
+ "is_admin": False,
+ "activated": False,
+ }
+
+ @patch("services.self_hosted.license_seats")
+ @patch("services.self_hosted.admin_owners")
+ def test_update_activate(self, admin_owners, license_seats):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+ license_seats.return_value = 5
+
+ org = OwnerFactory()
+ other_owner = OwnerFactory(organizations=[org.pk])
+
+ res = self.client.patch(
+ reverse("selfhosted-users-detail", kwargs={"pk": other_owner.pk}),
+ data={"activated": True},
+ format="json",
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "ownerid": other_owner.pk,
+ "username": other_owner.username,
+ "email": other_owner.email,
+ "name": other_owner.name,
+ "is_admin": False,
+ "activated": True,
+ }
+ assert is_activated_owner(other_owner) == True
+
+ @patch("services.self_hosted.license_seats")
+ @patch("services.self_hosted.admin_owners")
+ def test_update_activate_no_more_seats(self, admin_owners, license_seats):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+ license_seats.return_value = 0
+
+ org = OwnerFactory()
+ other_owner = OwnerFactory(organizations=[org.pk])
+
+ res = self.client.patch(
+ reverse("selfhosted-users-detail", kwargs={"pk": other_owner.pk}),
+ data={"activated": True},
+ format="json",
+ )
+ assert res.status_code == 403
+ assert res.json() == {
+ "detail": "No seats remaining. Please contact Codecov support or deactivate users."
+ }
+ assert is_activated_owner(other_owner) == False
+
+ @patch("services.self_hosted.license_seats")
+ @patch("services.self_hosted.admin_owners")
+ def test_update_deactivate(self, admin_owners, license_seats):
+ admin_owners.return_value = Owner.objects.filter(pk__in=[self.current_owner.pk])
+ license_seats.return_value = 5
+
+ org = OwnerFactory()
+ other_owner = OwnerFactory(organizations=[org.pk])
+
+ activate_owner(other_owner)
+
+ res = self.client.patch(
+ reverse("selfhosted-users-detail", kwargs={"pk": other_owner.pk}),
+ data={"activated": False},
+ format="json",
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "ownerid": other_owner.pk,
+ "username": other_owner.username,
+ "email": other_owner.email,
+ "name": other_owner.name,
+ "is_admin": False,
+ "activated": False,
+ }
+ assert is_activated_owner(other_owner) == False
diff --git a/apps/codecov-api/api/internal/tests/views/test_slack_view.py b/apps/codecov-api/api/internal/tests/views/test_slack_view.py
new file mode 100644
index 0000000000..2339dd789f
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_slack_view.py
@@ -0,0 +1,99 @@
+from django.test import override_settings
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import UserToken
+
+codecov_internal_token = "test3n4d079myhiy9fu7d3j7gsepz80df3da"
+
+
+class SlackViewSetTests(APITestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+ self.data = {"username": self.owner.username, "service": self.owner.service}
+
+ def test_generate_access_token_missing_headers(self):
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ )
+ )
+
+ assert response.status_code == 401
+ assert response.data == {
+ "detail": "Authentication credentials were not provided."
+ }
+
+ def test_generate_access_token_with_invalid_token(self):
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ ),
+ data=self.data,
+ HTTP_AUTHORIZATION=f"Bearer {codecov_internal_token}",
+ )
+ assert response.status_code == 401
+ assert response.data == {"detail": "Invalid token."}
+
+ @override_settings(CODECOV_INTERNAL_TOKEN=codecov_internal_token)
+ def test_generate_access_token_with_invalid_owner(self):
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ ),
+ HTTP_AUTHORIZATION=f"Bearer {codecov_internal_token}",
+ data={
+ "username": "random-owner",
+ "service": self.owner.service,
+ },
+ )
+
+ assert response.status_code == 404
+ assert response.data == {"detail": "Owner not found"}
+
+ @override_settings(CODECOV_INTERNAL_TOKEN=codecov_internal_token)
+ def test_generate_access_token_with_invalid_service(self):
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ ),
+ HTTP_AUTHORIZATION=f"Bearer {codecov_internal_token}",
+ data={
+ "username": self.owner.username,
+ "service": "random-service",
+ },
+ )
+
+ assert response.status_code == 400
+
+ @override_settings(CODECOV_INTERNAL_TOKEN=codecov_internal_token)
+ def test_generate_access_token_already_exists(self):
+ UserToken.objects.create(
+ name="slack-codecov-access-token",
+ owner=self.owner,
+ token_type=UserToken.TokenType.API.value,
+ )
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ ),
+ HTTP_AUTHORIZATION=f"Bearer {codecov_internal_token}",
+ data=self.data,
+ )
+
+ assert response.status_code == 200
+ assert response.data["token"] == self.owner.user_tokens.first().token
+
+ @override_settings(CODECOV_INTERNAL_TOKEN=codecov_internal_token)
+ def test_generate_access_token_success(self):
+ response = self.client.post(
+ reverse(
+ "generate-token",
+ ),
+ data=self.data,
+ HTTP_AUTHORIZATION=f"Bearer {codecov_internal_token}",
+ )
+
+ assert response.status_code == 200
+ assert response.data["token"] == self.owner.user_tokens.first().token
diff --git a/apps/codecov-api/api/internal/tests/views/test_user_viewset.py b/apps/codecov-api/api/internal/tests/views/test_user_viewset.py
new file mode 100644
index 0000000000..32bee8b1eb
--- /dev/null
+++ b/apps/codecov-api/api/internal/tests/views/test_user_viewset.py
@@ -0,0 +1,561 @@
+from datetime import datetime
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.plan.constants import DEFAULT_FREE_PLAN, TierName
+
+from core.models import Pull
+from utils.test_utils import APIClient
+
+
+class UserViewSetTests(APITestCase):
+ def setUp(self):
+ non_org_active_user = OwnerFactory()
+ tier = TierFactory(tier_name=TierName.BASIC.value)
+ plan = PlanFactory(name=DEFAULT_FREE_PLAN, tier=tier)
+ self.current_owner = OwnerFactory(
+ plan=plan.name,
+ plan_user_count=5,
+ plan_activated_users=[non_org_active_user.ownerid],
+ )
+ self.users = [
+ non_org_active_user,
+ OwnerFactory(organizations=[self.current_owner.ownerid]),
+ OwnerFactory(organizations=[self.current_owner.ownerid]),
+ OwnerFactory(organizations=[self.current_owner.ownerid]),
+ ]
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _list(self, kwargs={}, query_params={}):
+ if not kwargs:
+ kwargs = {
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ }
+ return self.client.get(reverse("users-list", kwargs=kwargs), data=query_params)
+
+ def _patch(self, kwargs, data):
+ return self.client.patch(reverse("users-detail", kwargs=kwargs), data=data)
+
+ def test_list_returns_200_and_user_list_on_success(self):
+ response = self._list()
+ assert response.status_code == status.HTTP_200_OK
+ expected = [
+ {
+ "name": user.name,
+ "is_admin": False,
+ "activated": user.ownerid in self.current_owner.plan_activated_users,
+ "username": user.username,
+ "email": user.email,
+ "ownerid": user.ownerid,
+ "student": user.student,
+ "last_pull_timestamp": None,
+ }
+ for user in self.users
+ ]
+ self.assertCountEqual(response.data["results"], expected)
+
+ def test_list_sets_activated(self):
+ self.current_owner.plan_activated_users = [self.users[0].ownerid]
+ self.current_owner.save()
+
+ response = self._list()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["results"][0] == {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": self.users[0].username,
+ "email": self.users[0].email,
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ }
+
+ def test_list_sets_is_admin(self):
+ self.current_owner.admins = [self.users[1].ownerid]
+ self.current_owner.save()
+
+ response = self._list()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["results"][1] == {
+ "name": self.users[1].name,
+ "activated": False,
+ "is_admin": True,
+ "username": self.users[1].username,
+ "email": self.users[1].email,
+ "ownerid": self.users[1].ownerid,
+ "student": self.users[1].student,
+ "last_pull_timestamp": None,
+ }
+
+ def test_list_can_filter_by_activated(self):
+ self.current_owner.plan_activated_users = [self.users[0].ownerid]
+ self.current_owner.save()
+
+ response = self._list(query_params={"activated": True})
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["results"] == [
+ {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": self.users[0].username,
+ "email": self.users[0].email,
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ }
+ ]
+
+ def test_list_can_filter_by_is_admin(self):
+ self.current_owner.admins = [self.users[1].ownerid]
+ self.current_owner.save()
+
+ response = self._list(query_params={"is_admin": True})
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["results"] == [
+ {
+ "name": self.users[1].name,
+ "activated": False,
+ "is_admin": True,
+ "username": self.users[1].username,
+ "email": self.users[1].email,
+ "ownerid": self.users[1].ownerid,
+ "student": self.users[1].student,
+ "last_pull_timestamp": None,
+ }
+ ]
+
+ def test_list_can_search_by_username(self):
+ # search_fields = ["name", "username", "email"], cannot have any overlaps
+ self.users[0].name = "thor45" # non_org_active_user
+ self.users[0].username = "thor45"
+ self.users[0].email = "thor45@gmail.com"
+ self.users[0].save()
+ self.users[1].name = "thanos"
+ self.users[1].username = "thanos"
+ self.users[1].email = "huntrobert@gmail.com"
+ self.users[1].save()
+ self.users[2].name = "thor23"
+ self.users[2].username = "thor23"
+ self.users[2].email = "thor23@gmail.com"
+ self.users[2].save()
+ self.users[3].name = "thor"
+ self.users[3].username = "thor"
+ self.users[3].email = "thor@gmail.com"
+ self.users[3].save()
+
+ expected_response = [
+ {
+ "name": self.users[3].name,
+ "activated": False,
+ "is_admin": False,
+ "username": "thor",
+ "email": self.users[3].email,
+ "ownerid": self.users[3].ownerid,
+ "student": self.users[3].student,
+ "last_pull_timestamp": None,
+ },
+ {
+ "name": self.users[2].name,
+ "activated": False,
+ "is_admin": False,
+ "username": "thor23",
+ "email": self.users[2].email,
+ "ownerid": self.users[2].ownerid,
+ "student": self.users[2].student,
+ "last_pull_timestamp": None,
+ },
+ {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": "thor45",
+ "email": self.users[0].email,
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ },
+ ]
+
+ response = self._list(query_params={"search": "hor", "ordering": "name"})
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == len(expected_response)
+ assert response.data["results"] == expected_response
+
+ def test_list_can_search_by_name(self):
+ # search_fields = ["name", "username", "email"], cannot have any overlaps
+ self.users[0].name = "thanos" # non_org_active_user
+ self.users[0].username = "thanos"
+ self.users[0].email = "huntrobert@gmail.com"
+ self.users[0].save()
+ self.users[1].name = "thor23"
+ self.users[1].username = "thor23"
+ self.users[1].email = "thor23@gmail.com"
+ self.users[1].save()
+ self.users[2].name = "thor"
+ self.users[2].username = "thor"
+ self.users[2].email = "thor@gmail.com"
+ self.users[2].save()
+ self.users[3].name = "loki"
+ self.users[3].username = "loki"
+ self.users[3].email = "loki@gmail.com"
+ self.users[3].save()
+
+ expected_result = [
+ {
+ "name": "thor",
+ "activated": False,
+ "is_admin": False,
+ "username": self.users[2].username,
+ "email": self.users[2].email,
+ "ownerid": self.users[2].ownerid,
+ "student": self.users[2].student,
+ "last_pull_timestamp": None,
+ },
+ {
+ "name": "thor23",
+ "activated": False,
+ "is_admin": False,
+ "username": self.users[1].username,
+ "email": self.users[1].email,
+ "ownerid": self.users[1].ownerid,
+ "student": self.users[1].student,
+ "last_pull_timestamp": None,
+ },
+ ]
+
+ response = self._list(query_params={"search": "tho", "ordering": "name"})
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == len(expected_result)
+ assert response.data["results"] == expected_result
+
+ def test_list_can_search_by_email(self):
+ # search_fields = ["name", "username", "email"], cannot have any overlaps
+ self.users[0].name = "thanos" # non_org_active_user
+ self.users[0].username = "thanos"
+ self.users[0].email = "thanos@gmail.com"
+ self.users[0].save()
+ self.users[1].name = "ironman"
+ self.users[1].username = "ironman"
+ self.users[1].email = "ironman@gmail.com"
+ self.users[1].save()
+ self.users[2].name = "thor"
+ self.users[2].username = "thor"
+ self.users[2].email = "thor@gmail.com"
+ self.users[2].save()
+ self.users[3].name = "loki"
+ self.users[3].username = "loki"
+ self.users[3].email = "loki@gmail.com"
+ self.users[3].save()
+
+ expected_response = [
+ {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": self.users[0].username,
+ "email": "thanos@gmail.com",
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ },
+ {
+ "name": self.users[2].name,
+ "activated": False,
+ "is_admin": False,
+ "username": self.users[2].username,
+ "email": "thor@gmail.com",
+ "ownerid": self.users[2].ownerid,
+ "student": self.users[2].student,
+ "last_pull_timestamp": None,
+ },
+ ]
+
+ response = self._list(query_params={"search": "th", "ordering": "name"})
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == len(expected_response)
+ assert response.data["results"] == expected_response
+
+ def test_list_can_order_by_name(self):
+ self.users[0].name = "a"
+ self.users[0].save()
+ self.users[1].name = "b"
+ self.users[1].save()
+ self.users[2].name = "c"
+ self.users[2].save()
+ self.users[3].name = "d"
+ self.users[3].save()
+
+ response = self._list(query_params={"ordering": "name"})
+
+ assert [r["name"] for r in response.data["results"]] == ["a", "b", "c", "d"]
+
+ response = self._list(query_params={"ordering": "-name"})
+
+ assert [r["name"] for r in response.data["results"]] == ["d", "c", "b", "a"]
+
+ def test_list_can_order_by_username(self):
+ self.users[0].username = "a"
+ self.users[0].save()
+ self.users[1].username = "b"
+ self.users[1].save()
+ self.users[2].username = "c"
+ self.users[2].save()
+ self.users[3].username = "d"
+ self.users[3].save()
+
+ response = self._list(query_params={"ordering": "username"})
+
+ assert [r["username"] for r in response.data["results"]] == ["a", "b", "c", "d"]
+
+ response = self._list(query_params={"ordering": "-username"})
+
+ assert [r["username"] for r in response.data["results"]] == ["d", "c", "b", "a"]
+
+ def test_list_can_order_by_email(self):
+ self.users[0].email = "a"
+ self.users[0].save()
+ self.users[1].email = "b"
+ self.users[1].save()
+ self.users[2].email = "c"
+ self.users[2].save()
+ self.users[3].email = "d"
+ self.users[3].save()
+
+ response = self._list(query_params={"ordering": "email"})
+
+ assert [r["email"] for r in response.data["results"]] == ["a", "b", "c", "d"]
+
+ response = self._list(query_params={"ordering": "-email"})
+
+ assert [r["email"] for r in response.data["results"]] == ["d", "c", "b", "a"]
+
+ def test_list_can_order_by_activated(self):
+ self.users[0].activated = False
+ self.users[0].save()
+ self.users[1].activated = True
+ self.users[1].save()
+ self.users[2].activated = False
+ self.users[2].save()
+ self.users[3].activated = False
+ self.users[3].save()
+
+ response = self._list(query_params={"ordering": "activated"})
+
+ assert [r["activated"] for r in response.data["results"]] == [
+ False,
+ False,
+ False,
+ True,
+ ]
+
+ response = self._list(query_params={"ordering": "-activated"})
+
+ assert [r["activated"] for r in response.data["results"]] == [
+ True,
+ False,
+ False,
+ False,
+ ]
+
+ @patch("api.internal.owner.views.on_enterprise_plan")
+ def test_list_can_order_by_last_pull_timestamp(self, on_enterprise_plan):
+ on_enterprise_plan.return_value = True
+
+ repo = RepositoryFactory()
+ pull1 = PullFactory(author=self.users[1], repository=repo)
+ pull2 = PullFactory(author=self.users[2], repository=repo)
+
+ # avoid `save` which sets `updatestamp`
+ Pull.objects.filter(pk=pull1.pk).update(
+ updatestamp=datetime(2022, 1, 1, 0, 0, 0)
+ )
+ Pull.objects.filter(pk=pull2.pk).update(
+ updatestamp=datetime(2022, 2, 1, 0, 0, 0)
+ )
+
+ response = self._list(query_params={"ordering": "last_pull_timestamp"})
+ assert [r["ownerid"] for r in response.data["results"]] == [
+ self.users[0].ownerid,
+ self.users[3].ownerid,
+ self.users[1].ownerid,
+ self.users[2].ownerid,
+ ]
+ assert [r["last_pull_timestamp"] for r in response.data["results"]] == [
+ None,
+ None,
+ datetime(2022, 1, 1, 0, 0, 0),
+ datetime(2022, 2, 1, 0, 0, 0),
+ ]
+
+ response = self._list(query_params={"ordering": "-last_pull_timestamp"})
+ assert [r["ownerid"] for r in response.data["results"]] == [
+ self.users[2].ownerid,
+ self.users[1].ownerid,
+ self.users[0].ownerid,
+ self.users[3].ownerid,
+ ]
+ assert [r["last_pull_timestamp"] for r in response.data["results"]] == [
+ datetime(2022, 2, 1, 0, 0, 0),
+ datetime(2022, 1, 1, 0, 0, 0),
+ None,
+ None,
+ ]
+
+ def test_patch_with_ownerid(self):
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[0].ownerid,
+ },
+ data={"activated": True},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": self.users[0].username,
+ "email": self.users[0].email,
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ }
+
+ def test_patch_can_set_activated_to_true(self):
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[0].username,
+ },
+ data={"activated": True},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "name": self.users[0].name,
+ "activated": True,
+ "is_admin": False,
+ "username": self.users[0].username,
+ "email": self.users[0].email,
+ "ownerid": self.users[0].ownerid,
+ "student": self.users[0].student,
+ "last_pull_timestamp": None,
+ }
+
+ self.current_owner.refresh_from_db()
+ assert self.users[0].ownerid in self.current_owner.plan_activated_users
+
+ def test_patch_can_set_activated_to_false(self):
+ # setup activated user
+ self.current_owner.plan_activated_users = [self.users[1].ownerid]
+ self.current_owner.save()
+
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[1].username,
+ },
+ data={"activated": False},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "name": self.users[1].name,
+ "activated": False,
+ "is_admin": False,
+ "username": self.users[1].username,
+ "email": self.users[1].email,
+ "ownerid": self.users[1].ownerid,
+ "student": self.users[1].student,
+ "last_pull_timestamp": None,
+ }
+
+ self.current_owner.refresh_from_db()
+ assert self.users[1].ownerid not in self.current_owner.plan_activated_users
+
+ @patch("codecov_auth.models.Owner.can_activate_user", lambda self, user: False)
+ def test_patch_returns_403_if_cannot_activate_user(self):
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[0].username,
+ },
+ data={"activated": True},
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_patch_can_set_is_admin_to_true(self):
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[2].username,
+ },
+ data={"is_admin": True},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "name": self.users[2].name,
+ "activated": False,
+ "is_admin": True,
+ "username": self.users[2].username,
+ "email": self.users[2].email,
+ "ownerid": self.users[2].ownerid,
+ "student": self.users[2].student,
+ "last_pull_timestamp": None,
+ }
+
+ self.current_owner.refresh_from_db()
+ assert self.users[2].ownerid in self.current_owner.admins
+
+ def test_patch_can_set_is_admin_to_false(self):
+ self.current_owner.admins = [self.users[2].ownerid]
+ self.current_owner.save()
+
+ response = self._patch(
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "user_username_or_ownerid": self.users[2].username,
+ },
+ data={"is_admin": False},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "name": self.users[2].name,
+ "activated": False,
+ "is_admin": False,
+ "username": self.users[2].username,
+ "email": self.users[2].email,
+ "ownerid": self.users[2].ownerid,
+ "student": self.users[2].student,
+ "last_pull_timestamp": None,
+ }
+
+ self.current_owner.refresh_from_db()
+ assert self.users[2].ownerid not in self.current_owner.admins
diff --git a/apps/codecov-api/api/internal/urls.py b/apps/codecov-api/api/internal/urls.py
new file mode 100644
index 0000000000..312eb44523
--- /dev/null
+++ b/apps/codecov-api/api/internal/urls.py
@@ -0,0 +1,79 @@
+from django.conf import settings, urls
+from django.urls import include, path
+from rest_framework.exceptions import server_error
+
+from api.internal.branch.views import BranchViewSet
+from api.internal.commit.views import CommitsViewSet
+from api.internal.compare.views import CompareViewSet
+from api.internal.coverage.views import CoverageViewSet
+from api.internal.enterprise_urls import urlpatterns as enterprise_urlpatterns
+from api.internal.feature.views import FeaturesView
+from api.internal.owner.views import (
+ AccountDetailsViewSet,
+ OwnerViewSet,
+ UserViewSet,
+)
+from api.internal.pull.views import PullViewSet
+from api.internal.repo.views import RepositoryViewSet
+from api.internal.user.views import CurrentUserView
+from api.shared.error_views import not_found
+from utils.routers import OptionalTrailingSlashRouter, RetrieveUpdateDestroyRouter
+
+urls.handler404 = not_found
+urls.handler500 = server_error
+
+
+owners_router = OptionalTrailingSlashRouter()
+owners_router.register(r"owners", OwnerViewSet, basename="owners")
+
+owner_artifacts_router = OptionalTrailingSlashRouter()
+owner_artifacts_router.register(r"users", UserViewSet, basename="users")
+
+account_details_router = RetrieveUpdateDestroyRouter()
+account_details_router.register(
+ r"account-details", AccountDetailsViewSet, basename="account_details"
+)
+
+repository_router = OptionalTrailingSlashRouter()
+repository_router.register(r"repos", RepositoryViewSet, basename="repos")
+
+repository_artifacts_router = OptionalTrailingSlashRouter()
+repository_artifacts_router.register(r"pulls", PullViewSet, basename="pulls")
+repository_artifacts_router.register(r"commits", CommitsViewSet, basename="commits")
+repository_artifacts_router.register(r"branches", BranchViewSet, basename="branches")
+repository_artifacts_router.register(r"coverage", CoverageViewSet, basename="coverage")
+
+compare_router = RetrieveUpdateDestroyRouter()
+compare_router.register(r"compare", CompareViewSet, basename="compare")
+
+urlpatterns = []
+
+if settings.IS_ENTERPRISE:
+ urlpatterns += enterprise_urlpatterns
+
+urlpatterns += [
+ path("user", CurrentUserView.as_view(), name="current-user"),
+ path("slack/", include("api.internal.slack.urls")),
+ path("charts/", include("api.internal.chart.urls")),
+ path("/", include(owners_router.urls)),
+ path("//", include(owner_artifacts_router.urls)),
+ path("//", include(account_details_router.urls)),
+ path("//", include(repository_router.urls)),
+ path(
+ "///",
+ include(repository_artifacts_router.urls),
+ ),
+ path(
+ "//repos//",
+ include(repository_artifacts_router.urls),
+ ),
+ path(
+ "///",
+ include(compare_router.urls),
+ ),
+ path(
+ "//repos//",
+ include(compare_router.urls),
+ ),
+ path("features", FeaturesView.as_view(), name="features"),
+]
diff --git a/apps/codecov-api/api/internal/user/serializers.py b/apps/codecov-api/api/internal/user/serializers.py
new file mode 100644
index 0000000000..873494980d
--- /dev/null
+++ b/apps/codecov-api/api/internal/user/serializers.py
@@ -0,0 +1,21 @@
+from rest_framework import serializers
+
+from api.internal.owner.serializers import OwnerSerializer
+from codecov_auth.models import User
+
+
+class UserSerializer(serializers.ModelSerializer):
+ owners = OwnerSerializer(many=True)
+
+ class Meta:
+ model = User
+ fields = (
+ "email",
+ "name",
+ "external_id",
+ "owners",
+ "terms_agreement",
+ "terms_agreement_at",
+ )
+
+ read_only_fields = fields
diff --git a/apps/codecov-api/api/internal/user/views.py b/apps/codecov-api/api/internal/user/views.py
new file mode 100644
index 0000000000..5104db9f2d
--- /dev/null
+++ b/apps/codecov-api/api/internal/user/views.py
@@ -0,0 +1,13 @@
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from .serializers import UserSerializer
+
+
+class CurrentUserView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ serializer = UserSerializer(request.user)
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/public/__init__.py b/apps/codecov-api/api/public/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v1/__init__.py b/apps/codecov-api/api/public/v1/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v1/permissions.py b/apps/codecov-api/api/public/v1/permissions.py
new file mode 100644
index 0000000000..0293691238
--- /dev/null
+++ b/apps/codecov-api/api/public/v1/permissions.py
@@ -0,0 +1,10 @@
+from rest_framework.permissions import BasePermission
+
+
+class PullUpdatePermission(BasePermission):
+ def has_permission(self, request, view):
+ return (
+ request.auth
+ and "upload" in request.auth.get_scopes()
+ and view.repo in request.auth.get_repositories()
+ )
diff --git a/apps/codecov-api/api/public/v1/serializers.py b/apps/codecov-api/api/public/v1/serializers.py
new file mode 100644
index 0000000000..2f783694c4
--- /dev/null
+++ b/apps/codecov-api/api/public/v1/serializers.py
@@ -0,0 +1,22 @@
+from rest_framework import serializers
+
+from core.models import Pull
+
+
+class PullSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Pull
+ read_only_fields = (
+ "pullid",
+ "title",
+ "base",
+ "head",
+ "compared_to",
+ "updatestamp",
+ "state",
+ )
+ fields = read_only_fields + ("user_provided_base_sha",)
+
+
+class PullIdSerializer(serializers.Serializer):
+ pullid = serializers.IntegerField()
diff --git a/apps/codecov-api/api/public/v1/tests/views/test_pull_viewset.py b/apps/codecov-api/api/public/v1/tests/views/test_pull_viewset.py
new file mode 100644
index 0000000000..f83e2b4386
--- /dev/null
+++ b/apps/codecov-api/api/public/v1/tests/views/test_pull_viewset.py
@@ -0,0 +1,142 @@
+import json
+from unittest.mock import patch
+
+from rest_framework.test import APIClient, APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from core.models import Pull
+
+
+class PullViewSetTests(APITestCase):
+ def setUp(self):
+ self.client = APIClient()
+ self.org = OwnerFactory(username="codecov", service="github")
+ other_org = OwnerFactory(username="other_org")
+ # Create different types of repos / pulls
+ self.repo = RepositoryFactory(author=self.org, name="testRepoName", active=True)
+ self.other_repo = RepositoryFactory(
+ author=other_org, name="otherRepoName", active=True
+ )
+ repo_with_permission = [self.repo.repoid]
+ self.user = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=repo_with_permission,
+ )
+ self.open_pull = PullFactory(
+ pullid=10,
+ author=self.org,
+ repository=self.repo,
+ state="open",
+ head=CommitFactory(repository=self.repo, author=self.user).commitid,
+ base=CommitFactory(repository=self.repo, author=self.user).commitid,
+ )
+ PullFactory(pullid=11, author=self.org, repository=self.repo, state="closed")
+ PullFactory(pullid=12, author=other_org, repository=self.other_repo)
+ self.correct_kwargs = {
+ "service": "github",
+ "owner_username": "codecov",
+ "repo_name": "testRepoName",
+ }
+
+ def test_get_pulls(self):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/")
+ self.assertEqual(response.status_code, 200)
+ content = json.loads(response.content.decode())
+ self.assertEqual(
+ len(content["results"]),
+ 3,
+ "got the wrong number of pulls: {}".format(content["results"]),
+ )
+
+ def test_get_pulls_wrong_repo_token(self):
+ self.client.credentials(
+ HTTP_AUTHORIZATION="Token " + self.other_repo.upload_token
+ )
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/")
+ self.assertEqual(response.status_code, 403)
+
+ def test_get_pulls_no_permissions(self):
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/")
+ self.assertEqual(response.status_code, 401)
+
+ def test_get_pull(self):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/10/")
+ self.assertEqual(response.status_code, 200)
+ content = json.loads(response.content.decode())
+ self.assertEqual(content["pullid"], 10)
+
+ def test_get_pull_no_permissions(self):
+ self.client.credentials(
+ HTTP_AUTHORIZATION="Token " + self.other_repo.upload_token
+ )
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/10/")
+ self.assertEqual(response.status_code, 403)
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_update_pull_user_provided_base(self, pulls_sync_mock):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ response = self.client.put(
+ "/api/github/codecov/testRepoName/pulls/10/",
+ {"user_provided_base_sha": "new-sha"},
+ )
+ self.assertEqual(response.status_code, 200)
+ content = json.loads(response.content.decode())
+ self.assertEqual(content["user_provided_base_sha"], "new-sha")
+ self.assertEqual(
+ Pull.objects.get(pullid=10, repository=self.repo).user_provided_base_sha,
+ "new-sha",
+ )
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid="10")
+
+ def test_update_pull_user_provided_base_no_permissions(self):
+ self.client.credentials(
+ HTTP_AUTHORIZATION="Token " + self.other_repo.upload_token
+ )
+ response = self.client.put(
+ "/api/github/codecov/testRepoName/pulls/10/",
+ {"user_provided_base_sha": "new-sha"},
+ )
+ self.assertEqual(response.status_code, 403)
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_create_new_pull_user_provided_base(self, pulls_sync_mock):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ self.client.force_login(user=self.user)
+ response = self.client.put(
+ "/api/github/codecov/testRepoName/pulls/15/",
+ {"user_provided_base_sha": "new-sha"},
+ )
+ self.assertEqual(response.status_code, 200)
+ content = json.loads(response.content.decode())
+ self.assertEqual(content["user_provided_base_sha"], "new-sha")
+ self.assertEqual(
+ Pull.objects.get(pullid=15, repository=self.repo).user_provided_base_sha,
+ "new-sha",
+ )
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid="15")
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_post_pull_user_provided_base(self, pulls_sync_mock):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ response = self.client.post(
+ "/api/github/codecov/testRepoName/pulls/15/",
+ {"user_provided_base_sha": "new-sha"},
+ )
+ self.assertEqual(response.status_code, 405)
+ assert not pulls_sync_mock.called
+
+ def test_get_pull_no_pullid_provided(self):
+ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.repo.upload_token)
+ response = self.client.get("/api/github/codecov/testRepoName/pulls/abc")
+ self.assertEqual(response.status_code, 400)
+ content = json.loads(response.content.decode())
+ self.assertEqual(content["pullid"], ["A valid integer is required."])
diff --git a/apps/codecov-api/api/public/v1/urls.py b/apps/codecov-api/api/public/v1/urls.py
new file mode 100644
index 0000000000..0542d5379e
--- /dev/null
+++ b/apps/codecov-api/api/public/v1/urls.py
@@ -0,0 +1,15 @@
+from django.urls import include, path
+
+from api.public.v1.views import PullViewSet
+from utils.routers import OptionalTrailingSlashRouter
+
+repository_router = OptionalTrailingSlashRouter()
+repository_router.register(r"pulls", PullViewSet, basename="pulls")
+
+
+urlpatterns = [
+ path(
+ "///",
+ include(repository_router.urls),
+ )
+]
diff --git a/apps/codecov-api/api/public/v1/views.py b/apps/codecov-api/api/public/v1/views.py
new file mode 100644
index 0000000000..b2e8546812
--- /dev/null
+++ b/apps/codecov-api/api/public/v1/views.py
@@ -0,0 +1,59 @@
+import logging
+
+from django.db.models import OuterRef, QuerySet, Subquery
+from django.shortcuts import get_object_or_404
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework import filters, mixins, viewsets
+
+from api.shared.mixins import RepoPropertyMixin
+from codecov_auth.authentication.repo_auth import RepositoryLegacyTokenAuthentication
+from core.models import Commit, Pull
+from services.task import TaskService
+
+from .permissions import PullUpdatePermission
+from .serializers import PullIdSerializer, PullSerializer
+
+log = logging.getLogger(__name__)
+
+
+class PullViewSet(
+ RepoPropertyMixin,
+ viewsets.GenericViewSet,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+):
+ serializer_class = PullSerializer
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filterset_fields = ["state"]
+ ordering_fields = ("pullid",)
+ authentication_classes = [RepositoryLegacyTokenAuthentication]
+ permission_classes = [PullUpdatePermission]
+
+ def get_object(self) -> Pull:
+ serializer = PullIdSerializer(data={"pullid": self.kwargs.get("pk")})
+ serializer.is_valid(raise_exception=True)
+ pullid = serializer.validated_data["pullid"]
+
+ if self.request.method == "PUT":
+ # Note: We create a new pull if needed to make sure that they can be updated
+ # with a base before the upload has finished processing.
+ obj, _created = self.get_queryset().get_or_create(
+ repository=self.repo, pullid=pullid
+ )
+ return obj
+ return get_object_or_404(self.get_queryset(), pullid=pullid)
+
+ def get_queryset(self) -> QuerySet:
+ return self.repo.pull_requests.annotate(
+ ci_passed=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"), repository=OuterRef("repository")
+ ).values("ci_passed")[:1]
+ )
+ )
+
+ def perform_update(self, serializer: PullSerializer) -> Pull:
+ result = super().perform_update(serializer)
+ TaskService().pulls_sync(repoid=self.repo.repoid, pullid=self.kwargs.get("pk"))
+ return result
diff --git a/apps/codecov-api/api/public/v2/__init__.py b/apps/codecov-api/api/public/v2/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/branch/__init__.py b/apps/codecov-api/api/public/v2/branch/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/branch/serializers.py b/apps/codecov-api/api/public/v2/branch/serializers.py
new file mode 100644
index 0000000000..289634f6bb
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/branch/serializers.py
@@ -0,0 +1,33 @@
+from rest_framework import serializers
+
+from api.public.v2.commit.serializers import CommitDetailSerializer
+from core.models import Branch, Commit
+
+
+class BranchSerializer(serializers.ModelSerializer):
+ name = serializers.CharField(label="branch name")
+ updatestamp = serializers.DateTimeField(label="last updated timestamp")
+
+ class Meta:
+ model = Branch
+ fields = ("name", "updatestamp")
+
+
+class BranchDetailSerializer(BranchSerializer):
+ head_commit = serializers.SerializerMethodField(
+ label="branch's current head commit"
+ )
+
+ def get_head_commit(self, branch: Branch) -> CommitDetailSerializer:
+ commit = (
+ Commit.objects.filter(
+ repository_id=branch.repository_id, commitid=branch.head
+ )
+ .defer("_report")
+ .first()
+ )
+ return CommitDetailSerializer(commit).data
+
+ class Meta:
+ model = Branch
+ fields = BranchSerializer.Meta.fields + ("head_commit",)
diff --git a/apps/codecov-api/api/public/v2/branch/views.py b/apps/codecov-api/api/public/v2/branch/views.py
new file mode 100644
index 0000000000..6d2d7119c6
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/branch/views.py
@@ -0,0 +1,50 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins
+
+from api.public.v2.schema import repo_parameters
+from api.shared.branch.mixins import BranchViewSetMixin
+from core.models import Branch
+
+from .serializers import BranchDetailSerializer, BranchSerializer
+
+
+@extend_schema(parameters=repo_parameters, tags=["Branches"])
+class BranchViewSet(
+ BranchViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+):
+ queryset = Branch.objects.none()
+ lookup_value_regex = "[^/]+"
+
+ def get_serializer_class(self):
+ if self.action == "retrieve":
+ return BranchDetailSerializer
+ elif self.action == "list":
+ return BranchSerializer
+
+ @extend_schema(summary="Branch list")
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of branches for the specified repository
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Branch detail",
+ parameters=[
+ OpenApiParameter(
+ "name",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="branch name",
+ ),
+ ],
+ )
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a single branch by name.
+ Includes head commit information embedded in the response.
+ """
+ return super().retrieve(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/commit/__init__.py b/apps/codecov-api/api/public/v2/commit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/commit/serializers.py b/apps/codecov-api/api/public/v2/commit/serializers.py
new file mode 100644
index 0000000000..6e8903a413
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/commit/serializers.py
@@ -0,0 +1,91 @@
+from rest_framework import serializers
+
+from api.public.v2.owner.serializers import OwnerSerializer
+from api.shared.commit.serializers import (
+ CommitTotalsSerializer,
+ ReportSerializer,
+ UploadTotalsSerializer,
+)
+from api.shared.serializers import StringListField
+from core.models import Commit
+from reports.models import ReportSession
+
+
+class CommitSerializer(serializers.ModelSerializer):
+ commitid = serializers.CharField(label="commit SHA")
+ message = serializers.CharField(label="commit message")
+ timestamp = serializers.DateTimeField(label="timestamp when commit was made")
+ ci_passed = serializers.BooleanField(
+ label="indicates whether the CI process passed for this commit"
+ )
+ author = OwnerSerializer(label="author of the commit")
+ branch = serializers.CharField(
+ label="branch name on which this commit currently lives"
+ )
+ totals = CommitTotalsSerializer(label="coverage totals")
+ state = serializers.ChoiceField(
+ label="Codecov processing state for this commit",
+ choices=Commit.CommitStates.choices,
+ )
+ parent = serializers.CharField(
+ label="commit SHA of first ancestor commit with coverage",
+ source="parent_commit_id",
+ )
+
+ class Meta:
+ model = Commit
+ fields = (
+ "commitid",
+ "message",
+ "timestamp",
+ "ci_passed",
+ "author",
+ "branch",
+ "totals",
+ "state",
+ "parent",
+ )
+
+
+class CommitDetailSerializer(CommitSerializer):
+ report = ReportSerializer(source="full_report", label="coverage report")
+
+ class Meta:
+ model = Commit
+ fields = CommitSerializer.Meta.fields + ("report",)
+
+
+class CommitUploadsSerializer(serializers.ModelSerializer):
+ created_at = serializers.CharField()
+ updated_at = serializers.CharField()
+ storage_path = serializers.CharField()
+ flags = StringListField(source="flag_names")
+ provider = serializers.CharField()
+ build_code = serializers.CharField()
+ name = serializers.CharField()
+ job_code = serializers.CharField()
+ build_url = serializers.CharField()
+ state = serializers.CharField()
+ env = serializers.JSONField()
+ upload_type = serializers.CharField()
+ upload_extras = serializers.JSONField()
+ totals = UploadTotalsSerializer(source="uploadleveltotals")
+
+ class Meta:
+ model = ReportSession
+ fields = (
+ "created_at",
+ "updated_at",
+ "storage_path",
+ "flags",
+ "provider",
+ "build_code",
+ "name",
+ "job_code",
+ "build_url",
+ "state",
+ "env",
+ "upload_type",
+ "upload_extras",
+ "totals",
+ )
diff --git a/apps/codecov-api/api/public/v2/commit/views.py b/apps/codecov-api/api/public/v2/commit/views.py
new file mode 100644
index 0000000000..978eb83269
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/commit/views.py
@@ -0,0 +1,85 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins, viewsets
+
+from api.public.v2.schema import repo_parameters
+from api.shared.commit.mixins import CommitsViewSetMixin
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from core.models import Commit
+from reports.models import ReportSession
+
+from .serializers import (
+ CommitDetailSerializer,
+ CommitSerializer,
+ CommitUploadsSerializer,
+)
+
+commits_parameters = [
+ OpenApiParameter(
+ "commitid",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="commit SHA",
+ ),
+]
+
+
+@extend_schema(parameters=repo_parameters, tags=["Commits"])
+class CommitsViewSet(
+ CommitsViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+):
+ queryset = Commit.objects.none()
+
+ def get_serializer_context(self, *args, **kwargs):
+ context = super().get_serializer_context(*args, **kwargs)
+ context.update({"include_line_coverage": True})
+ return context
+
+ def get_serializer_class(self):
+ if self.action == "retrieve":
+ return CommitDetailSerializer
+ elif self.action == "list":
+ return CommitSerializer
+
+ @extend_schema(summary="Commit list")
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of commits for the specified repository
+
+ Optionally filterable by:
+ * a `branch` name
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(summary="Commit detail", parameters=commits_parameters)
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a single commit by commitid (SHA)
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+
+@extend_schema(parameters=repo_parameters, tags=["Commits"])
+class CommitsUploadsViewSet(
+ viewsets.GenericViewSet,
+ RepoPropertyMixin,
+ mixins.ListModelMixin,
+):
+ permission_classes = [RepositoryArtifactPermissions]
+ serializer_class = CommitUploadsSerializer
+
+ def get_queryset(self):
+ commit = self.get_commit(self.kwargs["commitid"])
+ return ReportSession.objects.filter(report__commit=commit.id).select_related(
+ "uploadleveltotals"
+ )
+
+ @extend_schema(summary="Commit uploads", parameters=commits_parameters)
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of uploads for a single commit by commitid (SHA)
+ """
+ return super().list(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/compare/__init__.py b/apps/codecov-api/api/public/v2/compare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/compare/serializers.py b/apps/codecov-api/api/public/v2/compare/serializers.py
new file mode 100644
index 0000000000..e85843fe66
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/compare/serializers.py
@@ -0,0 +1,34 @@
+from typing import List
+
+from rest_framework import serializers
+
+from api.public.v2.commit.serializers import CommitSerializer
+from api.shared.commit.serializers import ReportTotalsSerializer
+from api.shared.compare.serializers import (
+ ComparisonSerializer as BaseComparisonSerializer,
+)
+from api.shared.compare.serializers import FileComparisonSerializer
+from services.comparison import Comparison
+
+
+class ComparisonSerializer(BaseComparisonSerializer):
+ commit_uploads = CommitSerializer(many=True, source="upload_commits")
+
+ def get_files(self, comparison: Comparison) -> List[dict]:
+ data = []
+ if comparison.head_report is not None:
+ for filename in comparison.head_report.files:
+ file = comparison.get_file_comparison(filename, bypass_max_diff=True)
+ if self._should_include_file(file):
+ data.append(FileComparisonSerializer(file).data)
+ return data
+
+
+class ComponentComparisonSerializer(serializers.Serializer):
+ component_id = serializers.CharField(source="component.component_id")
+ name = serializers.CharField(source="component.name")
+
+ # field names here are meant to match `FlagComparisonSerializer`
+ base_report_totals = ReportTotalsSerializer(source="base_totals")
+ head_report_totals = ReportTotalsSerializer(source="head_totals")
+ diff_totals = ReportTotalsSerializer(source="patch_totals")
diff --git a/apps/codecov-api/api/public/v2/compare/views.py b/apps/codecov-api/api/public/v2/compare/views.py
new file mode 100644
index 0000000000..d2805d7b63
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/compare/views.py
@@ -0,0 +1,169 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins
+from rest_framework.decorators import action
+from rest_framework.response import Response
+
+from api.public.v2.schema import repo_parameters
+from api.shared.compare.mixins import CompareViewSetMixin
+from api.shared.compare.serializers import (
+ FileComparisonSerializer,
+ FlagComparisonSerializer,
+ ImpactedFilesComparisonSerializer,
+ ImpactedFileSegmentsSerializer,
+)
+from services.components import ComponentComparison, commit_components
+from services.decorators import torngit_safe
+from utils import strtobool
+
+from .serializers import ComparisonSerializer, ComponentComparisonSerializer
+
+comparison_parameters = [
+ OpenApiParameter(
+ "pullid",
+ OpenApiTypes.INT,
+ OpenApiParameter.QUERY,
+ description="pull ID on which to perform the comparison (alternative to specifying `base` and `head`)",
+ ),
+ OpenApiParameter(
+ "base",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="base commit SHA (`head` also required)",
+ ),
+ OpenApiParameter(
+ "head",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="head commit SHA (`base` also required)",
+ ),
+]
+
+
+@extend_schema(parameters=repo_parameters, tags=["Comparison"])
+class CompareViewSet(
+ CompareViewSetMixin,
+ mixins.RetrieveModelMixin,
+):
+ serializer_class = ComparisonSerializer
+
+ def get_queryset(self):
+ return None
+
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ if "has_diff" in self.request.query_params:
+ context.update(
+ {"has_diff": strtobool(self.request.query_params["has_diff"])}
+ )
+ return context
+
+ @extend_schema(
+ summary="Comparison",
+ parameters=comparison_parameters,
+ )
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a comparison for either a pair of commits or a pull
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="File comparison",
+ parameters=comparison_parameters
+ + [
+ OpenApiParameter(
+ "file_path",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="file path",
+ ),
+ ],
+ responses={200: FileComparisonSerializer},
+ )
+ @action(
+ detail=False,
+ methods=["get"],
+ url_path="file/(?P.+)",
+ url_name="file",
+ )
+ def file(self, request, *args, **kwargs):
+ """
+ Returns a comparison for a specific file path
+ """
+ return super().file(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Flag comparison",
+ parameters=comparison_parameters,
+ responses={200: FlagComparisonSerializer},
+ )
+ @action(detail=False, methods=["get"])
+ def flags(self, request, *args, **kwargs):
+ """
+ Returns flag comparisons
+ """
+ return super().flags(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Component comparison",
+ parameters=comparison_parameters,
+ responses={200: ComponentComparisonSerializer},
+ )
+ @action(detail=False, methods=["get"])
+ @torngit_safe
+ def components(self, request, *args, **kwargs):
+ """
+ Returns component comparisons
+ """
+ comparison = self.get_object()
+ components = commit_components(comparison.head_commit, self.owner)
+ component_comparisons = [
+ ComponentComparison(comparison, component) for component in components
+ ]
+
+ serializer = ComponentComparisonSerializer(component_comparisons, many=True)
+ return Response(serializer.data)
+
+ @extend_schema(
+ summary="Impacted files comparison",
+ parameters=comparison_parameters,
+ responses={200: ImpactedFilesComparisonSerializer},
+ )
+ @action(detail=False, methods=["get"])
+ def impacted_files(self, request, *args, **kwargs):
+ """
+ Returns a comparison for either a pair of commits or a pull
+ Will only return pre-computed impacted files comparisons if available
+ If unavailable `files` will be empty, however once the computation is ready
+ the files will appear on subsequent calls
+ `state: "processed"` means `files` are finished computing and returned
+ `state: "pending"` means `files` are still computing, poll again later
+ """
+ return super().impacted_files(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Segmented file comparison",
+ parameters=comparison_parameters
+ + [
+ OpenApiParameter(
+ "file_path",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="file path",
+ ),
+ ],
+ responses={200: ImpactedFileSegmentsSerializer},
+ )
+ @action(
+ detail=False,
+ methods=["get"],
+ url_path="segments/(?P.+)",
+ url_name="segments",
+ )
+ def segments(self, request, *args, **kwargs):
+ """
+ Returns a comparison for a specific file path only showing the segments
+ of the file that are impacted instead of all lines in file
+ """
+ return super().segments(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/component/__init__.py b/apps/codecov-api/api/public/v2/component/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/component/serializers.py b/apps/codecov-api/api/public/v2/component/serializers.py
new file mode 100644
index 0000000000..ce182cee5a
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/component/serializers.py
@@ -0,0 +1,7 @@
+from rest_framework import serializers
+
+
+class ComponentSerializer(serializers.Serializer):
+ component_id = serializers.CharField(label="component id")
+ name = serializers.CharField(label="component name")
+ coverage = serializers.FloatField(label="component coverage")
diff --git a/apps/codecov-api/api/public/v2/component/views.py b/apps/codecov-api/api/public/v2/component/views.py
new file mode 100644
index 0000000000..4e50de67da
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/component/views.py
@@ -0,0 +1,64 @@
+from typing import Any
+
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import viewsets
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from api.public.v2.component.serializers import ComponentSerializer
+from api.public.v2.schema import repo_parameters
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from services.components import commit_components, component_filtered_report
+from utils import round_decimals_down
+
+
+@extend_schema(
+ parameters=repo_parameters
+ + [
+ OpenApiParameter(
+ "sha",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="commit SHA for which to return components",
+ ),
+ OpenApiParameter(
+ "branch",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="branch name for which to return components (of head commit)",
+ ),
+ ],
+ tags=["Components"],
+)
+class ComponentViewSet(viewsets.ViewSet, RepoPropertyMixin):
+ serializer_class = ComponentSerializer
+ permission_classes = [RepositoryArtifactPermissions]
+
+ @extend_schema(summary="Component list")
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Returns a list of components for the specified repository
+ """
+ commit = self.get_commit()
+ report = commit.full_report
+ components = commit_components(commit, self.owner)
+ components_with_coverage = []
+ for component in components:
+ component_report = component_filtered_report(report, [component])
+ coverage = None
+ if component_report.totals.coverage is not None:
+ coverage = round_decimals_down(
+ float(component_report.totals.coverage), 2
+ )
+ components_with_coverage.append(
+ {
+ "component_id": component.component_id,
+ "name": component.name,
+ "coverage": coverage,
+ }
+ )
+
+ serializer = ComponentSerializer(components_with_coverage, many=True)
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/public/v2/coverage/__init__.py b/apps/codecov-api/api/public/v2/coverage/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/coverage/filters.py b/apps/codecov-api/api/public/v2/coverage/filters.py
new file mode 100644
index 0000000000..47a29e4f62
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/coverage/filters.py
@@ -0,0 +1,35 @@
+import django_filters
+
+INTERVAL_CHOICES = (
+ ("1d", "1 day"),
+ ("7d", "7 day"),
+ ("30d", "30 day"),
+)
+
+
+class MeasurementFilters(django_filters.FilterSet):
+ interval = django_filters.ChoiceFilter(
+ choices=INTERVAL_CHOICES, method="filter_interval", required=True
+ )
+ start_date = django_filters.DateTimeFilter(
+ label="start datetime (inclusive)", method="filter_start_date"
+ )
+ end_date = django_filters.DateTimeFilter(
+ label="end datetime (inclusive)", method="filter_end_date"
+ )
+ branch = django_filters.CharFilter(label="branch name", method="filter_branch")
+
+ # the filtering for these methods happens in the view since they
+ # all need to be passed in to some of the timeseries helper functions
+
+ def filter_interval(self, queryset, name, value):
+ return queryset
+
+ def filter_start_date(self, queryset, name, value):
+ return queryset
+
+ def filter_end_date(self, queryset, name, value):
+ return queryset
+
+ def filter_branch(self, queryset, name, value):
+ return queryset
diff --git a/apps/codecov-api/api/public/v2/coverage/serializers.py b/apps/codecov-api/api/public/v2/coverage/serializers.py
new file mode 100644
index 0000000000..ad9453c156
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/coverage/serializers.py
@@ -0,0 +1,10 @@
+from rest_framework import serializers
+
+
+class MeasurementSerializer(serializers.Serializer):
+ timestamp = serializers.DateTimeField(
+ source="timestamp_bin", label="timestamp at the start of the interval"
+ )
+ min = serializers.FloatField(label="minimum value in the interval")
+ max = serializers.FloatField(label="maximum value in the interval")
+ avg = serializers.FloatField(label="average value in the interval")
diff --git a/apps/codecov-api/api/public/v2/coverage/views.py b/apps/codecov-api/api/public/v2/coverage/views.py
new file mode 100644
index 0000000000..0e60b8d536
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/coverage/views.py
@@ -0,0 +1,114 @@
+from datetime import datetime
+
+from drf_spectacular.utils import extend_schema
+from rest_framework import mixins, viewsets
+from rest_framework.exceptions import APIException
+
+from api.public.v2.schema import repo_parameters
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from reports.models import RepositoryFlag
+from timeseries.helpers import (
+ aggregate_measurements,
+ repository_coverage_measurements_with_fallback,
+)
+from timeseries.models import (
+ Interval,
+ MeasurementName,
+ MeasurementSummary,
+ MeasurementSummary1Day,
+)
+
+from .filters import MeasurementFilters
+from .serializers import MeasurementSerializer
+
+
+class InvalidInterval(APIException):
+ status_code = 422
+ default_detail = "You must specify an interval (1d/7d/30d)"
+ default_code = "invalid_interval"
+
+
+intervals = {
+ "1d": Interval.INTERVAL_1_DAY,
+ "7d": Interval.INTERVAL_7_DAY,
+ "30d": Interval.INTERVAL_30_DAY,
+}
+
+
+@extend_schema(parameters=repo_parameters, tags=["Coverage"])
+class CoverageViewSet(
+ viewsets.GenericViewSet,
+ mixins.ListModelMixin,
+ RepoPropertyMixin,
+):
+ permission_classes = [RepositoryArtifactPermissions]
+ serializer_class = MeasurementSerializer
+ filterset_class = MeasurementFilters
+
+ # this is here so that drf-spectacular can introspect the model filters
+ queryset = MeasurementSummary1Day.objects.none()
+
+ def get_queryset(self):
+ return repository_coverage_measurements_with_fallback(
+ self.repo,
+ self.get_measurement_interval(),
+ start_date=self.request.query_params.get(
+ "start_date", datetime(2000, 1, 1)
+ ),
+ end_date=self.request.query_params.get("end_date", datetime.now()),
+ branch=self.request.query_params.get("branch"),
+ )
+
+ def get_measurement_interval(self) -> Interval:
+ interval_name = self.request.query_params.get("interval")
+ if interval_name not in intervals:
+ raise InvalidInterval()
+
+ return intervals[interval_name]
+
+ @extend_schema(summary="Coverage trend")
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of timeseries measurements aggregated by the specified
+ `interval`. If there are no measurements on `start_date` then the response will include
+ 1 measurement older than `start_date` so that the coverage value can be carried forward
+ if necessary.
+
+ Optionally filterable by:
+ * `branch`
+ * `start_date`
+ * `end_date`
+ """
+ return super().list(request, *args, **kwargs)
+
+
+@extend_schema(parameters=repo_parameters, tags=["Flags"])
+class FlagCoverageViewSet(CoverageViewSet):
+ def get_queryset(self):
+ queryset = MeasurementSummary.agg_by(self.get_measurement_interval())
+
+ flag = RepositoryFlag.objects.filter(
+ repository_id=self.repo.pk,
+ flag_name=self.kwargs["flag_name"],
+ ).first()
+ if not flag:
+ return queryset.none()
+
+ queryset = queryset.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag.pk),
+ )
+
+ start_date = self.request.query_params.get("start_date")
+ if start_date is not None:
+ queryset = queryset.filter(timestamp_bin__gte=start_date)
+ end_date = self.request.query_params.get("end_date")
+ if end_date is not None:
+ queryset = queryset.filter(timestamp_bin__lte=end_date)
+
+ return aggregate_measurements(
+ queryset, ["timestamp_bin", "owner_id", "repo_id", "measurable_id"]
+ )
diff --git a/apps/codecov-api/api/public/v2/flag/__init__.py b/apps/codecov-api/api/public/v2/flag/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/flag/serializers.py b/apps/codecov-api/api/public/v2/flag/serializers.py
new file mode 100644
index 0000000000..0c2b94c4d0
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/flag/serializers.py
@@ -0,0 +1,6 @@
+from rest_framework import serializers
+
+
+class FlagSerializer(serializers.Serializer):
+ flag_name = serializers.CharField(label="flag name")
+ coverage = serializers.FloatField(label="flag coverage")
diff --git a/apps/codecov-api/api/public/v2/flag/views.py b/apps/codecov-api/api/public/v2/flag/views.py
new file mode 100644
index 0000000000..a4832248a2
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/flag/views.py
@@ -0,0 +1,45 @@
+from typing import Any
+
+from django.db.models import QuerySet
+from drf_spectacular.utils import extend_schema
+from rest_framework import mixins, viewsets
+from rest_framework.exceptions import NotFound
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from api.public.v2.flag.serializers import FlagSerializer
+from api.public.v2.schema import repo_parameters
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from reports.models import RepositoryFlag
+
+
+@extend_schema(parameters=repo_parameters, tags=["Flags"])
+class FlagViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, RepoPropertyMixin):
+ serializer_class = FlagSerializer
+ permission_classes = [RepositoryArtifactPermissions]
+ lookup_field = "flag_name"
+ queryset = RepositoryFlag.objects.none()
+
+ def get_queryset(self) -> QuerySet:
+ results = [
+ {"flag_name": f.flag_name, "coverage": None} for f in self.repo.flags.all()
+ ]
+ try:
+ report = self.get_commit().full_report
+ if not report:
+ return results
+ except NotFound:
+ return results
+
+ for i, val in enumerate(results):
+ flag_report = report.filter(flags=[val["flag_name"]])
+ results[i]["coverage"] = flag_report.totals.coverage or 0
+ return results
+
+ @extend_schema(summary="Flag list")
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Returns a paginated list of flags for the specified repository
+ """
+ return super().list(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/owner/__init__.py b/apps/codecov-api/api/public/v2/owner/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/owner/serializers.py b/apps/codecov-api/api/public/v2/owner/serializers.py
new file mode 100644
index 0000000000..754d4b7c90
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/owner/serializers.py
@@ -0,0 +1,41 @@
+from rest_framework import serializers
+
+from codecov_auth.models import Owner
+
+
+class OwnerSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Owner
+ fields = (
+ "service",
+ "username",
+ "name",
+ )
+ read_only_fields = fields
+
+
+class UserSerializer(OwnerSerializer):
+ activated = serializers.BooleanField()
+ is_admin = serializers.BooleanField()
+
+ class Meta:
+ model = Owner
+ fields = OwnerSerializer.Meta.fields + ("activated", "is_admin", "email")
+
+
+class UserSessionSerializer(serializers.ModelSerializer):
+ has_active_session = serializers.BooleanField()
+ expiry_date = serializers.DateTimeField()
+
+ class Meta:
+ model = Owner
+ fields = ("username", "name", "has_active_session", "expiry_date")
+ read_only_fields = fields
+
+
+class UserUpdateActivationSerializer(serializers.ModelSerializer):
+ activated = serializers.BooleanField()
+
+ class Meta:
+ model = Owner
+ fields = ("activated",)
diff --git a/apps/codecov-api/api/public/v2/owner/views.py b/apps/codecov-api/api/public/v2/owner/views.py
new file mode 100644
index 0000000000..8677f6f221
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/owner/views.py
@@ -0,0 +1,139 @@
+from typing import Any
+
+from django.db.models import Q, QuerySet
+from drf_spectacular.utils import extend_schema
+from rest_framework import mixins, viewsets
+from rest_framework.exceptions import APIException, NotFound
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from api.public.v2.schema import (
+ owner_parameters,
+ service_parameter,
+)
+from api.shared.owner.mixins import (
+ OwnerViewSetMixin,
+ UserSessionViewSetMixin,
+ UserViewSetMixin,
+)
+from codecov_auth.models import Owner, Service
+
+from .serializers import (
+ OwnerSerializer,
+ UserSerializer,
+ UserSessionSerializer,
+ UserUpdateActivationSerializer,
+)
+
+
+class NotEnoughSeatsLeft(APIException):
+ status_code = 400
+ default_detail = "Cannot activate user -- not enough seats left."
+ default_code = "no_seats_left"
+
+
+@extend_schema(parameters=owner_parameters, tags=["Users"])
+class OwnerViewSet(
+ OwnerViewSetMixin, viewsets.GenericViewSet, mixins.RetrieveModelMixin
+):
+ serializer_class = OwnerSerializer
+ queryset = Owner.objects.none()
+
+ @extend_schema(summary="Owner detail")
+ def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Owner:
+ """
+ Returns a single owner by name
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+
+@extend_schema(parameters=owner_parameters, tags=["Users"])
+class UserViewSet(UserViewSetMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin):
+ serializer_class = UserSerializer
+ queryset = Owner.objects.none()
+
+ @extend_schema(summary="User list")
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Returns a paginated list of users for the specified owner (org)
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(summary="User detail")
+ def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Owner:
+ """
+ Returns a user for the specified owner_username or ownerid
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+ @extend_schema(summary="Update a user", request=UserUpdateActivationSerializer)
+ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Updates a user for the specified owner_username or ownerid
+
+ Allowed fields
+ - activated: boolean value to activate or deactivate the user
+ """
+ instance = self.get_object()
+ serializer = UserUpdateActivationSerializer(
+ instance,
+ data=request.data,
+ )
+ serializer.is_valid(raise_exception=True)
+
+ if serializer.validated_data["activated"]:
+ if self.owner.can_activate_user(instance):
+ self.owner.activate_user(instance)
+ else:
+ raise NotEnoughSeatsLeft()
+ else:
+ self.owner.deactivate_user(instance)
+
+ return super().retrieve(request, *args, **kwargs)
+
+
+@extend_schema(parameters=owner_parameters, tags=["Users"])
+class UserSessionViewSet(UserSessionViewSetMixin, mixins.ListModelMixin):
+ serializer_class = UserSessionSerializer
+ queryset = Owner.objects.none()
+
+ @extend_schema(summary="User session list")
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Returns a paginated list of users' login session for the specified owner (org)
+
+ Note: Requires the caller to be an admin of the requested organization
+ """
+ return super().list(request, *args, **kwargs)
+
+
+@extend_schema(
+ parameters=[
+ service_parameter,
+ ],
+ tags=["Users"],
+)
+class OwnersViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
+ serializer_class = OwnerSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self) -> QuerySet:
+ service = self.kwargs.get("service")
+ try:
+ Service(service)
+ except ValueError:
+ raise NotFound(f"Service not found: {service}")
+
+ current_owner = self.request.current_owner
+ return Owner.objects.filter(
+ Q(service=service, ownerid__in=current_owner.organizations)
+ | Q(service=service, username=current_owner.username)
+ )
+
+ @extend_schema(summary="Service owners")
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ """
+ Returns all owners to which the currently authenticated user has access
+ """
+ return super().list(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/pull/__init__.py b/apps/codecov-api/api/public/v2/pull/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/pull/serializers.py b/apps/codecov-api/api/public/v2/pull/serializers.py
new file mode 100644
index 0000000000..9e4ab98be6
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/pull/serializers.py
@@ -0,0 +1,66 @@
+from typing import Dict, Optional
+
+from rest_framework import serializers
+
+from api.public.v2.owner.serializers import OwnerSerializer
+from api.shared.commit.serializers import (
+ CommitTotalsSerializer,
+ PatchCoverageSerializer,
+)
+from core.models import Pull, PullStates
+from services.comparison import CommitComparisonService, ComparisonReport
+
+
+class PullSerializer(serializers.ModelSerializer):
+ pullid = serializers.IntegerField(label="pull ID number")
+ title = serializers.CharField(label="title of the pull")
+ base_totals = CommitTotalsSerializer(label="coverage totals of base commit")
+ head_totals = CommitTotalsSerializer(label="coverage totals of head commit")
+ updatestamp = serializers.DateTimeField(label="last updated timestamp")
+ state = serializers.ChoiceField(
+ label="state of the pull", choices=PullStates.choices
+ )
+ ci_passed = serializers.BooleanField(
+ label="indicates whether the CI process passed for the head commit of this pull"
+ )
+ author = OwnerSerializer(label="pull author")
+ patch = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Pull
+ read_only_fields = (
+ "pullid",
+ "title",
+ "base_totals",
+ "head_totals",
+ "updatestamp",
+ "state",
+ "ci_passed",
+ "author",
+ "patch",
+ )
+ fields = read_only_fields
+
+ def get_patch(self, obj: Pull) -> Optional[Dict[str, float]]:
+ commit_comparison = CommitComparisonService.get_commit_comparison_for_pull(obj)
+ if not commit_comparison or not commit_comparison.is_processed:
+ return None
+ cr = ComparisonReport(commit_comparison)
+ hits = misses = partials = 0
+ for f in cr.impacted_files:
+ pc = f.patch_coverage
+ if pc:
+ hits += pc.hits
+ misses += pc.misses
+ partials += pc.partials
+ total_branches = hits + misses + partials
+ coverage = 0.0
+ if total_branches != 0:
+ coverage = round(100 * hits / total_branches, 2)
+ data = dict(
+ hits=hits,
+ misses=misses,
+ partials=partials,
+ coverage=coverage,
+ )
+ return PatchCoverageSerializer(data).data
diff --git a/apps/codecov-api/api/public/v2/pull/views.py b/apps/codecov-api/api/public/v2/pull/views.py
new file mode 100644
index 0000000000..ddb092a8d4
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/pull/views.py
@@ -0,0 +1,96 @@
+import django_filters
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins
+from rest_framework.authentication import BasicAuthentication, SessionAuthentication
+
+from api.public.v2.schema import repo_parameters
+from api.shared.pagination import PaginationMixin
+from api.shared.permissions import RepositoryArtifactPermissions, SuperTokenPermissions
+from api.shared.pull.mixins import PullViewSetMixin
+from codecov_auth.authentication import (
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+)
+from core.models import Pull, PullStates
+
+from .serializers import PullSerializer
+
+
+class PullFilters(django_filters.FilterSet):
+ state = django_filters.ChoiceFilter(choices=PullStates.choices)
+ start_date = django_filters.DateTimeFilter(method="filter_start_date")
+
+ def filter_start_date(self, queryset, name, value):
+ return queryset.filter(updatestamp__gte=value)
+
+
+@extend_schema(parameters=repo_parameters, tags=["Pulls"])
+class PullViewSet(
+ PaginationMixin,
+ PullViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+):
+ authentication_classes = [
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+ BasicAuthentication,
+ SessionAuthentication,
+ ]
+
+ permission_classes = [SuperTokenPermissions | RepositoryArtifactPermissions]
+
+ serializer_class = PullSerializer
+ queryset = Pull.objects.none()
+ filterset_class = PullFilters
+
+ def get_queryset(self):
+ return super().get_queryset().select_related("author")
+
+ @extend_schema(
+ summary="Pull list",
+ parameters=[
+ OpenApiParameter(
+ "state",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="the state of the pull (open/merged/closed)",
+ ),
+ OpenApiParameter(
+ "start_date",
+ OpenApiTypes.DATETIME,
+ OpenApiParameter.QUERY,
+ description="only return pulls with updatestamp on or after this date",
+ ),
+ ],
+ )
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of pulls for the specified repository
+
+ Optionally filterable by:
+ * `state`
+ * `start_date`
+
+ Orderable by:
+ * `pullid`
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Pull detail",
+ parameters=[
+ OpenApiParameter(
+ "pullid",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="pull ID",
+ ),
+ ],
+ )
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a single pull by ID
+ """
+ return super().retrieve(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/repo/__init__.py b/apps/codecov-api/api/public/v2/repo/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/repo/permissions.py b/apps/codecov-api/api/public/v2/repo/permissions.py
new file mode 100644
index 0000000000..feb1334a33
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/repo/permissions.py
@@ -0,0 +1,8 @@
+from rest_framework.permissions import BasePermission
+
+from codecov_auth.helpers import current_user_part_of_org
+
+
+class RepositoryOrgMemberPermissions(BasePermission):
+ def has_permission(self, request, view):
+ return current_user_part_of_org(request.current_owner, view.repo.author)
diff --git a/apps/codecov-api/api/public/v2/repo/serializers.py b/apps/codecov-api/api/public/v2/repo/serializers.py
new file mode 100644
index 0000000000..a99cd57c45
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/repo/serializers.py
@@ -0,0 +1,55 @@
+from rest_framework import serializers
+
+from api.public.v2.owner.serializers import OwnerSerializer
+from api.shared.commit.serializers import CommitTotalsSerializer
+from core.models import Repository
+
+
+class RepoSerializer(serializers.ModelSerializer):
+ name = serializers.CharField(label="repository name")
+ private = serializers.BooleanField(label="indicates private vs. public repository")
+ updatestamp = serializers.DateTimeField(label="last updated timestamp")
+ language = serializers.CharField(label="primary programming language used")
+ branch = serializers.CharField(label="default branch name")
+ active = serializers.BooleanField(
+ label="indicates whether the repository has received a coverage upload"
+ )
+ activated = serializers.BooleanField(
+ label="indicates whether the repository has been manually deactivated"
+ )
+ author = OwnerSerializer(label="repository owner")
+ totals = CommitTotalsSerializer(
+ label="recent commit totals on the default branch",
+ source="recent_commit_totals",
+ )
+
+ class Meta:
+ model = Repository
+ read_only_fields = (
+ "name",
+ "private",
+ "updatestamp",
+ "author",
+ "language",
+ "branch",
+ "active",
+ "activated",
+ "totals",
+ )
+ fields = read_only_fields
+
+
+class RepoConfigSerializer(serializers.ModelSerializer):
+ upload_token = serializers.CharField(
+ label="token used for uploading coverage reports for this repo"
+ )
+
+ graph_token = serializers.CharField(
+ source="image_token",
+ label="token used for repository graphs",
+ )
+
+ class Meta:
+ model = Repository
+ read_only_fields = ("upload_token", "graph_token")
+ fields = read_only_fields
diff --git a/apps/codecov-api/api/public/v2/repo/views.py b/apps/codecov-api/api/public/v2/repo/views.py
new file mode 100644
index 0000000000..c0a35157d5
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/repo/views.py
@@ -0,0 +1,111 @@
+from django_filters import rest_framework as django_filters
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import filters, mixins, views, viewsets
+from rest_framework.response import Response
+
+from api.public.v2.schema import owner_username_parameter, service_parameter
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from api.shared.repo.filter import RepositoryFilters
+from api.shared.repo.mixins import RepositoryViewSetMixin
+from core.models import Repository
+
+from .permissions import RepositoryOrgMemberPermissions
+from .serializers import RepoConfigSerializer, RepoSerializer
+
+
+@extend_schema(
+ parameters=[
+ service_parameter,
+ owner_username_parameter,
+ ],
+ tags=["Repos"],
+)
+class RepositoryViewSet(
+ RepositoryViewSetMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ viewsets.GenericViewSet,
+):
+ filter_backends = (
+ django_filters.DjangoFilterBackend,
+ filters.SearchFilter,
+ )
+ filterset_class = RepositoryFilters
+ search_fields = ("name",)
+ ordering_fields = (
+ "updatestamp",
+ "name",
+ )
+ serializer_class = RepoSerializer
+ queryset = Repository.objects.none()
+
+ def get_queryset(self):
+ return super().get_queryset().with_recent_coverage()
+
+ @extend_schema(
+ summary="Repository list",
+ parameters=[
+ OpenApiParameter(
+ name="names",
+ type={"type": "array", "items": "string"},
+ explode=True,
+ description="list of repository names",
+ )
+ ],
+ )
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a paginated list of repositories for the specified provider service and owner username
+
+ Optionally filterable by:
+ * a list of repository `name`s
+ * a `search` term which matches against the name
+ * whether the repository is `active` or not
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Repository detail",
+ parameters=[
+ OpenApiParameter(
+ "repo_name",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="repository name",
+ ),
+ ],
+ )
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a single repository by name
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+
+@extend_schema(
+ parameters=[
+ service_parameter,
+ owner_username_parameter,
+ ],
+ tags=["Repos"],
+)
+class RepositoryConfigView(views.APIView, RepoPropertyMixin):
+ permission_classes = [RepositoryArtifactPermissions, RepositoryOrgMemberPermissions]
+
+ @extend_schema(
+ summary="Repository config",
+ parameters=[
+ OpenApiParameter(
+ "repo_name",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="repository name",
+ ),
+ ],
+ responses={200: RepoConfigSerializer},
+ )
+ def get(self, request, *args, **kwargs):
+ serializer = RepoConfigSerializer(self.repo)
+ return Response(serializer.data)
diff --git a/apps/codecov-api/api/public/v2/report/__init__.py b/apps/codecov-api/api/public/v2/report/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/report/serializers.py b/apps/codecov-api/api/public/v2/report/serializers.py
new file mode 100644
index 0000000000..1ef3df74b8
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/report/serializers.py
@@ -0,0 +1,24 @@
+from rest_framework import serializers
+
+from api.shared.commit.serializers import ReportFileSerializer, ReportSerializer
+
+
+class CoverageReportSerializer(ReportSerializer):
+ commit_file_url = serializers.CharField(
+ label="Codecov url to see file coverage on commit. Can be unreliable with partial path names."
+ )
+
+
+class FileReportSerializer(ReportFileSerializer):
+ commit_sha = serializers.SerializerMethodField(
+ label="commit SHA of the commit for which coverage info was found"
+ )
+ commit_file_url = serializers.SerializerMethodField(
+ label="Codecov URL to see file coverage on commit."
+ )
+
+ def get_commit_sha(self, obj):
+ return self.context["commit_sha"]
+
+ def get_commit_file_url(self, obj):
+ return self.context["commit_file_url"]
diff --git a/apps/codecov-api/api/public/v2/report/views.py b/apps/codecov-api/api/public/v2/report/views.py
new file mode 100644
index 0000000000..43d63a12bd
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/report/views.py
@@ -0,0 +1,377 @@
+from typing import Optional
+
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins, viewsets
+from rest_framework.authentication import BasicAuthentication, SessionAuthentication
+from rest_framework.decorators import action
+from rest_framework.exceptions import NotFound, ValidationError
+from rest_framework.response import Response
+from shared.reports.resources import Report
+from shared.utils.match import match
+
+from api.public.v2.report.serializers import (
+ CoverageReportSerializer,
+ FileReportSerializer,
+)
+from api.public.v2.schema import repo_parameters
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions, SuperTokenPermissions
+from api.shared.report.serializers import TreeSerializer
+from codecov_auth.authentication import (
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+)
+from core.models import Commit
+from services.components import commit_components
+from services.path import ReportPaths, dashboard_commit_file_url
+
+
+class ReportMixin:
+ def _commit_file_url(self, commit: Commit, path: str):
+ service, owner, repo = (
+ self.kwargs["service"],
+ self.kwargs["owner_username"],
+ self.kwargs["repo_name"],
+ )
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=service,
+ owner=owner,
+ repo=repo,
+ commit=commit,
+ )
+ return commit_file_url
+
+
+@extend_schema(
+ parameters=repo_parameters
+ + [
+ OpenApiParameter(
+ "sha",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="commit SHA for which to return report",
+ ),
+ OpenApiParameter(
+ "branch",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="branch name for which to return report (of head commit)",
+ ),
+ OpenApiParameter(
+ "path",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="filter report to only include file paths starting with this value",
+ ),
+ OpenApiParameter(
+ "flag",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="filter report to only include info pertaining to given flag name",
+ ),
+ OpenApiParameter(
+ "component_id",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="filter report to only include info pertaining to given component id",
+ ),
+ ],
+ tags=["Coverage"],
+)
+class BaseReportViewSet(
+ viewsets.GenericViewSet, mixins.RetrieveModelMixin, RepoPropertyMixin, ReportMixin
+):
+ serializer_class = CoverageReportSerializer
+ permission_classes = [RepositoryArtifactPermissions]
+
+ def filter_report(
+ self,
+ commit: Commit,
+ report: Report,
+ path: Optional[str] = None,
+ flag: Optional[str] = None,
+ component_id: Optional[str] = None,
+ ) -> Report:
+ if component_id:
+ component = next(
+ (
+ component
+ for component in commit_components(commit, self.owner)
+ if component.component_id == component_id
+ ),
+ None,
+ )
+ if component is None:
+ raise NotFound(
+ f"The component {component_id} does not exist in commit {commit.commitid}"
+ )
+
+ if path and not match(component.paths, path):
+ # empty report since the path is not part of the component
+ return Report()
+
+ component_flags = component.get_matching_flags(report.get_flag_names())
+ if flag and len(component.flag_regexes) > 0 and flag not in component_flags:
+ # empty report since the flag is not part of the component
+ return Report()
+
+ if path and flag:
+ report = report.filter(flags=[flag], paths=[f"{path}.*"])
+ elif path:
+ report = report.filter(paths=[f"{path}.*"])
+ elif flag:
+ report = report.filter(flags=[flag])
+ elif component_id:
+ report = report.filter(flags=component_flags, paths=component.paths)
+
+ if path and len(report.files) == 0:
+ raise NotFound(f"No files or directories found matching path: {path}")
+
+ return report
+
+ def get_object(self):
+ commit = self.get_commit()
+ report = commit.full_report
+
+ if report is None:
+ raise NotFound(f"No coverage report found for commit {commit.commitid}")
+
+ path = self.request.query_params.get("path", None)
+ report = self.filter_report(
+ commit,
+ report,
+ path=path,
+ flag=self.request.query_params.get("flag", None),
+ component_id=self.request.query_params.get("component_id", None),
+ )
+
+ # Add commit url to report object
+ report.commit_file_url = self._commit_file_url(commit, path)
+
+ return report
+
+ def retrieve(self, request, *args, **kwargs):
+ report = self.get_object()
+ serializer = self.get_serializer(report)
+ return Response(serializer.data)
+
+
+class TotalsViewSet(BaseReportViewSet):
+ def get_serializer_context(self, *args, **kwargs):
+ context = super().get_serializer_context(*args, **kwargs)
+ context.update({"include_line_coverage": False})
+ return context
+
+ @extend_schema(summary="Commit coverage totals")
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns the coverage totals for a given commit and the
+ coverage totals broken down by file.
+
+ By default that commit is the head of the default branch but can also be specified explictily by:
+ * `sha` - return totals for the commit with the given SHA
+ * `branch` - return totals for the head commit of the branch with the given name
+
+ The totals can be optionally filtered by specifying:
+ * `path` - only show totals for pathnames that start with this value
+ * `flag` - only show totals that applies to the specified flag name
+ * `component_id` - only show totals that applies to the specified component
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+
+class ReportViewSet(BaseReportViewSet):
+ authentication_classes = [
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+ BasicAuthentication,
+ SessionAuthentication,
+ ]
+ permission_classes = [SuperTokenPermissions | RepositoryArtifactPermissions]
+
+ def get_queryset(self):
+ return None
+
+ def get_serializer_context(self, *args, **kwargs):
+ context = super().get_serializer_context(*args, **kwargs)
+ context.update({"include_line_coverage": True})
+ return context
+
+ @extend_schema(summary="Commit coverage report")
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Similar to the coverage totals endpoint but also returns line-by-line
+ coverage info (hit=0/miss=1/partial=2).
+
+ By default that commit is the head of the default branch but can also be specified explictily by:
+ * `sha` - return report for the commit with the given SHA
+ * `branch` - return report for the head commit of the branch with the given name
+
+ The report can be optionally filtered by specifying:
+ * `path` - only show report info for pathnames that start with this value
+ * `flag` - only show report info that applies to the specified flag name
+ * `component_id` - only show report info that applies to the specified component
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Coverage report tree",
+ parameters=[
+ OpenApiParameter(
+ "depth",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="depth of the traversal (default=1)",
+ ),
+ OpenApiParameter(
+ "path",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="starting path of the traversal (default is root path)",
+ ),
+ ],
+ responses={200: TreeSerializer},
+ )
+ @action(
+ detail=False,
+ methods=["get"],
+ url_path="tree",
+ )
+ def tree(self, request, *args, **kwargs):
+ """
+ Returns a hierarchical view of the report that matches the file structure of the covered files
+ with coverage info rollups at each level.
+
+ Returns only top-level data by default but the depth of the traversal can be controlled via
+ the `depth` parameter.
+
+ * `depth` - how deep in the tree to traverse (default=1)
+ * `path` - path in the tree from which to start the traversal (default is the root)
+ """
+ report = self.get_object()
+ path = request.query_params.get("path")
+ paths = ReportPaths(report, path=path)
+ serializer = TreeSerializer(
+ paths.single_directory(),
+ many=True,
+ context={
+ "max_depth": int(request.query_params.get("depth", 1)),
+ },
+ )
+ return Response(serializer.data)
+
+
+@extend_schema(
+ parameters=repo_parameters
+ + [
+ OpenApiParameter(
+ "path",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="the file path for which to retrieve coverage info",
+ ),
+ OpenApiParameter(
+ "sha",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="commit SHA for which to return report",
+ ),
+ OpenApiParameter(
+ "branch",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="branch name for which to return report (of head commit)",
+ ),
+ ],
+ tags=["Coverage"],
+)
+class FileReportViewSet(
+ viewsets.GenericViewSet, mixins.RetrieveModelMixin, RepoPropertyMixin, ReportMixin
+):
+ authentication_classes = [
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+ BasicAuthentication,
+ SessionAuthentication,
+ ]
+ permission_classes = [SuperTokenPermissions | RepositoryArtifactPermissions]
+ serializer_class = FileReportSerializer
+
+ def get_queryset(self):
+ return None
+
+ def get_object(self):
+ self.path = self.kwargs.get("path")
+
+ walk_back = int(self.request.query_params.get("walk_back", 0))
+ if walk_back > 20:
+ raise ValidationError("walk_back must be <= 20")
+
+ self.commit = self.get_commit()
+ report = self.commit.full_report
+
+ oldest_sha = self.request.query_params.get("oldest_sha")
+
+ for i in range(walk_back):
+ if self._is_valid_commit(self.commit) and self._is_valid_report(
+ report, self.path
+ ):
+ break
+ else:
+ # walk commit ancestors until we find coverage info for the given path
+ if not self.commit.parent_commit_id:
+ report = None
+ break
+ self.commit = self.repo.commits.filter(
+ commitid=self.commit.parent_commit_id
+ ).first()
+ if not self.commit:
+ report = None
+ break
+ report = self.commit.full_report
+
+ if oldest_sha and oldest_sha == self.commit.commitid:
+ break
+
+ if not self._is_valid_report(report, self.path):
+ raise NotFound(f"coverage info not found for path '{self.path}'")
+
+ return report.get(self.path)
+
+ def get_serializer_context(self, *args, **kwargs):
+ context = super().get_serializer_context(*args, **kwargs)
+ context.update(
+ {
+ "include_line_coverage": True,
+ "commit_sha": self.commit.commitid,
+ "commit_file_url": self._commit_file_url(self.commit, self.path),
+ }
+ )
+ return context
+
+ @extend_schema(summary="File coverage report")
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Similar to the coverage report endpoint but only returns coverage info for a single
+ file specified by `path`.
+
+ By default that commit is the head of the default branch but can also be specified explictily by:
+ * `sha` - return report for the commit with the given SHA
+ * `branch` - return report for the head commit of the branch with the given name
+ """
+ return super().retrieve(request, *args, **kwargs)
+
+ def _is_valid_commit(self, commit: Commit) -> bool:
+ return commit.state == Commit.CommitStates.COMPLETE
+
+ def _is_valid_report(self, report: Report, path: str) -> bool:
+ if report is None:
+ return False
+
+ report_file = report.get(path)
+ if report_file is None:
+ return False
+
+ return True
diff --git a/apps/codecov-api/api/public/v2/schema.py b/apps/codecov-api/api/public/v2/schema.py
new file mode 100644
index 0000000000..b4b9983b53
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/schema.py
@@ -0,0 +1,29 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+
+from codecov_auth.models import Service
+
+service_parameter = OpenApiParameter(
+ "service",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="Git hosting service provider",
+ enum=[name for name, desc in Service.choices],
+)
+
+owner_username_parameter = OpenApiParameter(
+ "owner_username",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="username from service provider",
+)
+
+repo_name_parameter = OpenApiParameter(
+ "repo_name",
+ OpenApiTypes.STR,
+ OpenApiParameter.PATH,
+ description="repository name",
+)
+
+owner_parameters = [service_parameter, owner_username_parameter]
+repo_parameters = owner_parameters + [repo_name_parameter]
diff --git a/apps/codecov-api/api/public/v2/test_results/__init__.py b/apps/codecov-api/api/public/v2/test_results/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/public/v2/test_results/serializers.py b/apps/codecov-api/api/public/v2/test_results/serializers.py
new file mode 100644
index 0000000000..161368567c
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/test_results/serializers.py
@@ -0,0 +1,40 @@
+from rest_framework import serializers
+
+from reports.models import TestInstance
+
+
+class TestInstanceSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(label="id")
+ name = serializers.CharField(source="test.name", read_only=True, label="test name")
+ test_id = serializers.CharField(label="test id")
+ failure_message = serializers.CharField(label="test name")
+ duration_seconds = serializers.FloatField(label="duration in seconds")
+ commitid = serializers.CharField(label="commit SHA")
+ outcome = serializers.CharField(label="outcome")
+ branch = serializers.CharField(label="branch name")
+ repoid = serializers.IntegerField(label="repo id")
+ failure_rate = serializers.FloatField(
+ source="test.failure_rate", read_only=True, label="failure rate"
+ )
+ commits_where_fail = serializers.ListField(
+ source="test.commits_where_fail",
+ read_only=True,
+ label="commits where test failed",
+ )
+
+ class Meta:
+ model = TestInstance
+ read_only_fields = (
+ "id",
+ "test_id",
+ "failure_message",
+ "duration_seconds",
+ "commitid",
+ "outcome",
+ "branch",
+ "repoid",
+ "failure_rate",
+ "name",
+ "commits_where_fail",
+ )
+ fields = read_only_fields
diff --git a/apps/codecov-api/api/public/v2/test_results/views.py b/apps/codecov-api/api/public/v2/test_results/views.py
new file mode 100644
index 0000000000..efa5739032
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/test_results/views.py
@@ -0,0 +1,114 @@
+import django_filters
+from django_filters.rest_framework import DjangoFilterBackend
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import mixins, viewsets
+from rest_framework.authentication import BasicAuthentication, SessionAuthentication
+
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions, SuperTokenPermissions
+from codecov_auth.authentication import (
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+)
+from reports.models import TestInstance
+
+from .serializers import TestInstanceSerializer
+
+
+class TestResultsFilters(django_filters.FilterSet):
+ commit_id = django_filters.CharFilter(field_name="commitid")
+ outcome = django_filters.CharFilter(field_name="outcome")
+ duration_min = django_filters.NumberFilter(
+ field_name="duration_seconds", lookup_expr="gte"
+ )
+ duration_max = django_filters.NumberFilter(
+ field_name="duration_seconds", lookup_expr="lte"
+ )
+ branch = django_filters.CharFilter(field_name="branch")
+
+ class Meta:
+ model = TestInstance
+ fields = ["commit_id", "outcome", "duration_min", "duration_max", "branch"]
+
+
+@extend_schema(
+ parameters=[
+ OpenApiParameter(
+ "commit_id",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="Commit SHA for which to return test results",
+ ),
+ OpenApiParameter(
+ "outcome",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="Status of the test (failure, skip, error, pass)",
+ ),
+ OpenApiParameter(
+ "duration_min",
+ OpenApiTypes.INT,
+ OpenApiParameter.QUERY,
+ description="Minimum duration of the test in seconds",
+ ),
+ OpenApiParameter(
+ "duration_max",
+ OpenApiTypes.INT,
+ OpenApiParameter.QUERY,
+ description="Maximum duration of the test in seconds",
+ ),
+ OpenApiParameter(
+ "branch",
+ OpenApiTypes.STR,
+ OpenApiParameter.QUERY,
+ description="Branch name for which to return test results",
+ ),
+ ],
+ tags=["Test Results"],
+ summary="Retrieve test results",
+)
+class TestResultsView(
+ viewsets.GenericViewSet,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ RepoPropertyMixin,
+):
+ authentication_classes = [
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+ BasicAuthentication,
+ SessionAuthentication,
+ ]
+
+ serializer_class = TestInstanceSerializer
+ permission_classes = [SuperTokenPermissions | RepositoryArtifactPermissions]
+ filter_backends = [DjangoFilterBackend]
+ filterset_class = TestResultsFilters
+
+ def get_queryset(self):
+ return TestInstance.objects.filter(repoid=self.repo.repoid)
+
+ @extend_schema(summary="Test results list")
+ def list(self, request, *args, **kwargs):
+ """
+ Returns a list of test results for the specified repository and commit
+ """
+ return super().list(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Test results detail",
+ parameters=[
+ OpenApiParameter(
+ "id",
+ OpenApiTypes.INT,
+ OpenApiParameter.PATH,
+ description="Test instance ID",
+ ),
+ ],
+ )
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Returns a single test result by ID
+ """
+ return super().retrieve(request, *args, **kwargs)
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_branch_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_branch_viewset.py
new file mode 100644
index 0000000000..5291a83b10
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_branch_viewset.py
@@ -0,0 +1,89 @@
+from django.urls import reverse
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from codecov.tests.base_test import InternalAPITest
+from utils.test_utils import APIClient
+
+get_permissions_method = (
+ "api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions"
+)
+
+
+@freeze_time("2022-01-01T00:00:00")
+class BranchViewsetTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid], organizations=[self.org.ownerid]
+ )
+ self.branches = [
+ BranchFactory(repository=self.repo, name="foo"),
+ BranchFactory(repository=self.repo, name="bar"),
+ BranchFactory(name="baz"),
+ ]
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_list(self):
+ res = self.client.get(
+ reverse(
+ "api-v2-branches-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {"name": "foo", "updatestamp": "2022-01-01T00:00:00Z"},
+ {"name": "bar", "updatestamp": "2022-01-01T00:00:00Z"},
+ ],
+ "total_pages": 1,
+ }
+
+ def test_retrieve(self):
+ res = self.client.get(
+ reverse(
+ "api-v2-branches-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "name": self.branches[0].name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.data["name"] == self.branches[0].name
+ assert res.data["head_commit"]["report"]
+
+ def test_retrieve_period(self):
+ branch = BranchFactory(repository=self.repo, name="test.dot")
+
+ res = self.client.get(
+ reverse(
+ "api-v2-branches-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "name": branch.name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.data["name"] == branch.name
+ assert res.data["head_commit"]["report"]
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_commit_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_commit_viewset.py
new file mode 100644
index 0000000000..a039515a42
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_commit_viewset.py
@@ -0,0 +1,508 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from django.urls import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ CommitWithReportFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.types import ReportTotals
+
+from utils.test_utils import APIClient
+
+
+class MockCoverage(object):
+ def __init__(self, cov):
+ self.coverage = cov
+
+
+class MockReportFile(object):
+ def __init__(self, name):
+ self.name = name
+ self.lines = [
+ [1, MockCoverage("1/2")], # partial => 2
+ [2, MockCoverage(1)], # hit => 0
+ [3, MockCoverage(0)], # miss => 1
+ ]
+ self.totals = ReportTotals(
+ lines=3,
+ hits=1,
+ misses=1,
+ partials=1,
+ coverage=33.33,
+ )
+
+
+class MockReport(object):
+ def __init__(self):
+ self.files = ["foo/a.py", "bar/b.py"]
+ self.totals = ReportTotals(
+ files=2,
+ lines=6,
+ hits=2,
+ misses=2,
+ partials=2,
+ coverage=33.33,
+ )
+
+ def get(self, name):
+ return MockReportFile(name)
+
+
+class BaseRepoCommitTestCase(TestCase):
+ def setUp(self) -> None:
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+ self.commit = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ totals={
+ "C": 2,
+ "M": 0,
+ "N": 5,
+ "b": 0,
+ "c": "79.16667",
+ "d": 0,
+ "f": 3,
+ "h": 19,
+ "m": 5,
+ "n": 24,
+ "p": 0,
+ "s": 2,
+ "diff": 0,
+ },
+ )
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class RepoCommitListTestCase(BaseRepoCommitTestCase):
+ def test_commit_list_not_authenticated(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author, private=False)
+ CommitFactory(repository=repo)
+
+ self.client.logout()
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-list",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ },
+ )
+ )
+
+ # allows access to public repos
+ assert response.status_code == 200
+
+ def test_commit_list_authenticated(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 1,
+ "total_pages": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "commitid": self.commit.commitid,
+ "message": self.commit.message,
+ "timestamp": self.commit.timestamp.replace(tzinfo=None).isoformat()
+ + "Z",
+ "ci_passed": True,
+ "author": {
+ "service": "github",
+ "username": "codecov",
+ "name": self.org.name,
+ },
+ "branch": "main",
+ "totals": {
+ "files": 3,
+ "lines": 24,
+ "hits": 19,
+ "misses": 5,
+ "partials": 0,
+ "coverage": 79.16,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 2,
+ "complexity": 2.0,
+ "complexity_total": 5.0,
+ "complexity_ratio": 40.0,
+ "diff": 0,
+ },
+ "state": "complete",
+ "parent": self.commit.parent_commit_id,
+ }
+ ],
+ }
+
+ def test_commit_list_null_coverage(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ self.commit.totals["c"] = None
+ self.commit.save()
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 1,
+ "total_pages": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "commitid": self.commit.commitid,
+ "message": self.commit.message,
+ "timestamp": self.commit.timestamp.replace(tzinfo=None).isoformat()
+ + "Z",
+ "ci_passed": True,
+ "author": {
+ "service": "github",
+ "username": "codecov",
+ "name": self.org.name,
+ },
+ "branch": "main",
+ "totals": {
+ "files": 3,
+ "lines": 24,
+ "hits": 19,
+ "misses": 5,
+ "partials": 0,
+ "coverage": None,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 2,
+ "complexity": 2.0,
+ "complexity_total": 5.0,
+ "complexity_ratio": 40.0,
+ "diff": 0,
+ },
+ "state": "complete",
+ "parent": self.commit.parent_commit_id,
+ }
+ ],
+ }
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class RepoCommitDetailTestCase(BaseRepoCommitTestCase):
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_commit_detail_not_authenticated(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = MockReport()
+
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author, private=False)
+ commit = CommitFactory(author=author, repository=repo)
+
+ self.client.logout()
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-detail",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo.name,
+ "commitid": commit.commitid,
+ },
+ )
+ )
+ # allows access to public repos
+ assert response.status_code == 200
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_commit_detail_authenticated(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = MockReport()
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "commitid": self.commit.commitid,
+ },
+ )
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "commitid": self.commit.commitid,
+ "message": self.commit.message,
+ "timestamp": self.commit.timestamp.replace(tzinfo=None).isoformat() + "Z",
+ "ci_passed": True,
+ "author": {
+ "service": "github",
+ "username": "codecov",
+ "name": self.org.name,
+ },
+ "branch": "main",
+ "totals": {
+ "files": 3,
+ "lines": 24,
+ "hits": 19,
+ "misses": 5,
+ "partials": 0,
+ "coverage": 79.16,
+ "branches": 0,
+ "methods": 0,
+ "sessions": 2,
+ "complexity": 2.0,
+ "complexity_total": 5.0,
+ "complexity_ratio": 40.0,
+ "diff": 0,
+ },
+ "state": "complete",
+ "parent": self.commit.parent_commit_id,
+ "report": {
+ "files": [
+ {
+ "name": "foo/a.py",
+ "totals": {
+ "files": 0,
+ "lines": 3,
+ "hits": 1,
+ "misses": 1,
+ "partials": 1,
+ "coverage": 33.33,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 2],
+ [2, 0],
+ [3, 1],
+ ],
+ },
+ {
+ "name": "bar/b.py",
+ "totals": {
+ "files": 0,
+ "lines": 3,
+ "hits": 1,
+ "misses": 1,
+ "partials": 1,
+ "coverage": 33.33,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 2],
+ [2, 0],
+ [3, 1],
+ ],
+ },
+ ],
+ "totals": {
+ "files": 2,
+ "lines": 6,
+ "hits": 2,
+ "misses": 2,
+ "partials": 2,
+ "coverage": 33.33,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ }
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class RepoCommitUploadsTestCase(BaseRepoCommitTestCase):
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_commit_uploads_not_authenticated(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ build_report_from_commit.return_value = MockReport()
+ get_repo_permissions.return_value = (True, True)
+
+ author = OwnerFactory()
+ repo_public = RepositoryFactory(author=author, private=False)
+ repo_private = RepositoryFactory(author=author, private=True)
+ commit_public = CommitWithReportFactory(author=author, repository=repo_public)
+ commit_private = CommitWithReportFactory(author=author, repository=repo_private)
+
+ self.client.logout()
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-detail",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo_public.name,
+ "commitid": commit_public.commitid,
+ },
+ )
+ )
+
+ # allows access to public repos
+ assert response.status_code == 200
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-detail",
+ kwargs={
+ "service": author.service,
+ "owner_username": author.username,
+ "repo_name": repo_private.name,
+ "commitid": commit_private.commitid,
+ },
+ )
+ )
+
+ # does not allow access to private repos
+ assert response.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_commit_uploads_authenticated(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ build_report_from_commit.return_value = MockReport()
+ get_repo_permissions.return_value = (True, True)
+ commit = CommitWithReportFactory(author=self.org, repository=self.repo)
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-uploads",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "commitid": commit.commitid,
+ },
+ )
+ )
+ data = response.json()
+
+ expected_storage_path = "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt"
+
+ assert response.status_code == 200
+ assert len(data["results"]) == 2
+ assert data["results"][0]["storage_path"] == expected_storage_path
+ assert data["results"][1]["storage_path"] == expected_storage_path
+ assert data["results"][0]["totals"] == {
+ "files": 3,
+ "lines": 20,
+ "hits": 17,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 85.0,
+ "branches": 0,
+ "methods": 0,
+ }
+ assert data["results"][1]["totals"] == {
+ "files": 3,
+ "lines": 20,
+ "hits": 17,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 85.0,
+ "branches": 0,
+ "methods": 0,
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_commit_uploads_pagination(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ build_report_from_commit.return_value = MockReport()
+ get_repo_permissions.return_value = (True, True)
+ commit = CommitWithReportFactory(author=self.org, repository=self.repo)
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-uploads",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "commitid": commit.commitid,
+ },
+ ),
+ data={"page_size": 1, "page": 1},
+ )
+ data_1 = response.json()
+ # Check that first page has one item
+ assert response.status_code == 200
+ assert data_1["total_pages"] == 2
+ assert data_1["count"] == 2
+ assert len(data_1["results"]) == 1
+
+ response = self.client.get(
+ reverse(
+ "api-v2-commits-uploads",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "commitid": commit.commitid,
+ },
+ ),
+ data={"page_size": 1, "page": 2},
+ )
+ data_2 = response.json()
+ # Check that second page has one item
+ assert response.status_code == 200
+ assert data_2["total_pages"] == 2
+ assert data_2["count"] == 2
+ assert len(data_2["results"]) == 1
+
+ # Check that first and second items are different
+ assert data_1["results"][0]["created_at"] != data_2["results"][0]["created_at"]
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_compare_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_compare_viewset.py
new file mode 100644
index 0000000000..868e86c3f4
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_compare_viewset.py
@@ -0,0 +1,1032 @@
+from dataclasses import dataclass
+from unittest.mock import PropertyMock, patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ CommitWithReportFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.api_report_service import SerializableReport
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine, ReportTotals
+from shared.utils.merge import LineType
+from shared.utils.sessions import Session
+
+import services.comparison as comparison
+from api.shared.commit.serializers import ReportTotalsSerializer
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory
+from services.comparison import ComparisonReport
+from services.components import Component
+from utils.test_utils import APIClient
+
+
+def sample_report1():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+def sample_report2():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(11, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(13, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+class MockSerializableReport(SerializableReport):
+ """
+ Stubs the 'get' method of SerializableReport, which usually constructs
+ report files on the fly from information not provided by these test, like the chunks
+ for example.
+ """
+
+ def get(self, file_name):
+ return self.mocked_files.get(file_name)
+
+ @property
+ def files(self):
+ return self.mocked_files.keys()
+
+ def __contains__(self, f):
+ return f in self.mocked_files.keys()
+
+
+class MockedComparisonAdapter:
+ def __init__(self, test_diff, test_lines=[]):
+ self.test_lines = test_lines
+ self.test_diff = test_diff
+
+ async def get_source(self, file_name, commitid):
+ return {"content": self.test_lines}
+
+ async def get_compare(self, base, head):
+ return self.test_diff
+
+ async def get_authenticated(self):
+ return False, False
+
+
+def sample_report_impacted():
+ report = Report(flags={"flag1": True})
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ third_file = ReportFile("file3.py")
+ third_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.append(third_file)
+ report.add_session(Session(flags=["flag1"]))
+ return report
+
+
+mock_data_from_archive = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"]
+ ],
+ "unexpected_line_changes": []
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"h"],
+ [13,"h"],
+ [14,"h"],
+ [15,"h"],
+ [16,"m"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "m"]]]
+ }]
+}
+"""
+
+
+@dataclass
+class MockSegment:
+ has_diff_changes: bool = False
+ has_unintended_changes: bool = False
+
+
+class MockFileComparison(object):
+ def __init__(self):
+ self.segments = [
+ MockSegment(has_unintended_changes=True, has_diff_changes=False),
+ MockSegment(has_unintended_changes=False, has_diff_changes=True),
+ MockSegment(has_unintended_changes=True, has_diff_changes=True),
+ ]
+
+
+@patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+@patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+class TestCompareViewSetRetrieve(APITestCase):
+ """
+ Tests for retrieving a comparison. Does not test data that will be deprecated,
+ eg base and head report fields. Tests for commits etc will be added as the
+ compare-api refactor progresses.
+ """
+
+ def setUp(self):
+ self.file_name = "myfile.py"
+
+ self.mock_git_compare_data = {
+ "commits": [],
+ "diff": {
+ "files": {
+ self.file_name: {
+ "type": "modified",
+ "segments": [
+ {
+ "header": ["4", "43", "4", "3"],
+ "lines": ["", "", ""] + ["-this line is removed"] * 40,
+ }
+ ],
+ "stats": {"removed": 40, "added": 0},
+ "totals": ReportTotals.default_totals(),
+ }
+ }
+ },
+ }
+
+ self.mocked_compare_adapter = MockedComparisonAdapter(
+ self.mock_git_compare_data
+ )
+
+ self.base_file = ReportFile(
+ name=self.file_name, totals=[46, 46, 0, 0, 100, 0, 0, 0, 1, 0, 0, 0]
+ )
+ self.base_file._parsed_lines = [[1, "", [[1, 1, 0, 0, 0]], 0, 0]] * 46
+ self.base_report = MockSerializableReport()
+ self.base_report.mocked_files = {self.file_name: self.base_file}
+
+ self.head_file = ReportFile(
+ name=self.file_name, totals=[6, 6, 0, 0, 100, 0, 0, 0, 1, 0, 0, 0]
+ )
+ self.head_file._parsed_lines = [[1, "", [[1, 1, 0, 0, 0]], 0, 0]] * 6
+ self.head_file.totals.diff = ReportTotals.default_totals()
+ self.head_report = MockSerializableReport()
+ self.head_report.mocked_files = {self.file_name: self.head_file}
+
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.base, self.head = (
+ CommitFactory(repository=self.repo),
+ CommitFactory(repository=self.repo),
+ )
+ self.current_owner = OwnerFactory(
+ service=self.org.service,
+ permission=[self.repo.repoid],
+ organizations=[self.org.ownerid],
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ self.expected_files = [
+ {
+ "name": {"base": self.file_name, "head": self.file_name},
+ "totals": {
+ "base": ReportTotalsSerializer(self.base_file.totals).data,
+ "head": ReportTotalsSerializer(self.head_file.totals).data,
+ "patch": ReportTotalsSerializer(ReportTotals.default_totals()).data,
+ },
+ "has_diff": True,
+ "stats": {"added": 0, "removed": 40},
+ "change_summary": {},
+ "lines": [
+ {
+ "value": "",
+ "number": {"base": idx, "head": idx},
+ "coverage": {"base": LineType.hit, "head": LineType.hit},
+ "added": False,
+ "removed": False,
+ "is_diff": True,
+ "sessions": 1,
+ }
+ for idx in range(4, 7)
+ ]
+ + [
+ {
+ "value": "-this line is removed",
+ "number": {"base": idx, "head": None},
+ "coverage": {"base": LineType.hit, "head": None},
+ "added": False,
+ "removed": True,
+ "is_diff": True,
+ "sessions": None,
+ }
+ for idx in range(7, 47)
+ ],
+ }
+ ]
+
+ def _get_comparison(self, kwargs={}, query_params={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ if query_params == {}:
+ query_params = {"base": self.base.commitid, "head": self.head.commitid}
+
+ return self.client.get(
+ reverse("api-v2-compare-detail", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+
+ def _get_file_comparison(self, file_name="", kwargs={}, query_params={}):
+ if kwargs == {}:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "file_path": file_name or self.file_name,
+ }
+ if query_params == {}:
+ query_params = {"base": self.base.commitid, "head": self.head.commitid}
+
+ return self.client.get(
+ reverse("api-v2-compare-file", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+
+ def _get_flag_comparison(self, kwargs=None, query_params=None):
+ if kwargs is None:
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ if query_params is None:
+ query_params = {"base": self.base.commitid, "head": self.head.commitid}
+
+ return self.client.get(
+ reverse("api-v2-compare-flags", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+
+ def test_can_return_public_repo_comparison_with_not_authenticated(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ public_repo = RepositoryFactory(author=self.org, private=False)
+ base, head = (
+ CommitFactory(repository=public_repo),
+ CommitFactory(repository=public_repo),
+ )
+
+ self.client.logout()
+ response = self._get_comparison(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": public_repo.name,
+ },
+ query_params={"base": base.commitid, "head": head.commitid},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_returns_200_and_expected_files_on_success(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ def test_returns_404_if_base_or_head_references_not_found(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ response = self._get_comparison(query_params={"base": 12345, "head": 678})
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_returns_404_if_user_doesnt_have_permissions(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ other_user = OwnerFactory()
+ self.client.force_login(user=other_user)
+
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == 404
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ def test_accepts_pullid_query_param(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ def test_has_diff_query_param(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ pull = PullFactory(
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ )
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": pull.pullid,
+ "has_diff": "false",
+ }
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == []
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": pull.pullid,
+ "has_diff": "true",
+ }
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ def test_pullid_with_nonexistent_base_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base="123456",
+ head=self.head.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_pullid_with_nonexistent_head_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head="123456",
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_file_returns_compare_file_with_diff_and_src_data(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ src = b"first\nfirst\nfirst\nfirst\nfirst\nfirst"
+
+ adapter_mock.return_value = MockedComparisonAdapter(
+ test_diff=self.mock_git_compare_data, test_lines=src
+ )
+
+ response = self._get_file_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+
+ expected_lines = [
+ {
+ "value": "first",
+ "number": {"base": idx, "head": idx},
+ "coverage": {"base": LineType.hit, "head": LineType.hit},
+ "added": False,
+ "removed": False,
+ "is_diff": False,
+ "sessions": 1,
+ }
+ for idx in range(1, 4)
+ ] + self.expected_files[0]["lines"]
+
+ assert response.data["lines"] == expected_lines
+
+ def test_file_ignores_MAX_DIFF_SIZE(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ previous_max = comparison.MAX_DIFF_SIZE
+ comparison.MAX_DIFF_SIZE = -1
+
+ src = b"first\nfirst\nfirst\nfirst\nfirst\nfirst"
+ adapter_mock.return_value = MockedComparisonAdapter(
+ test_diff=self.mock_git_compare_data, test_lines=src
+ )
+
+ response = self._get_file_comparison()
+
+ comparison.MAX_DIFF_SIZE = previous_max
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["lines"]) == 46
+
+ def test_missing_base_report_returns_none_base_totals(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = None
+ head_report_mock.return_value = self.head_report
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["totals"]["base"] is None
+
+ def test_no_raw_reports_returns_404(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ base_report_mock.return_value = None
+ head_report_mock.side_effect = comparison.MissingComparisonReport(
+ "Missing head report"
+ )
+ adapter_mock.return_value = self.mocked_compare_adapter
+
+ response = self._get_comparison()
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_returns_403_if_user_inactive(
+ self, adapter_mock, base_report_mock, head_report_mock
+ ):
+ self.org.plan = "users-inappy"
+ self.org.plan_auto_activate = False
+ self.org.save()
+
+ response = self._get_comparison()
+ assert response.status_code == 403
+
+ @patch("redis.Redis.get", lambda self, key: None)
+ @patch("redis.Redis.set", lambda self, key, val, ex: None)
+ @patch(
+ "services.comparison.PullRequestComparison.pseudo_diff_adjusts_tracked_lines",
+ new_callable=PropertyMock,
+ )
+ @patch(
+ "services.comparison.PullRequestComparison.update_base_report_with_pseudo_diff"
+ )
+ def test_pull_request_pseudo_comparison_can_update_base_report(
+ self,
+ update_base_report_mock,
+ pseudo_diff_adjusts_tracked_lines_mock,
+ adapter_mock,
+ base_report_mock,
+ head_report_mock,
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ pseudo_diff_adjusts_tracked_lines_mock.return_value = True
+
+ response = self._get_comparison(
+ query_params={
+ "pullid": PullFactory(
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ pullid=2,
+ repository=self.repo,
+ ).pullid
+ }
+ )
+
+ update_base_report_mock.assert_called_once()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["files"] == self.expected_files
+
+ def test_flags_comparison(self, adapter_mock, base_report_mock, head_report_mock):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = self.base_report
+ head_report_mock.return_value = self.head_report
+
+ res = self._get_flag_comparison()
+ assert res.status_code == 200
+
+ @patch("api.public.v2.compare.views.commit_components")
+ def test_components_comparison(
+ self, commit_components, adapter_mock, base_report_mock, head_report_mock
+ ):
+ commit_components.return_value = [
+ Component(
+ component_id="foo",
+ paths=[r"^foo/.+"],
+ name="Foo",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ component_id="bar",
+ paths=[r"^bar/.+"],
+ name="Bar",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+ adapter_mock.return_value = self.mocked_compare_adapter
+ base_report_mock.return_value = sample_report1()
+ head_report_mock.return_value = sample_report2()
+
+ res = self.client.get(
+ reverse(
+ "api-v2-compare-components",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ ),
+ data={
+ "base": self.base.commitid,
+ "head": self.head.commitid,
+ },
+ content_type="application/json",
+ )
+
+ commit_components.assert_called_once_with(self.head, self.org)
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "component_id": "foo",
+ "name": "Foo",
+ "base_report_totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "head_report_totals": {
+ "files": 1,
+ "lines": 9,
+ "hits": 5,
+ "misses": 4,
+ "partials": 0,
+ "coverage": 55.55,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "diff_totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": None,
+ "complexity_total": None,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ {
+ "component_id": "bar",
+ "name": "Bar",
+ "base_report_totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "head_report_totals": {
+ "files": 1,
+ "lines": 3,
+ "hits": 2,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 66.66,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "diff_totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": None,
+ "complexity_total": None,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ]
+
+
+class TestImpactedFilesComparison(APITestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+
+ self.current_owner = OwnerFactory(
+ service=self.org.service,
+ permission=[self.repo.repoid],
+ organizations=[self.org.ownerid],
+ )
+
+ self.parent_commit = CommitWithReportFactory.create(
+ message="this is a commit message for parent",
+ commitid="39a24eeb9a00f78e0fd91a091960eee86d415497",
+ repository=self.repo,
+ )
+ self.commit = CommitWithReportFactory.create(
+ message="this is a commit message for current",
+ commitid="fc02b87aac39d16a1626722004e3ec36d046e718",
+ parent_commit_id=self.parent_commit.commitid,
+ repository=self.repo,
+ )
+
+ self.comparison = CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ report_storage_path="v4/test.json",
+ )
+ self.comparison_report = ComparisonReport(self.comparison)
+
+ self.mock_git_compare_data = {
+ "commits": [],
+ "diff": {
+ "files": {
+ "fileA": {
+ "type": "modified",
+ "segments": [
+ {
+ "header": ["4", "43", "4", "3"],
+ "lines": ["", "", ""] + ["-this line is removed"] * 40,
+ }
+ ],
+ "stats": {"removed": 40, "added": 0},
+ "totals": ReportTotals.default_totals(),
+ }
+ }
+ },
+ }
+
+ self.src = b"first\nfirst\nfirst\nfirst\nfirst\nfirst"
+
+ self.mocked_compare_adapter = MockedComparisonAdapter(
+ self.mock_git_compare_data, self.src
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_impacted_files_200_found(
+ self, adapter_mock, read_file, build_report_from_commit
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ build_report_from_commit.return_value = sample_report_impacted()
+ read_file.return_value = mock_data_from_archive
+
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ query_params = {
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ }
+
+ response = self.client.get(
+ reverse("api-v2-compare-impacted-files", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+ data = response.data
+
+ assert response.status_code == status.HTTP_200_OK
+ assert data.get("base_commit") == "39a24eeb9a00f78e0fd91a091960eee86d415497"
+ assert data.get("head_commit") == "fc02b87aac39d16a1626722004e3ec36d046e718"
+ assert data["totals"]["head"]["hits"] == 7
+ assert data["totals"]["base"]["hits"] == 7
+ assert data["totals"]["patch"]["hits"] == 0
+ assert len(data["files"]) == 2
+ assert data["state"] == "processed"
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @patch("services.task.TaskService.compute_comparison")
+ @patch("api.shared.compare.serializers.ComparisonSerializer.get_files")
+ def test_impacted_files_200_not_found(
+ self,
+ mock_parent_get_files,
+ mock_task_service,
+ adapter_mock,
+ read_file,
+ build_report_from_commit,
+ ):
+ mock_parent_get_files.return_value = []
+ mock_task_service.return_value = None
+ adapter_mock.return_value = self.mocked_compare_adapter
+ build_report_from_commit.return_value = sample_report_impacted()
+ read_file.return_value = mock_data_from_archive
+
+ self.comparison.delete()
+
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ }
+ query_params = {
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ }
+
+ response = self.client.get(
+ reverse("api-v2-compare-impacted-files", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert not mock_parent_get_files.called
+ assert mock_task_service.called
+ assert response.data["files"] == []
+ assert response.data["state"] == "pending"
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_impacted_file_segment_found(
+ self, adapter_mock, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "file_path": "fileA",
+ }
+ query_params = {
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ }
+
+ response = self.client.get(
+ reverse("api-v2-compare-segments", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+ data = response.data
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(data["segments"]) == 1
+ assert data["segments"][0]["header"] == "-4,43 +4,3"
+ assert data["segments"][0]["has_unintended_changes"] == False
+ assert len(data["segments"][0]["lines"]) > 0
+
+ @patch("services.task.TaskService.compute_comparison")
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_impacted_file_segment_not_found(
+ self,
+ adapter_mock,
+ read_file,
+ mock_get_file_comparison,
+ mock_compare_validate,
+ mock_task_service,
+ ):
+ adapter_mock.return_value = self.mocked_compare_adapter
+ read_file.return_value = mock_data_from_archive
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ mock_task_service.return_value = None
+
+ self.comparison.delete()
+
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "file_path": "notarealfile",
+ }
+ query_params = {
+ "base": self.parent_commit.commitid,
+ "head": self.commit.commitid,
+ }
+
+ response = self.client.get(
+ reverse("api-v2-compare-segments", kwargs=kwargs),
+ data=query_params,
+ content_type="application/json",
+ )
+ data = response.data
+
+ assert response.status_code == status.HTTP_200_OK
+ assert data["segments"] == []
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_component_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_component_viewset.py
new file mode 100644
index 0000000000..8fc5e7e2dd
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_component_viewset.py
@@ -0,0 +1,134 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from services.components import Component
+from utils.test_utils import APIClient
+
+
+# Borrowed from ./test_file_report_viewset.py
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ # (1 * 5 hits + 0 * 3 misses) / 8 lines = 0.625 coverage
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ # (1 * 1 hit + 0 * 1 partial) / 2 lines = 0.5 coverage
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+def empty_report():
+ return Report()
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class ComponentViewSetTestCase(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.commit = CommitFactory(repository=self.repo)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _request_components(self):
+ url = reverse(
+ "api-v2-components-list",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ return self.client.get(url)
+
+ @patch("api.public.v2.component.views.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_component_list(
+ self, build_report_from_commit, commit_components, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [sample_report()]
+ commit_components.return_value = [
+ Component(
+ component_id="foo",
+ paths=[r".*foo"],
+ name="Foo",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ component_id="bar",
+ paths=[r".*bar"],
+ name="Bar",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+
+ res = self._request_components()
+ commit_components.assert_called_once_with(self.commit, self.org)
+ assert res.status_code == 200
+ assert res.json() == [
+ {"component_id": "foo", "name": "Foo", "coverage": 62.5},
+ {"component_id": "bar", "name": "Bar", "coverage": 50.0},
+ ]
+
+ @patch("api.public.v2.component.views.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_component_list_no_coverage(
+ self, build_report_from_commit, commit_components, get_repo_permissions
+ ) -> None:
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [empty_report()]
+ commit_components.return_value = [
+ Component(
+ component_id="foo",
+ paths=[r".*foo"],
+ name="Foo",
+ flag_regexes=[],
+ statuses=[],
+ )
+ ]
+
+ res = self._request_components()
+ commit_components.assert_called_once_with(self.commit, self.org)
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "component_id": "foo",
+ "name": "Foo",
+ "coverage": None,
+ }
+ ]
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_coverage_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_coverage_viewset.py
new file mode 100644
index 0000000000..71a56f0b20
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_coverage_viewset.py
@@ -0,0 +1,299 @@
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from reports.tests.factories import RepositoryFlagFactory
+from timeseries.models import MeasurementName
+from timeseries.tests.factories import DatasetFactory, MeasurementFactory
+from utils.test_utils import APIClient
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class CoverageViewSetTestCase(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_repo_coverage(self, get_repo_permissions, is_backfilled):
+ get_repo_permissions.return_value = (True, True)
+ is_backfilled.return_value = True
+
+ DatasetFactory(
+ repository_id=self.repo.pk,
+ name=MeasurementName.COVERAGE.value,
+ )
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="main",
+ value=80.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:13:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="main",
+ value=90.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-19T00:01:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="main",
+ value=100.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=9999,
+ measurable_id="9999",
+ branch="main",
+ value=10.0,
+ )
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/coverage?interval=1d&start_date=2022-08-18&end_date=2022-08-19"
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "timestamp": "2022-08-18T00:00:00Z",
+ "min": 80.0,
+ "max": 90.0,
+ "avg": 85.0,
+ },
+ {
+ "timestamp": "2022-08-19T00:00:00Z",
+ "min": 100.0,
+ "max": 100.0,
+ "avg": 100.0,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_repo_coverage_branch(self, get_repo_permissions, is_backfilled):
+ get_repo_permissions.return_value = (True, True)
+ is_backfilled.return_value = True
+
+ DatasetFactory(
+ repository_id=self.repo.pk,
+ name=MeasurementName.COVERAGE.value,
+ )
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="other",
+ value=80.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:13:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="main",
+ value=90.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-19T00:01:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch="other",
+ value=100.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=9999,
+ measurable_id="9999",
+ branch="other",
+ value=10.0,
+ )
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/coverage?interval=1d&start_date=2022-08-18&end_date=2022-08-19&branch=other"
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "timestamp": "2022-08-18T00:00:00Z",
+ "min": 80.0,
+ "max": 80.0,
+ "avg": 80.0,
+ },
+ {
+ "timestamp": "2022-08-19T00:00:00Z",
+ "min": 100.0,
+ "max": 100.0,
+ "avg": 100.0,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ def test_repo_coverage_no_interval(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/coverage"
+ )
+ assert response.status_code == 422
+
+ def test_repo_coverage_invalid_interval(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/coverage?interval=wrong"
+ )
+ assert response.status_code == 422
+
+ def test_flag_coverage(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ flag1 = RepositoryFlagFactory(
+ repository=self.repo,
+ flag_name="flag1",
+ )
+ flag2 = RepositoryFlagFactory(
+ repository=self.repo,
+ flag_name="flag1",
+ )
+
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-10T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag1.pk),
+ branch="main",
+ value=100.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag1.pk),
+ branch="main",
+ value=80.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-18T00:13:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag1.pk),
+ branch="main",
+ value=90.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-19T00:01:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag1.pk),
+ branch="main",
+ value=100.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-18T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag2.pk),
+ branch="main",
+ value=10.0,
+ )
+ MeasurementFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ timestamp="2022-08-20T00:12:00",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ measurable_id=str(flag1.pk),
+ branch="main",
+ value=100.0,
+ )
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/flags/{flag1.flag_name}/coverage?interval=1d&start_date=2022-08-18&end_date=2022-08-19"
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "timestamp": "2022-08-18T00:00:00Z",
+ "min": 80.0,
+ "max": 90.0,
+ "avg": 85.0,
+ },
+ {
+ "timestamp": "2022-08-19T00:00:00Z",
+ "min": 100.0,
+ "max": 100.0,
+ "avg": 100.0,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ def test_flag_coverage_missing_flag(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ response = self.client.get(
+ f"/api/v2/github/codecov/repos/{self.repo.name}/flags/wrong-flag/coverage?interval=1d&start_date=2022-08-18&end_date=2022-08-19"
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "count": 0,
+ "next": None,
+ "previous": None,
+ "results": [],
+ "total_pages": 1,
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_owner_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_owner_viewset.py
new file mode 100644
index 0000000000..d2339026b5
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_owner_viewset.py
@@ -0,0 +1,638 @@
+from datetime import timedelta
+
+from django.utils import timezone
+from rest_framework import status
+from rest_framework.exceptions import ErrorDetail
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, SessionFactory
+
+from codecov_auth.tests.factories import DjangoSessionFactory
+from utils.test_utils import APIClient
+
+
+class OwnerViewSetTests(APITestCase):
+ def _retrieve(self, kwargs):
+ return self.client.get(reverse("api-v2-owners-detail", kwargs=kwargs))
+
+ def test_retrieve_returns_owner_with_username(self):
+ owner = OwnerFactory()
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": owner.service,
+ "username": owner.username,
+ "name": owner.name,
+ }
+
+ def test_retrieve_returns_owner_with_period_username(self):
+ owner = OwnerFactory(username="codecov.test")
+ response = self._retrieve(
+ kwargs={"service": owner.service, "owner_username": owner.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": owner.service,
+ "username": owner.username,
+ "name": owner.name,
+ }
+
+ def test_retrieve_returns_404_if_no_matching_username(self):
+ response = self._retrieve(kwargs={"service": "github", "owner_username": "fff"})
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data == {
+ "detail": ErrorDetail(
+ string="No Owner matches the given query.", code="not_found"
+ )
+ }
+
+ def test_retrieve_owner_unknown_service_returns_404(self):
+ response = self._retrieve(
+ kwargs={"service": "not-real", "owner_username": "anything"}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data == {"detail": "Service not found: not-real"}
+
+
+class UserViewSetTests(APITestCase):
+ def _list(self, kwargs):
+ return self.client.get(reverse("api-v2-users-list", kwargs=kwargs))
+
+ def _detail(self, kwargs):
+ return self.client.get(reverse("api-v2-users-detail", kwargs=kwargs))
+
+ def _patch(self, kwargs, data):
+ return self.client.patch(
+ reverse("api-v2-users-detail", kwargs=kwargs), data=data
+ )
+
+ def setUp(self):
+ self.org = OwnerFactory(service="github")
+ self.current_owner = OwnerFactory(service="github", organizations=[self.org.pk])
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_list(self):
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "service": "github",
+ "username": self.current_owner.username,
+ "name": self.current_owner.name,
+ "activated": False,
+ "is_admin": False,
+ "email": self.current_owner.email,
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ def test_retrieve_by_username(self):
+ another_user = OwnerFactory(service="github", organizations=[self.org.pk])
+ response = self._detail(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ }
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_retrieve_by_ownerid(self):
+ another_user = OwnerFactory(service="github", organizations=[self.org.pk])
+ response = self._detail(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.ownerid,
+ }
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_retrieve_cannot_get_details_of_members_of_other_orgs(self):
+ another_org = OwnerFactory(service="github")
+ another_user = OwnerFactory(service="github", organizations=[another_org.pk])
+ kwargs = {
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ }
+ response = self._detail(kwargs=kwargs)
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ another_user.organizations.append(self.org.pk)
+ another_user.save()
+
+ response = self._detail(kwargs=kwargs)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_retrieve_cannot_get_details_if_not_member_of_org(self):
+ another_org = OwnerFactory(service="github")
+ another_user = OwnerFactory(service="github", organizations=[another_org.pk])
+ kwargs = {
+ "service": another_org.service,
+ "owner_username": another_org.username,
+ "user_username_or_ownerid": another_user.username,
+ }
+ response = self._detail(kwargs=kwargs)
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ self.current_owner.organizations.append(another_org.pk)
+ self.current_owner.save()
+
+ response = self._detail(kwargs=kwargs)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_update_activate_by_username(self):
+ another_user = OwnerFactory(service="github", organizations=[self.org.pk])
+
+ # Activate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Deactivate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_update_activate_by_ownerid(self):
+ another_user = OwnerFactory(service="github", organizations=[self.org.pk])
+
+ # Activate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.ownerid,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Deactivate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.ownerid,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_update_activate_unauthorized_members_of_other_orgs(self):
+ another_org = OwnerFactory(service="github")
+ another_user = OwnerFactory(service="github", organizations=[another_org.pk])
+
+ # Activate user - not allowed
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ # Deactivate user - not allowed
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ # Request allowed after user joins the org
+ another_user.organizations.append(self.org.pk)
+ another_user.save()
+
+ # Activate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Deactivate user
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_update_activate_unauthorized_not_member_of_org(self):
+ another_org = OwnerFactory(service="github")
+ another_user = OwnerFactory(service="github", organizations=[another_org.pk])
+
+ # Activate user - not allowed
+ response = self._patch(
+ kwargs={
+ "service": another_org.service,
+ "owner_username": another_org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ # Deactivate user - not allowed
+ response = self._patch(
+ kwargs={
+ "service": another_org.service,
+ "owner_username": another_org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ # Request owner now joins the other org and thus is allowed to activate/deactivate
+ self.current_owner.organizations.append(another_org.pk)
+ self.current_owner.save()
+
+ # Activate user
+ response = self._patch(
+ kwargs={
+ "service": another_org.service,
+ "owner_username": another_org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Deactivate user
+ response = self._patch(
+ kwargs={
+ "service": another_org.service,
+ "owner_username": another_org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ def test_update_activate_no_seats_left(self):
+ another_user = OwnerFactory(service="github", organizations=[self.org.pk])
+ another_user_2 = OwnerFactory(service="github", organizations=[self.org.pk])
+
+ # Activate user 1
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Activate user 2
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user_2.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert response.data == {
+ "detail": ErrorDetail(
+ string="Cannot activate user -- not enough seats left.",
+ code="no_seats_left",
+ )
+ }
+
+ # Deactivate user 1 to make room for user 2
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user.username,
+ },
+ data={"activated": False},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user.username,
+ "name": another_user.name,
+ "activated": False,
+ "is_admin": False,
+ "email": another_user.email,
+ }
+
+ # Activate user 2 now that there's room
+ response = self._patch(
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "user_username_or_ownerid": another_user_2.username,
+ },
+ data={"activated": True},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "service": "github",
+ "username": another_user_2.username,
+ "name": another_user_2.name,
+ "activated": True,
+ "is_admin": False,
+ "email": another_user_2.email,
+ }
+
+
+class UserSessionViewSetTests(APITestCase):
+ def _list(self, kwargs):
+ return self.client.get(reverse("api-v2-user-sessions-list", kwargs=kwargs))
+
+ def setUp(self):
+ self.org = OwnerFactory(service="github")
+ self.admin_owner = OwnerFactory(service="github", organizations=[self.org.pk])
+ self.org.admins = [self.admin_owner.pk]
+ self.org.save()
+ self.client = APIClient()
+
+ def test_not_part_of_org(self):
+ self.current_owner = OwnerFactory(service="github", organizations=[])
+ self.client.force_login_owner(self.current_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_not_admin_of_org(self):
+ self.not_in_org_owner = OwnerFactory(
+ service="github", organizations=[self.org.pk]
+ )
+ self.client.force_login_owner(self.not_in_org_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_no_sessions(self):
+ self.client.force_login_owner(self.admin_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "username": self.admin_owner.username,
+ "name": self.admin_owner.name,
+ "has_active_session": False,
+ "expiry_date": None,
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ def test_has_active_session(self):
+ expiry_date = timezone.now() + timedelta(days=1)
+ expiry_date_response = str(expiry_date).replace(" ", "T").replace("+00:00", "Z")
+
+ self.session = SessionFactory(
+ owner=self.admin_owner,
+ login_session=DjangoSessionFactory(expire_date=expiry_date),
+ )
+ self.client.force_login_owner(self.admin_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "username": self.admin_owner.username,
+ "name": self.admin_owner.name,
+ "has_active_session": True,
+ "expiry_date": expiry_date_response,
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ def test_multiple_sessions_one(self):
+ expiry_date = timezone.now() + timedelta(days=1)
+ expiry_date_response = str(expiry_date).replace(" ", "T").replace("+00:00", "Z")
+
+ self.session_1 = SessionFactory(
+ owner=self.admin_owner,
+ login_session=DjangoSessionFactory(expire_date=expiry_date),
+ )
+ self.session_2 = SessionFactory(
+ owner=self.admin_owner,
+ login_session=DjangoSessionFactory(
+ expire_date=timezone.now() - timedelta(days=1)
+ ),
+ )
+ self.client.force_login_owner(self.admin_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "username": self.admin_owner.username,
+ "name": self.admin_owner.name,
+ "has_active_session": True,
+ "expiry_date": expiry_date_response,
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ def test_multiple_sessions_two(self):
+ expiry_date = timezone.now()
+ expiry_date_response = str(expiry_date).replace(" ", "T").replace("+00:00", "Z")
+
+ self.session_1 = SessionFactory(
+ owner=self.admin_owner,
+ login_session=DjangoSessionFactory(expire_date=expiry_date),
+ )
+ self.session_2 = SessionFactory(
+ owner=self.admin_owner,
+ login_session=DjangoSessionFactory(
+ expire_date=timezone.now() - timedelta(days=1)
+ ),
+ )
+ self.client.force_login_owner(self.admin_owner)
+
+ response = self._list(
+ kwargs={"service": self.org.service, "owner_username": self.org.username}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "username": self.admin_owner.username,
+ "name": self.admin_owner.name,
+ "has_active_session": False,
+ "expiry_date": expiry_date_response,
+ }
+ ],
+ "total_pages": 1,
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_pull_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_pull_viewset.py
new file mode 100644
index 0000000000..a4eff3b1e3
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_pull_viewset.py
@@ -0,0 +1,418 @@
+from unittest.mock import MagicMock, patch
+
+from django.test import override_settings
+from django.urls import reverse
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from codecov.tests.base_test import InternalAPITest
+from core.models import Pull
+from utils.test_utils import APIClient
+
+
+@freeze_time("2022-01-01T00:00:00")
+class PullViewsetTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid],
+ organizations=[self.org.ownerid],
+ )
+ self.pulls = [
+ PullFactory(repository=self.repo),
+ PullFactory(repository=self.repo),
+ ]
+ Pull.objects.filter(pk=self.pulls[1].pk).update(
+ updatestamp="2023-01-01T00:00:00"
+ )
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+ self.no_patch_response = dict(hits=0, misses=0, partials=0, coverage=0.0)
+
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ def test_list(self, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "pullid": self.pulls[1].pullid,
+ "title": self.pulls[1].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2023-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ },
+ {
+ "pullid": self.pulls[0].pullid,
+ "title": self.pulls[0].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2022-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ def test_list_state(self, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ pull = PullFactory(repository=self.repo, state="closed")
+ url = reverse(
+ "api-v2-pulls-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(f"{url}?state=closed")
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "pullid": pull.pullid,
+ "title": pull.title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2022-01-01T00:00:00Z",
+ "state": "closed",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ def test_list_start_date(self, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ url = reverse(
+ "api-v2-pulls-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(f"{url}?start_date=2022-12-01")
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "pullid": self.pulls[1].pullid,
+ "title": self.pulls[1].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2023-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ def test_list_cursor_pagination(self, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ url = reverse(
+ "api-v2-pulls-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(f"{url}?page_size=1&cursor=")
+ assert res.status_code == 200
+ data = res.json()
+ assert data["results"] == [
+ {
+ "pullid": self.pulls[1].pullid,
+ "title": self.pulls[1].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2023-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+ ]
+ assert data["previous"] is None
+ assert data["next"] is not None
+
+ res = self.client.get(data["next"])
+ data = res.json()
+ assert data["results"] == [
+ {
+ "pullid": self.pulls[0].pullid,
+ "title": self.pulls[0].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2022-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+ ]
+ assert data["previous"] is not None
+ assert data["next"] is None
+
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+ def test_retrieve(self, get_repo_permissions, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ get_repo_permissions.return_value = (True, True)
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "pullid": self.pulls[0].pullid,
+ "title": self.pulls[0].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2022-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ @patch("api.shared.permissions.SuperTokenPermissions.has_permission")
+ def test_no_pull_if_unauthenticated_token_request(
+ self,
+ super_token_permissions_has_permission,
+ repository_artifact_permissions_has_permission,
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ super_token_permissions_has_permission.return_value = False
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ )
+ )
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_pull_if_not_super_token_nor_user_token(
+ self, repository_artifact_permissions_has_permission
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer 73c8d301-2e0b-42c0-9ace-95eef6b68e86",
+ )
+ assert res.status_code == 401
+ assert res.data["detail"] == "Invalid token."
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_pull_if_super_token_but_no_GET_request(
+ self, repository_artifact_permissions_has_permission
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self.client.post(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer testaxs3o76rdcdpfzexuccx3uatui2nw73r",
+ )
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
+ def test_pull_with_valid_super_token(self, mock_patch):
+ mock_patch.return_value = self.no_patch_response
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer testaxs3o76rdcdpfzexuccx3uatui2nw73r",
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "pullid": self.pulls[0].pullid,
+ "title": self.pulls[0].title,
+ "base_totals": None,
+ "head_totals": None,
+ "updatestamp": "2022-01-01T00:00:00Z",
+ "state": "open",
+ "ci_passed": None,
+ "author": None,
+ "patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
+ }
+
+ @patch("api.public.v2.pull.serializers.ComparisonReport")
+ @patch("services.comparison.CommitComparison.objects.filter")
+ def test_retrieve_with_patch_coverage(self, mock_cc_filter, mock_comparison_report):
+ mock_cc_instance = MagicMock(is_processed=True)
+ mock_cc_filter.return_value.select_related.return_value.first.return_value = (
+ mock_cc_instance
+ )
+
+ mock_file = MagicMock()
+ mock_file.patch_coverage.hits = 10
+ mock_file.patch_coverage.misses = 5
+ mock_file.patch_coverage.partials = 2
+ mock_comparison_report.return_value.impacted_files = [mock_file]
+
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ )
+ )
+ assert res.status_code == 200
+ data = res.json()
+ assert data["patch"] == {
+ "hits": 10,
+ "misses": 5,
+ "partials": 2,
+ "coverage": 58.82,
+ }
+
+ @patch("api.public.v2.pull.serializers.ComparisonReport")
+ @patch("services.comparison.CommitComparison.objects.filter")
+ def test_retrieve_with_patch_coverage_no_branches(
+ self, mock_cc_filter, mock_comparison_report
+ ):
+ mock_cc_instance = MagicMock(is_processed=True)
+ mock_cc_filter.return_value.select_related.return_value.first.return_value = (
+ mock_cc_instance
+ )
+
+ mock_file = MagicMock()
+ mock_file.patch_coverage.hits = 0
+ mock_file.patch_coverage.misses = 0
+ mock_file.patch_coverage.partials = 0
+ mock_comparison_report.return_value.impacted_files = [mock_file]
+
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ )
+ )
+ assert res.status_code == 200
+ data = res.json()
+ assert data["patch"] == self.no_patch_response
+
+ @patch("api.public.v2.pull.serializers.ComparisonReport")
+ @patch("services.comparison.CommitComparison.objects.filter")
+ def test_retrieve_with_patch_coverage_no_commit_comparison(
+ self, mock_cc_filter, mock_comparison_report
+ ):
+ mock_cc_instance = MagicMock(is_processed=False)
+ mock_cc_filter.return_value.select_related.return_value.first.return_value = (
+ mock_cc_instance
+ )
+
+ mock_file = MagicMock()
+ mock_file.patch_coverage.hits = 0
+ mock_file.patch_coverage.misses = 0
+ mock_file.patch_coverage.partials = 0
+ mock_comparison_report.return_value.impacted_files = [mock_file]
+
+ res = self.client.get(
+ reverse(
+ "api-v2-pulls-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pullid": self.pulls[0].pullid,
+ },
+ )
+ )
+ assert res.status_code == 200
+ data = res.json()
+ assert data["patch"] is None
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_repo_config.py b/apps/codecov-api/api/public/v2/tests/test_api_repo_config.py
new file mode 100644
index 0000000000..abfcc30fc0
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_repo_config.py
@@ -0,0 +1,62 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov.tests.base_test import InternalAPITest
+from utils.test_utils import APIClient
+
+
+@freeze_time("2022-01-01T00:00:00")
+class RepoConfigViewTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid], organizations=[self.org.ownerid]
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+ def test_get(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ res = self.client.get(
+ reverse(
+ "api-v2-repo-config",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "upload_token": self.repo.upload_token,
+ "graph_token": self.repo.image_token,
+ }
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+ def test_get_no_part_of_org(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ self.current_owner.organizations = []
+ self.current_owner.save()
+
+ res = self.client.get(
+ reverse(
+ "api-v2-repo-config",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert res.status_code == 403
+ assert self.repo.upload_token not in str(res.content)
+ assert self.repo.image_token not in str(res.content)
diff --git a/apps/codecov-api/api/public/v2/tests/test_api_repo_viewset.py b/apps/codecov-api/api/public/v2/tests/test_api_repo_viewset.py
new file mode 100644
index 0000000000..10579207c8
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_api_repo_viewset.py
@@ -0,0 +1,134 @@
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.urls import reverse
+from django.utils import timezone
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from codecov.tests.base_test import InternalAPITest
+from utils.test_utils import APIClient
+
+
+@freeze_time("2022-01-01T00:00:00")
+class RepoViewsetTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.commit = CommitFactory(
+ repository=self.repo, timestamp=timezone.now() - timedelta(days=1)
+ )
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid], organizations=[self.org.ownerid]
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_list(self):
+ res = self.client.get(
+ reverse(
+ "api-v2-repos-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ },
+ )
+ )
+ assert res.status_code == 200
+ data = res.json()
+
+ # there's a SQL trigger that updates this - not sure how to test the value
+ assert data["results"][0]["updatestamp"] is not None
+ del data["results"][0]["updatestamp"]
+
+ assert data == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "name": self.repo.name,
+ "private": True,
+ "author": {
+ "service": self.org.service,
+ "username": self.org.username,
+ "name": self.org.name,
+ },
+ "language": self.repo.language,
+ "branch": "main",
+ "active": False,
+ "activated": False,
+ "totals": {
+ "branches": 0,
+ "complexity": 0.0,
+ "complexity_ratio": 0,
+ "complexity_total": 0.0,
+ "coverage": 85.0,
+ "diff": 0,
+ "files": 3,
+ "hits": 17,
+ "lines": 20,
+ "methods": 0,
+ "misses": 3,
+ "partials": 0,
+ "sessions": 1,
+ },
+ }
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+ def test_retrieve(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ res = self.client.get(
+ reverse(
+ "api-v2-repos-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ )
+ assert res.status_code == 200
+ data = res.json()
+
+ # there's a SQL trigger that updates this - not sure how to test the value
+ assert data["updatestamp"] is not None
+ del data["updatestamp"]
+
+ assert data == {
+ "name": self.repo.name,
+ "private": True,
+ "author": {
+ "service": self.org.service,
+ "username": self.org.username,
+ "name": self.org.name,
+ },
+ "language": self.repo.language,
+ "branch": "main",
+ "active": False,
+ "activated": False,
+ "totals": {
+ "branches": 0,
+ "complexity": 0.0,
+ "complexity_ratio": 0,
+ "complexity_total": 0.0,
+ "coverage": 85.0,
+ "diff": 0,
+ "files": 3,
+ "hits": 17,
+ "lines": 20,
+ "methods": 0,
+ "misses": 3,
+ "partials": 0,
+ "sessions": 1,
+ },
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_file_report_viewset.py b/apps/codecov-api/api/public/v2/tests/test_file_report_viewset.py
new file mode 100644
index 0000000000..836637d826
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_file_report_viewset.py
@@ -0,0 +1,362 @@
+from unittest.mock import call, patch
+from urllib.parse import urlencode
+
+from django.conf import settings
+from django.test import TestCase
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from core.models import Branch
+from utils.test_utils import APIClient
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class FileReportViewSetTestCase(TestCase):
+ def setUp(self):
+ self.service = "github"
+ self.username = "codecov"
+ self.repo_name = "test-repo"
+ self.org = OwnerFactory(username=self.username, service=self.service)
+ self.repo = RepositoryFactory(author=self.org, name=self.repo_name, active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+ self.commit1 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ )
+ self.commit2 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ parent_commit_id=self.commit1.commitid,
+ )
+ self.commit3 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ parent_commit_id=self.commit2.commitid,
+ )
+ self.branch = Branch.objects.get(repository=self.repo, name=self.repo.branch)
+ self.branch.head = self.commit3.commitid
+ self.branch.save()
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _request_file_report(self, path=None, **params):
+ url = reverse(
+ "api-v2-file-report-detail",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "path": path,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+ return self.client.get(url)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py")
+ assert res.status_code == 200
+ assert res.json() == {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ "commit_sha": self.commit3.commitid,
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit3.commitid}/blob/foo/file1.py",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit3)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_no_walk_back(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py")
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_called_once_with(self.commit3)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_not_enough_walk_back(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, None, sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=1)
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_with_walk_back(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, None, sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=2)
+ assert res.status_code == 200
+ assert res.json() == {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ "commit_sha": self.commit1.commitid,
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/blob/foo/file1.py",
+ }
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2), call(self.commit1)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_with_walk_back_oldest_sha(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, None, sample_report()]
+
+ res = self._request_file_report(
+ path="foo/file1.py", walk_back=2, oldest_sha=self.commit2.commitid
+ )
+ assert res.status_code == 404
+
+ # does not walk back to commit1
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_large_walk_back(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=21)
+ assert res.status_code == 400
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_walk_back_no_parent(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, None, None]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=20)
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2), call(self.commit1)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_walk_back_commit_not_found(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, None, None]
+
+ self.commit3.parent_commit_id = "wrong"
+ self.commit3.save()
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=20)
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_has_calls([call(self.commit3)])
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_walk_back_commit_not_complete(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+
+ self.commit1.state = "pending"
+ self.commit1.save()
+
+ build_report_from_commit.side_effect = [
+ sample_report(), # skips since the state is pending
+ None, # skips since there's no report
+ sample_report(), # found
+ ]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=20)
+ assert res.status_code == 200
+ assert res.json() == {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ "commit_sha": self.commit3.commitid,
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit3.commitid}/blob/foo/file1.py",
+ }
+
+ build_report_from_commit.assert_has_calls([call(self.commit3)])
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_walk_back_found(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [None, sample_report(), sample_report()]
+
+ res = self._request_file_report(path="foo/file1.py", walk_back=20)
+ assert res.status_code == 200
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_missing_file(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [
+ sample_report(),
+ sample_report(),
+ sample_report(),
+ ]
+
+ res = self._request_file_report(path="bar/file1.py", walk_back=20)
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit3), call(self.commit2), call(self.commit1)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_file_report_missing_parent_commit(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.side_effect = [
+ sample_report(),
+ sample_report(),
+ sample_report(),
+ ]
+
+ self.commit3.parent_commit_id = None
+ self.commit3.save()
+
+ res = self._request_file_report(path="bar/file1.py", walk_back=20)
+ assert res.status_code == 404
+
+ build_report_from_commit.assert_has_calls([call(self.commit3)])
diff --git a/apps/codecov-api/api/public/v2/tests/test_flag_viewset.py b/apps/codecov-api/api/public/v2/tests/test_flag_viewset.py
new file mode 100644
index 0000000000..ae11dafc45
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_flag_viewset.py
@@ -0,0 +1,133 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from reports.tests.factories import RepositoryFlagFactory
+from utils.test_utils import APIClient
+
+
+def flags_report():
+ report = Report()
+ session_a_id, _ = report.add_session(Session(flags=["foo"]))
+ session_b_id, _ = report.add_session(Session(flags=["bar"]))
+
+ file_a = ReportFile("foo/file1.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(2, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(3, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(5, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(6, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(8, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(9, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(10, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ report.append(file_a)
+
+ file_b = ReportFile("bar/file2.py")
+ file_b.append(12, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ file_b.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[session_b_id, 2]])
+ )
+ report.append(file_b)
+
+ return report
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class FlagViewSetTestCase(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+ self.flag1 = RepositoryFlagFactory(flag_name="foo", repository=self.repo)
+ self.flag2 = RepositoryFlagFactory(flag_name="bar", repository=self.repo)
+ self.flag2 = RepositoryFlagFactory(flag_name="baz")
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _request_flags(self):
+ url = reverse(
+ "api-v2-flags-list",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ return self.client.get(url)
+
+ def test_flag_list_no_commit(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+
+ res = self._request_flags()
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {"flag_name": "foo", "coverage": None},
+ {"flag_name": "bar", "coverage": None},
+ ],
+ "total_pages": 1,
+ }
+
+ def test_flag_list_no_report(self, get_repo_permissions):
+ CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ branch=self.repo.branch,
+ )
+ get_repo_permissions.return_value = (True, True)
+
+ res = self._request_flags()
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {"flag_name": "foo", "coverage": None},
+ {"flag_name": "bar", "coverage": None},
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_flag_list_with_coverage(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ branch=self.repo.branch,
+ )
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = flags_report()
+
+ res = self._request_flags()
+ assert res.status_code == 200
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {"flag_name": "foo", "coverage": 62.5},
+ {"flag_name": "bar", "coverage": 100},
+ ],
+ "total_pages": 1,
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_owners_view.py b/apps/codecov-api/api/public/v2/tests/test_owners_view.py
new file mode 100644
index 0000000000..6870d69f65
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_owners_view.py
@@ -0,0 +1,49 @@
+from django.test import TestCase
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from utils.test_utils import APIClient
+
+
+class OwnersViewTestCase(TestCase):
+ def setUp(self):
+ self.service = "github"
+ self.org1 = OwnerFactory(username="org1", service=self.service)
+ self.org2 = OwnerFactory(username="org2", service=self.service)
+ self.org3 = OwnerFactory(username="org3", service=self.service)
+
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org1.pk, self.org2.pk],
+ )
+
+ def _request_owners(self, service="github", login=True):
+ if login:
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+ url = reverse(
+ "api-v2-service-owners",
+ kwargs={
+ "service": service,
+ },
+ )
+
+ return self.client.get(url)
+
+ def test_owners_list(self):
+ res = self._request_owners()
+ assert res.status_code == 200
+ assert [item["username"] for item in res.json()["results"]] == [
+ "org1",
+ "org2",
+ "codecov-user",
+ ]
+
+ def test_owners_list_invalid_service(self):
+ res = self._request_owners(service="unknown")
+ assert res.status_code == 404
+
+ def test_owners_list_unauthenticated(self):
+ res = self._request_owners(login=False)
+ assert res.status_code == 401
diff --git a/apps/codecov-api/api/public/v2/tests/test_report_tree.py b/apps/codecov-api/api/public/v2/tests/test_report_tree.py
new file mode 100644
index 0000000000..84b4051de3
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_report_tree.py
@@ -0,0 +1,188 @@
+from unittest.mock import patch
+from urllib.parse import urlencode
+
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from utils.test_utils import APIClient
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ third_file = ReportFile("file3.py")
+ third_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.append(third_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+class ReportTreeTests(APITestCase):
+ def _tree(self, **params):
+ url = reverse(
+ "api-v2-report-tree",
+ kwargs={
+ "service": self.current_owner.service,
+ "owner_username": self.current_owner.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+
+ return self.client.get(url)
+
+ def setUp(self):
+ self.current_owner = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.current_owner)
+ self.commit = CommitFactory(
+ author=self.current_owner,
+ repository=self.repo,
+ )
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree()
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "foo",
+ "full_path": "foo",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ },
+ {
+ "name": "bar",
+ "full_path": "bar",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ },
+ {
+ "name": "file3.py",
+ "full_path": "file3.py",
+ "coverage": 100.0,
+ "lines": 1,
+ "hits": 1,
+ "partials": 0,
+ "misses": 0,
+ },
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_depth(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(depth=2)
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "foo",
+ "full_path": "foo",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ "children": [
+ {
+ "name": "file1.py",
+ "full_path": "foo/file1.py",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ }
+ ],
+ },
+ {
+ "name": "bar",
+ "full_path": "bar",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ "children": [
+ {
+ "name": "file2.py",
+ "full_path": "bar/file2.py",
+ "coverage": 50.0,
+ "lines": 2,
+ "hits": 1,
+ "partials": 1,
+ "misses": 0,
+ }
+ ],
+ },
+ {
+ "name": "file3.py",
+ "full_path": "file3.py",
+ "coverage": 100.0,
+ "lines": 1,
+ "hits": 1,
+ "partials": 0,
+ "misses": 0,
+ },
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_tree_path(self, build_report_from_commit):
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._tree(path="foo")
+ assert res.status_code == 200
+ assert res.json() == [
+ {
+ "name": "file1.py",
+ "full_path": "foo/file1.py",
+ "coverage": 62.5,
+ "lines": 8,
+ "hits": 5,
+ "partials": 0,
+ "misses": 3,
+ }
+ ]
+
+ build_report_from_commit.assert_called_once_with(self.commit)
diff --git a/apps/codecov-api/api/public/v2/tests/test_report_viewset.py b/apps/codecov-api/api/public/v2/tests/test_report_viewset.py
new file mode 100644
index 0000000000..f6ef37c987
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_report_viewset.py
@@ -0,0 +1,1126 @@
+from unittest.mock import call, patch
+from urllib.parse import urlencode
+
+from django.conf import settings
+from django.test import TestCase, override_settings
+from rest_framework.reverse import reverse
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ UserTokenFactory,
+)
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from services.components import Component
+from utils.test_utils import APIClient
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+def sample_report_2():
+ report = Report()
+ first_file = ReportFile("App/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ second_file = ReportFile("AppOld/file2.py")
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ return report
+
+
+def flags_report():
+ report = Report()
+ session_a_id, _ = report.add_session(Session(flags=["flag-a"]))
+ session_b_id, _ = report.add_session(Session(flags=["flag-b"]))
+
+ file_a = ReportFile("foo/file1.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(2, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(3, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(5, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(6, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(8, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(9, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(10, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ report.append(file_a)
+
+ file_b = ReportFile("bar/file2.py")
+ file_b.append(12, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ file_b.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[session_b_id, 2]])
+ )
+ report.append(file_b)
+
+ return report
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class ReportViewSetTestCase(TestCase):
+ def setUp(self):
+ self.service = "github"
+ self.username = "codecov"
+ self.repo_name = "test-repo"
+ self.org = OwnerFactory(username=self.username, service=self.service)
+ self.repo = RepositoryFactory(author=self.org, name=self.repo_name, active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+ # the order in which these commits are created matters
+ # because the branch head is the one that is created
+ # later
+ self.commit1 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ )
+ self.commit2 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ )
+ self.branch = BranchFactory(repository=self.repo, name="test-branch")
+
+ self.commit3 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ branch=self.branch.name,
+ )
+ self.branch.head = self.commit3.commitid
+ self.branch.save()
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _request_report(self, user_token=None, **params):
+ if user_token:
+ self.client.logout()
+
+ url = reverse(
+ "api-v2-report-detail",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+ return (
+ self.client.get(url, HTTP_AUTHORIZATION=f"Bearer {user_token}")
+ if user_token
+ else self.client.get(url)
+ )
+
+ def _post_report(self, user_token=None, **params):
+ if user_token:
+ self.client.logout()
+
+ url = reverse(
+ "api-v2-report-detail",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+ return (
+ self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {user_token}")
+ if user_token
+ else self.client.post(url)
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report()
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_commit_sha(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(sha=self.commit2.commitid)
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit2.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit2)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_nonexistent_commit_sha(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ sha = "aSD*FAJ#GVUAJS-random-sha"
+ res = self._request_report(sha=sha)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"The commit {sha} is not in our records. Please specify valid commit."
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_missing_report(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = None
+
+ res = self._request_report()
+ assert res.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_branch(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(branch="test-branch")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit3.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit3)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_nonexistent_branch(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ branch = "random-nonexistent-branch-aaa"
+ res = self._request_report(branch=branch)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"The branch '{branch}' in not in our records. Please provide a valid branch name."
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_path(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(path="bar")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/bar",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+ assert res.status_code == 200
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_path_regex_filter(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report_2()
+
+ # Report has files App/file1.py and AppOld/file2.py
+
+ # Only match App/file1.py
+ res = self._request_report(path="App/")
+ assert res.status_code == 200
+ data = res.json()
+ assert data["totals"]["files"] == 1
+ assert data["files"][0]["name"] == "App/file1.py"
+
+ # Match both files
+ res = self._request_report(path="App")
+ assert res.status_code == 200
+ data = res.json()
+ assert data["totals"]["files"] == 2
+ assert data["files"][0]["name"] in ["App/file1.py", "AppOld/file2.py"]
+
+ # Match no files
+ res = self._request_report(path="Apps")
+ assert res.status_code == 404
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_invalid_path(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+ path = "random-path-that-doesnt-exist-1234"
+
+ res = self._request_report(path=path)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"No files or directories found matching path: {path}"
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_flag(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = flags_report()
+
+ res = self._request_report(flag="flag-a")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ res = self._request_report(flag="flag-b")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 2,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 100.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 2,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 100.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 0]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit1), call(self.commit1)]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_flag_and_path(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = flags_report()
+
+ res = self._request_report(flag="flag-a", path="foo")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/foo",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ @patch("api.shared.permissions.SuperTokenPermissions.has_permission")
+ def test_no_report_if_unauthenticated_token_request(
+ self,
+ super_token_permissions_has_permission,
+ repository_artifact_permissions_has_permission,
+ _,
+ ):
+ super_token_permissions_has_permission.return_value = False
+ repository_artifact_permissions_has_permission.return_value = False
+
+ res = self._request_report()
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_report_if_not_super_token_nor_user_token(
+ self, repository_artifact_permissions_has_permission, _
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self._request_report("73c8d301-2e0b-42c0-9ace-95eef6b68e86")
+ assert res.status_code == 401
+ assert res.data["detail"] == "Invalid token."
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_report_if_super_token_but_no_GET_request(
+ self, repository_artifact_permissions_has_permission, _
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self._post_report("testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_super_token_permission_success(
+ self,
+ build_report_from_commit,
+ _,
+ ):
+ build_report_from_commit.return_value = sample_report()
+ res = self._request_report("testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryPermissionsService.user_is_activated")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_success_if_token_is_not_super_but_is_user_token(
+ self, build_report_from_commit, mock_is_user_activated, get_repo_permissions
+ ):
+ build_report_from_commit.return_value = sample_report()
+ mock_is_user_activated.return_value = True
+ get_repo_permissions.return_value = (True, True)
+ user_token = UserTokenFactory(
+ owner=self.current_owner,
+ )
+ res = self._request_report(user_token.token)
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("api.public.v2.report.views.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_component(
+ self, build_report_from_commit, commit_components, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ commit_components.return_value = [
+ Component(
+ component_id="foo",
+ paths=[r"^foo/.+"],
+ name="Foo",
+ flag_regexes=["flag1"],
+ statuses=[],
+ ),
+ Component(
+ component_id="bar",
+ paths=[r"^bar/.+"],
+ name="Bar",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+ build_report_from_commit.return_value = flags_report()
+
+ res = self._request_report(component_id="foo")
+ commit_components.assert_called_once_with(self.commit1, self.org)
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 2,
+ "complexity": 0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [
+ [1, 0],
+ [2, 1],
+ [3, 0],
+ [5, 0],
+ [6, 1],
+ [8, 0],
+ [9, 0],
+ [10, 1],
+ ],
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ res = self._request_report(component_id="bar")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 2,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "line_coverage": [[12, 0], [51, 2]],
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ res = self._request_report(component_id="invalid")
+ assert res.status_code == 404
+
+ res = self._request_report(component_id="foo", path="bar")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/bar",
+ }
+
+ res = self._request_report(component_id="foo", flag="flag-b")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_test_results_view.py b/apps/codecov-api/api/public/v2/tests/test_test_results_view.py
new file mode 100644
index 0000000000..aec5bcc6a7
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_test_results_view.py
@@ -0,0 +1,229 @@
+from unittest.mock import patch
+
+from django.test import override_settings
+from django.urls import reverse
+from freezegun import freeze_time
+from rest_framework import status
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov.tests.base_test import InternalAPITest
+from reports.tests.factories import TestInstanceFactory
+from utils.test_utils import APIClient
+
+
+@freeze_time("2022-01-01T00:00:00")
+class TestResultsViewsetTests(InternalAPITest):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.current_owner = OwnerFactory(
+ permission=[self.repo.repoid], organizations=[self.org.ownerid]
+ )
+ self.test_instances = [
+ TestInstanceFactory(repoid=self.repo.repoid, commitid="1234"),
+ TestInstanceFactory(repoid=self.repo.repoid, commitid="3456"),
+ ]
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def test_list(self):
+ url = reverse(
+ "api-v2-tests-results-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(url)
+ assert res.status_code == status.HTTP_200_OK
+ assert res.json() == {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "id": self.test_instances[0].id,
+ "name": self.test_instances[0].test.name,
+ "test_id": self.test_instances[0].test_id,
+ "failure_message": self.test_instances[0].failure_message,
+ "duration_seconds": self.test_instances[0].duration_seconds,
+ "commitid": self.test_instances[0].commitid,
+ "outcome": self.test_instances[0].outcome,
+ "branch": self.test_instances[0].branch,
+ "repoid": self.test_instances[0].repoid,
+ },
+ {
+ "id": self.test_instances[1].id,
+ "name": self.test_instances[1].test.name,
+ "test_id": self.test_instances[1].test_id,
+ "failure_message": self.test_instances[1].failure_message,
+ "duration_seconds": self.test_instances[1].duration_seconds,
+ "commitid": self.test_instances[1].commitid,
+ "outcome": self.test_instances[1].outcome,
+ "branch": self.test_instances[1].branch,
+ "repoid": self.test_instances[1].repoid,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ def test_list_filters(self):
+ url = reverse(
+ "api-v2-tests-results-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(f"{url}?commit_id={self.test_instances[0].commitid}")
+ assert res.status_code == status.HTTP_200_OK
+ assert res.json() == {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "id": self.test_instances[0].id,
+ "name": self.test_instances[0].test.name,
+ "test_id": self.test_instances[0].test_id,
+ "failure_message": self.test_instances[0].failure_message,
+ "duration_seconds": self.test_instances[0].duration_seconds,
+ "commitid": self.test_instances[0].commitid,
+ "outcome": self.test_instances[0].outcome,
+ "branch": self.test_instances[0].branch,
+ "repoid": self.test_instances[0].repoid,
+ },
+ ],
+ "total_pages": 1,
+ }
+
+ @patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+ def test_retrieve(self, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ res = self.client.get(
+ reverse(
+ "api-v2-tests-results-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pk": self.test_instances[0].pk,
+ },
+ )
+ )
+ assert res.status_code == status.HTTP_200_OK
+ assert res.json() == {
+ "id": self.test_instances[0].id,
+ "name": self.test_instances[0].test.name,
+ "test_id": self.test_instances[0].test_id,
+ "failure_message": self.test_instances[0].failure_message,
+ "duration_seconds": self.test_instances[0].duration_seconds,
+ "commitid": self.test_instances[0].commitid,
+ "outcome": self.test_instances[0].outcome,
+ "branch": self.test_instances[0].branch,
+ "repoid": self.test_instances[0].repoid,
+ }
+
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ @patch("api.shared.permissions.SuperTokenPermissions.has_permission")
+ def test_no_test_result_if_unauthenticated_token_request(
+ self,
+ super_token_permissions_has_permission,
+ repository_artifact_permissions_has_permission,
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ super_token_permissions_has_permission.return_value = False
+
+ url = reverse(
+ "api-v2-tests-results-list",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+ res = self.client.get(f"{url}?commit_id={self.test_instances[0].commitid}")
+
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_result_if_not_super_token_nor_user_token(
+ self, repository_artifact_permissions_has_permission
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+
+ res = self.client.get(
+ reverse(
+ "api-v2-tests-results-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pk": self.test_instances[0].pk,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer 73c8d301-2e0b-42c0-9ace-95eef6b68e86",
+ )
+ assert res.status_code == 401
+ assert res.data["detail"] == "Invalid token."
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_result_if_super_token_but_no_GET_request(
+ self, repository_artifact_permissions_has_permission
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self.client.post(
+ reverse(
+ "api-v2-tests-results-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pk": self.test_instances[0].pk,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer testaxs3o76rdcdpfzexuccx3uatui2nw73r",
+ )
+ assert res.status_code == 403
+ assert (
+ res.data["detail"] == "You do not have permission to perform this action."
+ )
+
+ @override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_result_with_valid_super_token(
+ self, repository_artifact_permissions_has_permission
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+ res = self.client.get(
+ reverse(
+ "api-v2-tests-results-detail",
+ kwargs={
+ "service": self.org.service,
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ "pk": self.test_instances[0].pk,
+ },
+ ),
+ HTTP_AUTHORIZATION="Bearer testaxs3o76rdcdpfzexuccx3uatui2nw73r",
+ )
+ assert res.status_code == 200
+ assert res.json() == {
+ "id": self.test_instances[0].id,
+ "name": self.test_instances[0].test.name,
+ "test_id": self.test_instances[0].test_id,
+ "failure_message": self.test_instances[0].failure_message,
+ "duration_seconds": self.test_instances[0].duration_seconds,
+ "commitid": self.test_instances[0].commitid,
+ "outcome": self.test_instances[0].outcome,
+ "branch": self.test_instances[0].branch,
+ "repoid": self.test_instances[0].repoid,
+ }
diff --git a/apps/codecov-api/api/public/v2/tests/test_totals_viewset.py b/apps/codecov-api/api/public/v2/tests/test_totals_viewset.py
new file mode 100644
index 0000000000..96eae97a48
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/tests/test_totals_viewset.py
@@ -0,0 +1,712 @@
+from unittest.mock import call, patch
+from urllib.parse import urlencode
+
+from django.conf import settings
+from django.test import TestCase
+from rest_framework.reverse import reverse
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from services.components import Component
+from utils.test_utils import APIClient
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+def flags_report():
+ report = Report()
+ session_a_id, _ = report.add_session(Session(flags=["flag-a"]))
+ session_b_id, _ = report.add_session(Session(flags=["flag-b"]))
+
+ file_a = ReportFile("foo/file1.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(2, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(3, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(5, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(6, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ file_a.append(8, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(9, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ file_a.append(10, ReportLine.create(coverage=0, sessions=[[session_a_id, 0]]))
+ report.append(file_a)
+
+ file_b = ReportFile("bar/file2.py")
+ file_b.append(12, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ file_b.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[session_b_id, 2]])
+ )
+ report.append(file_b)
+
+ return report
+
+
+@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
+class TotalsViewSetTestCase(TestCase):
+ def setUp(self):
+ self.service = "github"
+ self.username = "codecov"
+ self.repo_name = "test-repo"
+ self.org = OwnerFactory(username=self.username, service=self.service)
+ self.repo = RepositoryFactory(author=self.org, name=self.repo_name, active=True)
+ self.current_owner = OwnerFactory(
+ username="codecov-user",
+ service="github",
+ organizations=[self.org.ownerid],
+ permission=[self.repo.repoid],
+ )
+ # the order in which these commits are created matters
+ # because the branch head is the one that is created
+ # later
+ self.commit1 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ )
+ self.commit2 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ )
+ self.branch = BranchFactory(repository=self.repo, name="test-branch")
+
+ self.commit3 = CommitFactory(
+ author=self.org,
+ repository=self.repo,
+ branch=self.branch.name,
+ )
+ self.branch.head = self.commit3.commitid
+ self.branch.save()
+
+ self.client = APIClient()
+ self.client.force_login_owner(self.current_owner)
+
+ def _request_report(self, user_token=None, **params):
+ if user_token:
+ self.client.logout()
+
+ url = reverse(
+ "api-v2-totals-detail",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+ return (
+ self.client.get(url, HTTP_AUTHORIZATION=f"Bearer {user_token}")
+ if user_token
+ else self.client.get(url)
+ )
+
+ def _post_report(self, user_token=None, **params):
+ if user_token:
+ self.client.logout()
+
+ url = reverse(
+ "api-v2-totals-detail",
+ kwargs={
+ "service": "github",
+ "owner_username": self.org.username,
+ "repo_name": self.repo.name,
+ },
+ )
+
+ qs = urlencode(params)
+ url = f"{url}?{qs}"
+ return (
+ self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {user_token}")
+ if user_token
+ else self.client.post(url)
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report()
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_commit_sha(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(sha=self.commit2.commitid)
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit2.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit2)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_nonexistent_commit_sha(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ sha = "aSD*FAJ#GVUAJS-random-sha"
+ res = self._request_report(sha=sha)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"The commit {sha} is not in our records. Please specify valid commit."
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_branch(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(branch="test-branch")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": 60.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit3.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit3)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_nonexistent_branch(
+ self, build_report_from_commit, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ branch = "random-nonexistent-branch-aaa"
+ res = self._request_report(branch=branch)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"The branch '{branch}' in not in our records. Please provide a valid branch name."
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_path(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(path="bar")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/bar",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_invalid_path(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = sample_report()
+ path = "random-path-that-doesnt-exist-1234"
+
+ res = self._request_report(path=path)
+ assert res.status_code == 404
+ assert res.json() == {
+ "detail": f"No files or directories found matching path: {path}"
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_flag(self, build_report_from_commit, get_repo_permissions):
+ get_repo_permissions.return_value = (True, True)
+ build_report_from_commit.return_value = flags_report()
+
+ res = self._request_report(flag="flag-a")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_called_once_with(self.commit1)
+
+ res = self._request_report(flag="flag-b")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 2,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 100.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 0,
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 0,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 2,
+ "misses": 0,
+ "partials": 0,
+ "coverage": 100.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ },
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ build_report_from_commit.assert_has_calls(
+ [call(self.commit1), call(self.commit1)]
+ )
+
+ @patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
+ def test_no_report_if_unauthenticated_token_request(
+ self,
+ repository_artifact_permissions_has_permission,
+ _,
+ ):
+ repository_artifact_permissions_has_permission.return_value = False
+
+ res = self._request_report()
+ assert res.status_code == 403
+ assert (
+ res.data["detail"]
+ == "Permission denied: some possible reasons for this are (1) the user doesn't have permission to view the specific resource, (2) the organization has a per-user plan or (3) the user is trying to view a private repo but is not activated."
+ )
+
+ @patch("api.public.v2.report.views.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_report_component(
+ self, build_report_from_commit, commit_components, get_repo_permissions
+ ):
+ get_repo_permissions.return_value = (True, True)
+ commit_components.return_value = [
+ Component(
+ component_id="foo",
+ paths=[r"^foo/.+"],
+ name="Foo",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ component_id="bar",
+ paths=[r"^bar/.+"],
+ name="Bar",
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+ build_report_from_commit.return_value = sample_report()
+
+ res = self._request_report(component_id="foo")
+ commit_components.assert_called_once_with(self.commit1, self.org)
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "foo/file1.py",
+ "totals": {
+ "files": 0,
+ "lines": 8,
+ "hits": 5,
+ "misses": 3,
+ "partials": 0,
+ "coverage": 62.5,
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 10.0,
+ "complexity_total": 2.0,
+ "complexity_ratio": 500.0,
+ "diff": 0,
+ },
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ res = self._request_report(component_id="bar")
+ assert res.status_code == 200
+ assert res.json() == {
+ "totals": {
+ "files": 1,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ "files": [
+ {
+ "name": "bar/file2.py",
+ "totals": {
+ "files": 0,
+ "lines": 2,
+ "hits": 1,
+ "misses": 0,
+ "partials": 1,
+ "coverage": 50.0,
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 0,
+ "complexity": 0.0,
+ "complexity_total": 0.0,
+ "complexity_ratio": 0,
+ "diff": 0,
+ },
+ }
+ ],
+ "commit_file_url": f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.username}/{self.repo_name}/commit/{self.commit1.commitid}/tree/",
+ }
+
+ res = self._request_report(component_id="invalid")
+ assert res.status_code == 404
diff --git a/apps/codecov-api/api/public/v2/urls.py b/apps/codecov-api/api/public/v2/urls.py
new file mode 100644
index 0000000000..6f95197aab
--- /dev/null
+++ b/apps/codecov-api/api/public/v2/urls.py
@@ -0,0 +1,117 @@
+from django.conf import settings, urls
+from django.urls import include, path
+from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
+from rest_framework.exceptions import server_error
+
+from api.shared.error_views import not_found
+from utils.routers import OptionalTrailingSlashRouter, RetrieveUpdateDestroyRouter
+
+from .branch.views import BranchViewSet
+from .commit.views import CommitsUploadsViewSet, CommitsViewSet
+from .compare.views import CompareViewSet
+from .component.views import ComponentViewSet
+from .coverage.views import CoverageViewSet, FlagCoverageViewSet
+from .flag.views import FlagViewSet
+from .owner.views import OwnersViewSet, OwnerViewSet, UserSessionViewSet, UserViewSet
+from .pull.views import PullViewSet
+from .repo.views import RepositoryConfigView, RepositoryViewSet
+from .report.views import FileReportViewSet, ReportViewSet, TotalsViewSet
+from .test_results.views import TestResultsView
+
+urls.handler404 = not_found
+urls.handler500 = server_error
+
+owners_router = OptionalTrailingSlashRouter()
+owners_router.register(r"", OwnerViewSet, basename="api-v2-owners")
+
+owner_artifacts_router = OptionalTrailingSlashRouter()
+owner_artifacts_router.register(r"users", UserViewSet, basename="api-v2-users")
+owner_artifacts_router.register(
+ r"user-sessions", UserSessionViewSet, basename="api-v2-user-sessions"
+)
+
+repository_router = OptionalTrailingSlashRouter()
+repository_router.register(r"repos", RepositoryViewSet, basename="api-v2-repos")
+
+repository_artifacts_router = OptionalTrailingSlashRouter()
+repository_artifacts_router.register(r"pulls", PullViewSet, basename="api-v2-pulls")
+repository_artifacts_router.register(
+ r"commits", CommitsViewSet, basename="api-v2-commits"
+)
+repository_artifacts_router.register(
+ r"branches", BranchViewSet, basename="api-v2-branches"
+)
+repository_artifacts_router.register(r"flags", FlagViewSet, basename="api-v2-flags")
+repository_artifacts_router.register(
+ r"components", ComponentViewSet, basename="api-v2-components"
+)
+repository_artifacts_router.register(
+ r"test-results", TestResultsView, basename="api-v2-tests-results"
+)
+
+compare_router = RetrieveUpdateDestroyRouter()
+compare_router.register(r"compare", CompareViewSet, basename="api-v2-compare")
+
+coverage_router = OptionalTrailingSlashRouter()
+coverage_router.register(r"coverage", CoverageViewSet, basename="api-v2-coverage")
+
+flag_coverage_router = OptionalTrailingSlashRouter()
+flag_coverage_router.register(
+ r"coverage", FlagCoverageViewSet, basename="api-v2-flag-coverage"
+)
+
+totals_router = RetrieveUpdateDestroyRouter()
+totals_router.register(r"totals", TotalsViewSet, basename="api-v2-totals")
+
+report_router = RetrieveUpdateDestroyRouter()
+report_router.register(r"report", ReportViewSet, basename="api-v2-report")
+
+file_report_router = RetrieveUpdateDestroyRouter()
+file_report_router.register(
+ r"file_report/(?P.+)", FileReportViewSet, basename="api-v2-file-report"
+)
+
+service_prefix = "/"
+owner_prefix = "//"
+repo_prefix = "//repos//"
+flag_prefix = repo_prefix + "flags//"
+commit_prefix = repo_prefix + "commits//"
+
+urlpatterns = [
+ path(r"schema/", SpectacularAPIView.as_view(), name="api-v2-schema"),
+ path(
+ r"docs/",
+ SpectacularRedocView.as_view(url_name="api-v2-schema"),
+ name="api-v2-docs",
+ ),
+ path(
+ "/",
+ OwnersViewSet.as_view({"get": "list"}),
+ name="api-v2-service-owners",
+ ),
+ path(service_prefix, include(owners_router.urls)),
+ path(owner_prefix, include(owner_artifacts_router.urls)),
+ path(owner_prefix, include(repository_router.urls)),
+ path(repo_prefix, include(repository_artifacts_router.urls)),
+ path(
+ f"{repo_prefix}config/",
+ RepositoryConfigView.as_view(),
+ name="api-v2-repo-config",
+ ),
+ path(repo_prefix, include(compare_router.urls)),
+ path(repo_prefix, include(totals_router.urls)),
+ path(repo_prefix, include(report_router.urls)),
+ path(repo_prefix, include(file_report_router.urls)),
+ path(repo_prefix, include(coverage_router.urls)),
+ path(
+ f"{commit_prefix}uploads/",
+ CommitsUploadsViewSet.as_view({"get": "list"}),
+ name="api-v2-commits-uploads",
+ ),
+]
+
+if settings.TIMESERIES_ENABLED:
+ urlpatterns += [
+ path(repo_prefix, include(coverage_router.urls)),
+ path(flag_prefix, include(flag_coverage_router.urls)),
+ ]
diff --git a/apps/codecov-api/api/shared/branch/__init__.py b/apps/codecov-api/api/shared/branch/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/shared/branch/filters.py b/apps/codecov-api/api/shared/branch/filters.py
new file mode 100644
index 0000000000..c1bd4535ac
--- /dev/null
+++ b/apps/codecov-api/api/shared/branch/filters.py
@@ -0,0 +1,8 @@
+import django_filters
+
+
+class BranchFilters(django_filters.FilterSet):
+ author = django_filters.CharFilter(method="filter_author")
+
+ def filter_author(self, queryset, name, value):
+ return queryset.filter(authors__contains=[value])
diff --git a/apps/codecov-api/api/shared/branch/mixins.py b/apps/codecov-api/api/shared/branch/mixins.py
new file mode 100644
index 0000000000..f4bff840da
--- /dev/null
+++ b/apps/codecov-api/api/shared/branch/mixins.py
@@ -0,0 +1,19 @@
+from django.db.models import F
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework import filters, viewsets
+
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+
+from .filters import BranchFilters
+
+
+class BranchViewSetMixin(viewsets.GenericViewSet, RepoPropertyMixin):
+ filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
+ filterset_class = BranchFilters
+ ordering_fields = ("updatestamp", "name")
+ permission_classes = [RepositoryArtifactPermissions]
+ lookup_field = "name"
+
+ def get_queryset(self):
+ return self.repo.branches.order_by(F("updatestamp").desc(nulls_last=True))
diff --git a/apps/codecov-api/api/shared/commit/__init__.py b/apps/codecov-api/api/shared/commit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/shared/commit/filters.py b/apps/codecov-api/api/shared/commit/filters.py
new file mode 100644
index 0000000000..5b99b686ff
--- /dev/null
+++ b/apps/codecov-api/api/shared/commit/filters.py
@@ -0,0 +1,16 @@
+import django_filters
+
+from core.models import Commit
+
+
+class CommitFilters(django_filters.FilterSet):
+ branch = django_filters.CharFilter(
+ field_name="branch", method="filter_branch", label="branch name"
+ )
+
+ def filter_branch(self, queryset, name, value):
+ return queryset.filter(branch=value)
+
+ class Meta:
+ model = Commit
+ fields = ["branch"]
diff --git a/apps/codecov-api/api/shared/commit/mixins.py b/apps/codecov-api/api/shared/commit/mixins.py
new file mode 100644
index 0000000000..66b4df56f4
--- /dev/null
+++ b/apps/codecov-api/api/shared/commit/mixins.py
@@ -0,0 +1,24 @@
+from rest_framework import viewsets
+
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+
+from .filters import CommitFilters
+
+
+class CommitsViewSetMixin(
+ viewsets.GenericViewSet,
+ RepoPropertyMixin,
+):
+ filterset_class = CommitFilters
+ permission_classes = [RepositoryArtifactPermissions]
+ lookup_field = "commitid"
+
+ def get_queryset(self):
+ # We don't use the "report" field in this endpoint since it can be many MBs of JSON.
+ # Choosing not to fetch it for perf reasons.
+ return (
+ self.repo.commits.defer("_report")
+ .select_related("author")
+ .order_by("-timestamp")
+ )
diff --git a/apps/codecov-api/api/shared/commit/serializers.py b/apps/codecov-api/api/shared/commit/serializers.py
new file mode 100644
index 0000000000..35c3ae6ebf
--- /dev/null
+++ b/apps/codecov-api/api/shared/commit/serializers.py
@@ -0,0 +1,116 @@
+from rest_framework import serializers
+from shared.reports.resources import Report, ReportFile
+from shared.utils.merge import line_type
+
+from utils import round_decimals_down
+
+
+class BaseTotalsSerializer(serializers.Serializer):
+ files = serializers.IntegerField()
+ lines = serializers.IntegerField()
+ hits = serializers.IntegerField()
+ misses = serializers.IntegerField()
+ partials = serializers.IntegerField()
+ coverage = serializers.SerializerMethodField()
+ branches = serializers.IntegerField()
+ methods = serializers.IntegerField()
+
+ def get_coverage(self, totals) -> float:
+ if totals.coverage is not None:
+ return round_decimals_down(float(totals.coverage), 2)
+ return 0
+
+
+class PatchCoverageSerializer(serializers.Serializer):
+ hits = serializers.IntegerField()
+ misses = serializers.IntegerField()
+ partials = serializers.IntegerField()
+ coverage = serializers.FloatField()
+
+
+class CommitTotalsSerializer(BaseTotalsSerializer):
+ files = serializers.IntegerField(source="f")
+ lines = serializers.IntegerField(source="n")
+ hits = serializers.IntegerField(source="h")
+ misses = serializers.IntegerField(source="m")
+ partials = serializers.IntegerField(source="p")
+ coverage = serializers.SerializerMethodField()
+ branches = serializers.IntegerField(source="b")
+ methods = serializers.IntegerField(source="d")
+ sessions = serializers.IntegerField(source="s")
+ complexity = serializers.FloatField(source="C")
+ complexity_total = serializers.FloatField(source="N")
+ complexity_ratio = serializers.SerializerMethodField()
+ diff = serializers.SerializerMethodField(
+ label="Deprecated: this will always return 0. Please use comparison endpoint for diff totals instead."
+ )
+
+ def get_coverage(self, totals) -> float:
+ if totals.get("c") is None:
+ return None
+ else:
+ return round_decimals_down(float(totals["c"]), 2)
+
+ def get_complexity_ratio(self, totals) -> float:
+ return (
+ round_decimals_down((totals["C"] / totals["N"]) * 100, 2)
+ if totals["C"] and totals["N"]
+ else 0
+ )
+
+ def get_diff(self, totals) -> int:
+ # deprecated
+ # 0 is used as the default elsewhere in the system so we'll use that here as well (instead of null)
+ return 0
+
+
+class ReportTotalsSerializer(BaseTotalsSerializer):
+ messages = serializers.IntegerField()
+ sessions = serializers.IntegerField()
+ complexity = serializers.FloatField()
+ complexity_total = serializers.FloatField()
+ complexity_ratio = serializers.SerializerMethodField()
+ diff = serializers.JSONField()
+
+ def get_complexity_ratio(self, totals) -> float:
+ return (
+ round_decimals_down((totals.complexity / totals.complexity_total) * 100, 2)
+ if totals.complexity and totals.complexity_total
+ else 0
+ )
+
+
+class UploadTotalsSerializer(BaseTotalsSerializer):
+ pass
+
+
+class ReportFileSerializer(serializers.Serializer):
+ name = serializers.CharField(label="file path")
+ totals = ReportTotalsSerializer(label="coverage totals")
+ line_coverage = serializers.SerializerMethodField(
+ label="line-by-line coverage values"
+ )
+
+ def get_line_coverage(self, report_file: ReportFile) -> list:
+ if self.context.get("include_line_coverage"):
+ return [
+ (ln, line_type(report_line.coverage))
+ for ln, report_line in report_file.lines
+ ]
+
+ def to_representation(self, value):
+ res = super().to_representation(value)
+ if not self.context.get("include_line_coverage"):
+ del res["line_coverage"]
+ return res
+
+
+class ReportSerializer(serializers.Serializer):
+ totals = ReportTotalsSerializer(label="coverage totals")
+ files = serializers.SerializerMethodField(label="file specific coverage totals")
+
+ def get_files(self, report: Report) -> ReportFileSerializer:
+ return [
+ ReportFileSerializer(report.get(file), context=self.context).data
+ for file in report.files
+ ]
diff --git a/apps/codecov-api/api/shared/compare/__init__.py b/apps/codecov-api/api/shared/compare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/shared/compare/mixins.py b/apps/codecov-api/api/shared/compare/mixins.py
new file mode 100644
index 0000000000..a8e288fc2c
--- /dev/null
+++ b/apps/codecov-api/api/shared/compare/mixins.py
@@ -0,0 +1,156 @@
+import logging
+
+from rest_framework import viewsets
+from rest_framework.decorators import action
+from rest_framework.exceptions import NotFound
+from rest_framework.response import Response
+
+from api.shared.mixins import CompareSlugMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from compare.models import CommitComparison
+from services.comparison import (
+ CommitComparisonService,
+ Comparison,
+ MissingComparisonCommit,
+ MissingComparisonReport,
+ PullRequestComparison,
+)
+from services.decorators import torngit_safe
+from services.task import TaskService
+
+from .serializers import (
+ FileComparisonSerializer,
+ FlagComparisonSerializer,
+ ImpactedFilesComparisonSerializer,
+ ImpactedFileSegmentsSerializer,
+)
+
+log = logging.getLogger(__name__)
+
+
+class CompareViewSetMixin(CompareSlugMixin, viewsets.GenericViewSet):
+ permission_classes = [RepositoryArtifactPermissions]
+
+ def get_object(self) -> Comparison:
+ compare_data = self.get_compare_data()
+
+ if "pull" in compare_data:
+ comparison = PullRequestComparison(
+ user=self.request.current_owner,
+ pull=compare_data["pull"],
+ )
+ try:
+ # make sure we have a base and head commit
+ comparison.base_commit
+ comparison.head_commit
+ except MissingComparisonCommit:
+ raise NotFound("Sorry, we are missing a commit for that pull request.")
+ else:
+ comparison = Comparison(
+ user=self.request.current_owner,
+ base_commit=compare_data["base"],
+ head_commit=compare_data["head"],
+ )
+
+ return comparison
+
+ def get_or_create_commit_comparison(
+ self, comparison: Comparison
+ ) -> CommitComparison:
+ """
+ Retrieves the pre-computed CommitComparison
+ if not found will create one and return None
+ """
+ commit_comparison = CommitComparisonService.fetch_precomputed(
+ comparison.head_commit.repository_id,
+ [(comparison.base_commit.commitid, comparison.head_commit.commitid)],
+ )
+
+ # Can't use pre-computed impacted files from CommitComparison
+ # first trigger a Celery task to create a comparison for this commit pair for the future
+ if not commit_comparison:
+ new_comparison = CommitComparison(
+ base_commit=comparison.base_commit,
+ compare_commit=comparison.head_commit,
+ state=CommitComparison.CommitComparisonStates.PENDING,
+ )
+ new_comparison.save()
+ TaskService().compute_comparison(new_comparison.pk)
+ log.info(
+ "CommitComparison not found, creating and request to compute new entry"
+ )
+ return new_comparison
+ return commit_comparison[0]
+
+ @torngit_safe
+ def retrieve(self, request, *args, **kwargs):
+ comparison = self.get_object()
+
+ # Some checks here for pseudo-comparisons. Basically, when pseudo-comparing,
+ # we sometimes might need to tweak the base report
+ if isinstance(comparison, PullRequestComparison):
+ if comparison.pseudo_diff_adjusts_tracked_lines:
+ comparison.update_base_report_with_pseudo_diff()
+ serializer = self.get_serializer(comparison)
+
+ try:
+ return Response(serializer.data)
+ except MissingComparisonReport:
+ raise NotFound("Raw report not found for base or head reference.")
+
+ @action(
+ detail=False,
+ methods=["get"],
+ url_path="file/(?P.+)",
+ url_name="file",
+ )
+ @torngit_safe
+ def file(self, request, *args, **kwargs):
+ comparison = self.get_object()
+ file_path = file_path = kwargs.get("file_path")
+ if file_path not in comparison.head_report:
+ raise NotFound("File not found in head report.")
+ return Response(
+ FileComparisonSerializer(
+ comparison.get_file_comparison(
+ file_path, with_src=True, bypass_max_diff=True
+ )
+ ).data
+ )
+
+ @action(detail=False, methods=["get"])
+ @torngit_safe
+ def flags(self, request, *args, **kwargs):
+ comparison = self.get_object()
+ flags = [
+ comparison.flag_comparison(flag_name)
+ for flag_name in comparison.non_carried_forward_flags
+ ]
+ return Response(FlagComparisonSerializer(flags, many=True).data)
+
+ @action(detail=False, methods=["get"])
+ @torngit_safe
+ def impacted_files(self, request, *args, **kwargs):
+ comparison = self.get_object()
+ return Response(
+ ImpactedFilesComparisonSerializer(
+ comparison,
+ context={
+ "commit_comparison": self.get_or_create_commit_comparison(
+ comparison
+ )
+ },
+ ).data
+ )
+
+ @action(detail=False, methods=["get"])
+ @torngit_safe
+ def segments(self, request, *args, **kwargs):
+ file_path = file_path = kwargs.get("file_path")
+ comparison = self.get_object()
+
+ return Response(
+ ImpactedFileSegmentsSerializer(
+ file_path, context={"comparison": comparison}
+ ).data
+ )
diff --git a/apps/codecov-api/api/shared/compare/serializers.py b/apps/codecov-api/api/shared/compare/serializers.py
new file mode 100644
index 0000000000..162fd32387
--- /dev/null
+++ b/apps/codecov-api/api/shared/compare/serializers.py
@@ -0,0 +1,220 @@
+import dataclasses
+import logging
+from typing import List
+
+from rest_framework import serializers
+
+from api.internal.commit.serializers import CommitSerializer
+from api.shared.commit.serializers import ReportTotalsSerializer
+from services.comparison import (
+ Comparison,
+ ComparisonReport,
+ FileComparison,
+ ImpactedFile,
+ Segment,
+)
+
+log = logging.getLogger(__name__)
+
+
+class TotalsComparisonSerializer(serializers.Serializer):
+ base = ReportTotalsSerializer()
+ head = ReportTotalsSerializer()
+ patch = ReportTotalsSerializer(source="diff")
+
+
+class LineComparisonSerializer(serializers.Serializer):
+ value = serializers.CharField()
+ number = serializers.JSONField()
+ coverage = serializers.JSONField()
+ is_diff = serializers.BooleanField()
+ added = serializers.BooleanField()
+ removed = serializers.BooleanField()
+ sessions = serializers.IntegerField(source="hit_count")
+
+
+class FileComparisonSerializer(serializers.Serializer):
+ name = serializers.JSONField()
+ totals = TotalsComparisonSerializer()
+ has_diff = serializers.BooleanField()
+ stats = serializers.JSONField()
+ change_summary = serializers.JSONField()
+ lines = LineComparisonSerializer(many=True)
+
+
+class ComparisonSerializer(serializers.Serializer):
+ base_commit = serializers.CharField(source="base_commit.commitid")
+ head_commit = serializers.CharField(source="head_commit.commitid")
+ totals = TotalsComparisonSerializer()
+ commit_uploads = CommitSerializer(many=True, source="upload_commits")
+ diff = serializers.SerializerMethodField()
+ files = serializers.SerializerMethodField()
+ untracked = serializers.SerializerMethodField()
+
+ def get_untracked(self, comparison) -> List[str]:
+ return [
+ f
+ for f, _ in comparison.git_comparison["diff"]["files"].items()
+ if f not in (comparison.base_report or [])
+ and f not in comparison.head_report
+ ]
+
+ def get_diff(self, comparison) -> dict:
+ return {"git_commits": comparison.git_commits}
+
+ def get_files(self, comparison: Comparison) -> List[dict]:
+ return [
+ FileComparisonSerializer(file).data
+ for file in comparison.files
+ if self._should_include_file(file)
+ ]
+
+ def _should_include_file(self, file: FileComparison):
+ if "has_diff" in self.context:
+ return self.context["has_diff"] == file.has_diff
+ else:
+ return True
+
+
+class FlagComparisonSerializer(serializers.Serializer):
+ name = serializers.CharField(source="flag_name")
+ base_report_totals = serializers.SerializerMethodField()
+ head_report_totals = ReportTotalsSerializer(source="head_report.totals")
+ diff_totals = ReportTotalsSerializer()
+
+ def get_base_report_totals(self, obj):
+ if obj.base_report:
+ return ReportTotalsSerializer(obj.base_report.totals).data
+
+
+class ImpactedFileSegmentSerializer(serializers.Serializer):
+ header = serializers.SerializerMethodField()
+ has_unintended_changes = serializers.BooleanField()
+ lines = serializers.SerializerMethodField()
+
+ def get_header(self, segment: Segment) -> serializers.CharField:
+ (
+ base_starting,
+ base_extracted,
+ head_starting,
+ head_extracted,
+ ) = segment.header
+ base = f"{base_starting}"
+ if base_extracted is not None:
+ base = f"{base},{base_extracted}"
+ head = f"{head_starting}"
+ if head_extracted is not None:
+ head = f"{head},{head_extracted}"
+ return f"-{base} +{head}"
+
+ def get_lines(self, segment: Segment) -> serializers.ListField:
+ lines = []
+ for line in segment.lines:
+ value = line.value
+ if value and line.is_diff:
+ content = f"{value[0]} {value[1:]}"
+ else:
+ content = f" {value}"
+
+ lines.append(
+ {
+ "base_number": line.number["base"],
+ "head_number": line.number["head"],
+ "base_coverage": line.coverage["base"],
+ "head_coverage": line.coverage["head"],
+ "content": content,
+ }
+ )
+ return lines
+
+
+class ImpactedFileSegmentsSerializer(serializers.Serializer):
+ segments = serializers.SerializerMethodField()
+
+ def get_segments(self, file_path: str) -> ImpactedFileSegmentSerializer:
+ file_comparison = self.context["comparison"].get_file_comparison(
+ file_path, with_src=True, bypass_max_diff=True
+ )
+ return [
+ ImpactedFileSegmentSerializer(segment).data
+ for segment in file_comparison.segments
+ ]
+
+
+class ImpactedFileSerializer(serializers.Serializer):
+ file_name = serializers.SerializerMethodField()
+ base_name = serializers.CharField()
+ head_name = serializers.CharField()
+ is_new_file = serializers.SerializerMethodField()
+ is_renamed_file = serializers.SerializerMethodField()
+ is_deleted_file = serializers.SerializerMethodField()
+ base_coverage = serializers.SerializerMethodField()
+ head_coverage = serializers.SerializerMethodField()
+ patch_coverage = serializers.SerializerMethodField()
+ change_coverage = serializers.SerializerMethodField()
+ misses_count = serializers.SerializerMethodField()
+
+ def get_base_coverage(self, impacted_file: ImpactedFile) -> serializers.JSONField:
+ if impacted_file.base_coverage:
+ return dataclasses.asdict(impacted_file.base_coverage)
+
+ def get_head_coverage(self, impacted_file: ImpactedFile) -> serializers.JSONField:
+ if impacted_file.head_coverage:
+ return dataclasses.asdict(impacted_file.head_coverage)
+
+ def get_patch_coverage(self, impacted_file: ImpactedFile) -> serializers.JSONField:
+ if impacted_file.patch_coverage:
+ return dataclasses.asdict(impacted_file.patch_coverage)
+
+ def get_file_name(self, impacted_file: ImpactedFile) -> serializers.CharField:
+ return impacted_file.file_name
+
+ def get_is_new_file(self, impacted_file: ImpactedFile) -> serializers.BooleanField:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return base_name is None and head_name is not None
+
+ def get_is_renamed_file(
+ self, impacted_file: ImpactedFile
+ ) -> serializers.BooleanField:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return (
+ base_name is not None and head_name is not None and base_name != head_name
+ )
+
+ def get_is_deleted_file(
+ self, impacted_file: ImpactedFile
+ ) -> serializers.BooleanField:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return base_name is not None and head_name is None
+
+ def get_change_coverage(
+ self, impacted_file: ImpactedFile
+ ) -> serializers.FloatField:
+ return impacted_file.change_coverage
+
+ def get_misses_count(self, impacted_file: ImpactedFile) -> serializers.IntegerField:
+ return impacted_file.misses_count
+
+
+class ImpactedFilesComparisonSerializer(ComparisonSerializer):
+ files = serializers.SerializerMethodField()
+ state = serializers.SerializerMethodField()
+
+ def get_state(self, comparison: Comparison) -> str:
+ return self.context["commit_comparison"].state
+
+ def get_files(self, comparison: Comparison) -> List[dict]:
+ commit_comparison = self.context["commit_comparison"]
+
+ if not commit_comparison.is_processed:
+ return []
+
+ return [
+ ImpactedFileSerializer(
+ impacted_file, context={"comparison": comparison}
+ ).data
+ for impacted_file in ComparisonReport(commit_comparison).files
+ ]
diff --git a/apps/codecov-api/api/shared/error_views.py b/apps/codecov-api/api/shared/error_views.py
new file mode 100644
index 0000000000..c5ee950b3e
--- /dev/null
+++ b/apps/codecov-api/api/shared/error_views.py
@@ -0,0 +1,7 @@
+from django.http import JsonResponse
+from rest_framework import status
+
+
+def not_found(request, *args, **kwargs):
+ data = {"error": "Page Not Found (404)"}
+ return JsonResponse(data=data, status=status.HTTP_404_NOT_FOUND)
diff --git a/apps/codecov-api/api/shared/mixins.py b/apps/codecov-api/api/shared/mixins.py
new file mode 100644
index 0000000000..43af0d7881
--- /dev/null
+++ b/apps/codecov-api/api/shared/mixins.py
@@ -0,0 +1,129 @@
+from typing import Optional
+
+from django.conf import settings
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.utils.functional import cached_property
+from rest_framework.exceptions import NotFound
+
+from api.shared.serializers import (
+ CommitRefQueryParamSerializer,
+ PullIDQueryParamSerializer,
+)
+from codecov_auth.authentication import (
+ InternalToken,
+ InternalUser,
+ SuperToken,
+ SuperUser,
+)
+from codecov_auth.models import Owner, Service
+from core.models import Commit, Repository
+from utils.services import get_long_service_name
+
+
+class OwnerPropertyMixin:
+ @cached_property
+ def owner(self):
+ service = get_long_service_name(self.kwargs.get("service"))
+ if service not in Service:
+ raise Http404("Invalid service for Owner.")
+
+ return get_object_or_404(
+ Owner, username=self.kwargs.get("owner_username"), service=service
+ )
+
+
+class RepoPropertyMixin(OwnerPropertyMixin):
+ @cached_property
+ def repo(self):
+ return get_object_or_404(
+ Repository, name=self.kwargs.get("repo_name"), author=self.owner
+ )
+
+ def get_commit(self, commit_sha: Optional[str] = None) -> Commit:
+ commit_sha = commit_sha or self.request.query_params.get("sha")
+ if not commit_sha:
+ branch_name = self.request.query_params.get("branch", self.repo.branch)
+ branch = self.repo.branches.filter(name=branch_name).first()
+ if branch is None:
+ raise NotFound(
+ f"The branch '{branch_name}' in not in our records. Please provide a valid branch name.",
+ 404,
+ )
+
+ commit_sha = branch.head
+
+ commit = self.repo.commits.filter(commitid=commit_sha).first()
+ if commit is None:
+ raise NotFound(
+ f"The commit {commit_sha} is not in our records. Please specify valid commit.",
+ 404,
+ )
+
+ return commit
+
+
+class RepositoriesMixin:
+ @cached_property
+ def repositories(self):
+ """
+ List of repositories passed in through request query parameters. Used when generating chart response data.
+ """
+ service = get_long_service_name(self.kwargs.get("service"))
+
+ return Repository.objects.filter(
+ name__in=self.request.data.get("repositories", []),
+ author__username=self.kwargs.get("owner_username"),
+ author__service=service,
+ )
+
+
+class CompareSlugMixin(RepoPropertyMixin):
+ def _get_query_param_serializer_class(self):
+ if "pullid" in self.request.query_params:
+ return PullIDQueryParamSerializer
+ return CommitRefQueryParamSerializer
+
+ def get_compare_data(self):
+ serializer = self._get_query_param_serializer_class()(
+ data=self.request.query_params, context={"repo": self.repo}
+ )
+ serializer.is_valid(raise_exception=True)
+ validated_data = serializer.validated_data
+ return validated_data
+
+
+class SuperPermissionsMixin:
+ def has_super_token_permissions(self, request):
+ if request.method != "GET":
+ return False
+ user = request.user
+ auth = request.auth
+
+ if not isinstance(request.user, SuperUser) or not isinstance(
+ request.auth, SuperToken
+ ):
+ return False
+ return (
+ user.is_super_user
+ and auth.is_super_token
+ and auth.token == settings.SUPER_API_TOKEN
+ )
+
+
+class InternalPermissionsMixin:
+ def has_internal_token_permissions(self, request):
+ if request.method != "POST":
+ return False
+ user = request.user
+ auth = request.auth
+
+ if not isinstance(request.user, InternalUser) or not isinstance(
+ request.auth, InternalToken
+ ):
+ return False
+ return (
+ user.is_internal_user
+ and auth.is_internal_token
+ and auth.token == settings.CODECOV_INTERNAL_TOKEN
+ )
diff --git a/apps/codecov-api/api/shared/owner/filters.py b/apps/codecov-api/api/shared/owner/filters.py
new file mode 100644
index 0000000000..9629cf0b2e
--- /dev/null
+++ b/apps/codecov-api/api/shared/owner/filters.py
@@ -0,0 +1,12 @@
+import django_filters
+
+
+class UserFilters(django_filters.FilterSet):
+ activated = django_filters.BooleanFilter(method="filter_activated")
+ is_admin = django_filters.BooleanFilter(method="filter_is_admin")
+
+ def filter_activated(self, queryset, name, value):
+ return queryset.filter(activated=value)
+
+ def filter_is_admin(self, queryset, name, value):
+ return queryset.filter(is_admin=value)
diff --git a/apps/codecov-api/api/shared/owner/mixins.py b/apps/codecov-api/api/shared/owner/mixins.py
new file mode 100644
index 0000000000..bb9a1cc5ae
--- /dev/null
+++ b/apps/codecov-api/api/shared/owner/mixins.py
@@ -0,0 +1,85 @@
+from django.db.models import BooleanField, Case, Max, Q, QuerySet, When
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+from django_filters import rest_framework as django_filters
+from rest_framework import filters, viewsets
+from rest_framework.exceptions import NotFound
+
+from api.shared.mixins import OwnerPropertyMixin
+from api.shared.permissions import MemberOfOrgPermissions, UserIsAdminPermissions
+from codecov_auth.models import Owner, Service
+
+from .filters import UserFilters
+
+
+class OwnerViewSetMixin(viewsets.GenericViewSet):
+ lookup_field = "owner_username"
+ lookup_value_regex = "[^/]+"
+
+ def get_queryset(self) -> QuerySet:
+ service = self.kwargs.get("service")
+ try:
+ Service(service)
+ except ValueError:
+ raise NotFound(f"Service not found: {service}")
+ return Owner.objects.filter(service=self.kwargs.get("service"))
+
+ def get_object(self) -> Owner:
+ return get_object_or_404(
+ self.get_queryset(),
+ username=self.kwargs.get("owner_username"),
+ service=self.kwargs.get("service"),
+ )
+
+
+class UserViewSetMixin(
+ viewsets.GenericViewSet,
+ OwnerPropertyMixin,
+):
+ filter_backends = (
+ django_filters.DjangoFilterBackend,
+ filters.SearchFilter,
+ )
+ filterset_class = UserFilters
+ permission_classes = [MemberOfOrgPermissions]
+ ordering_fields = ("name", "username", "email", "last_pull_timestamp", "activated")
+ lookup_field = "user_username_or_ownerid"
+ search_fields = ["name", "username", "email"]
+
+ def get_queryset(self) -> QuerySet:
+ return (
+ Owner.objects.users_of(owner=self.owner)
+ .annotate_activated_in(owner=self.owner)
+ .annotate_is_admin_in(owner=self.owner)
+ )
+
+ def get_object(self) -> Owner:
+ username_or_ownerid = self.kwargs.get("user_username_or_ownerid")
+ try:
+ ownerid = int(username_or_ownerid)
+ except ValueError:
+ ownerid = None
+
+ return get_object_or_404(
+ self.get_queryset(),
+ (Q(username=username_or_ownerid) | Q(ownerid=ownerid)),
+ )
+
+
+class UserSessionViewSetMixin(
+ viewsets.GenericViewSet,
+ OwnerPropertyMixin,
+):
+ permission_classes = [UserIsAdminPermissions]
+ ordering_fields = ("name", "username")
+
+ def get_queryset(self) -> QuerySet:
+ return Owner.objects.users_of(owner=self.owner).annotate(
+ expiry_date=Max("session__login_session__expire_date"),
+ has_active_session=Case(
+ When(expiry_date__isnull=True, then=False),
+ When(expiry_date__gt=timezone.now(), then=True),
+ default=False,
+ output_field=BooleanField(),
+ ),
+ )
diff --git a/apps/codecov-api/api/shared/pagination.py b/apps/codecov-api/api/shared/pagination.py
new file mode 100644
index 0000000000..64ada38b1a
--- /dev/null
+++ b/apps/codecov-api/api/shared/pagination.py
@@ -0,0 +1,37 @@
+from rest_framework.pagination import CursorPagination, PageNumberPagination
+
+
+class CodecovCursorPagination(CursorPagination):
+ page_size_query_param = "page_size"
+
+
+class StandardPageNumberPagination(PageNumberPagination):
+ page_size_query_param = "page_size"
+
+ def get_paginated_response(self, data):
+ response = super(StandardPageNumberPagination, self).get_paginated_response(
+ data
+ )
+ response.data["total_pages"] = self.page.paginator.num_pages
+ return response
+
+
+class PaginationMixin:
+ """
+ Allows dynamicly switching between the default page number based pagination (above)
+ and cursor based pagination.
+
+ Specifying a `cursor` query string parameter will switch to the cursor-based pagination.
+ """
+
+ @property
+ def paginator(self):
+ if not hasattr(self, "_paginator"):
+ if self.pagination_class is None:
+ self._paginator = None
+ else:
+ if "cursor" in self.request.query_params:
+ self._paginator = CodecovCursorPagination()
+ else:
+ self._paginator = self.pagination_class()
+ return self._paginator
diff --git a/apps/codecov-api/api/shared/permissions.py b/apps/codecov-api/api/shared/permissions.py
new file mode 100644
index 0000000000..3c6cd7a242
--- /dev/null
+++ b/apps/codecov-api/api/shared/permissions.py
@@ -0,0 +1,205 @@
+import logging
+from typing import Any, Tuple
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.http import Http404, HttpRequest
+from rest_framework.permissions import (
+ SAFE_METHODS, # ['GET', 'HEAD', 'OPTIONS']
+ BasePermission,
+)
+
+import services.self_hosted as self_hosted
+from api.shared.mixins import InternalPermissionsMixin, SuperPermissionsMixin
+from api.shared.repo.repository_accessors import RepoAccessors
+from codecov_auth.models import Owner
+from core.models import Repository
+from services.activation import try_auto_activate
+from services.decorators import torngit_safe
+from services.repo_providers import get_generic_adapter_params, get_provider
+
+log = logging.getLogger(__name__)
+
+
+class RepositoryPermissionsService:
+ @torngit_safe
+ def _fetch_provider_permissions(
+ self, owner: Owner, repo: Repository
+ ) -> Tuple[bool, bool]:
+ can_view, can_edit = RepoAccessors().get_repo_permissions(owner, repo)
+
+ if can_view:
+ owner.permission = owner.permission or []
+ owner.permission.append(repo.repoid)
+ owner.save(update_fields=["permission"])
+
+ return can_view, can_edit
+
+ def has_read_permissions(self, owner: Owner, repo: Repository) -> bool:
+ return not repo.private or (
+ owner is not None
+ and (
+ repo.author.ownerid == owner.ownerid
+ or owner.permission
+ and repo.repoid in owner.permission
+ or self._fetch_provider_permissions(owner, repo)[0]
+ )
+ )
+
+ def has_write_permissions(self, user: Owner, repo: Repository) -> bool:
+ return user.is_authenticated and (
+ repo.author.ownerid == user.ownerid
+ or self._fetch_provider_permissions(user, repo)[1]
+ )
+
+ def user_is_activated(self, current_owner: Owner, owner: Owner) -> bool:
+ if not current_owner or not owner:
+ return False
+ if current_owner.ownerid == owner.ownerid:
+ return True
+ if owner.has_legacy_plan:
+ return True
+ if (
+ current_owner.organizations is None
+ or owner.ownerid not in current_owner.organizations
+ ):
+ return False
+ if (
+ owner.plan_activated_users
+ and current_owner.ownerid in owner.plan_activated_users
+ ):
+ return True
+ return try_auto_activate(owner, current_owner)
+
+
+class RepositoryArtifactPermissions(BasePermission):
+ """
+ Permissions class for artifacts of a repository, eg commits, branches,
+ pulls, comparisons, etc. Requires that the view has a '.repo'
+ property that returns the repo being worked on.
+ """
+
+ permissions_service = RepositoryPermissionsService()
+ message = (
+ "Permission denied: some possible reasons for this are (1) the "
+ "user doesn't have permission to view the specific resource, "
+ "(2) the organization has a per-user plan or (3) the user is "
+ "trying to view a private repo but is not activated."
+ )
+
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ if view.repo.private:
+ user_activated_permissions = (
+ request.user.is_authenticated
+ and self.permissions_service.user_is_activated(
+ request.current_owner, view.owner
+ )
+ )
+ else:
+ user_activated_permissions = True
+ has_read_permissions = (
+ request.method in SAFE_METHODS
+ and self.permissions_service.has_read_permissions(
+ request.current_owner, view.repo
+ )
+ )
+ if has_read_permissions and user_activated_permissions:
+ return True
+ if has_read_permissions and not user_activated_permissions:
+ # user that can access the repo; but are not activated
+ return False
+ raise Http404()
+
+
+class SuperTokenPermissions(BasePermission, SuperPermissionsMixin):
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ return self.has_super_token_permissions(request)
+
+
+class InternalTokenPermissions(BasePermission, InternalPermissionsMixin):
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ return self.has_internal_token_permissions(request)
+
+
+class ChartPermissions(BasePermission):
+ permissions_service = RepositoryPermissionsService()
+
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ log.info(
+ f"Coverage chart has repositories {view.repositories}",
+ extra=dict(user=request.current_owner),
+ )
+ for repo in view.repositories:
+ # TODO: this can cause a provider-api request for every repo in the list,
+ # can we just rely on our stored read permissions? In fact, it seems like
+ # permissioning is built into api.internal.charts.filter.add_simple_filters
+ if not self.permissions_service.has_read_permissions(
+ request.current_owner, repo
+ ):
+ raise Http404
+ return True
+
+
+class UserIsAdminPermissions(BasePermission):
+ """
+ Permissions class for asserting the user is an admin of the 'owner'
+ being queried. Requires that the view has a '.owner' property that
+ returns this owner.
+ """
+
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ if settings.IS_ENTERPRISE:
+ return request.user.is_authenticated and self_hosted.is_admin_owner(
+ request.current_owner
+ )
+ else:
+ return (
+ request.user.is_authenticated
+ and request.current_owner
+ and (
+ view.owner.is_admin(request.current_owner)
+ or self._is_admin_on_provider(request.current_owner, view.owner)
+ )
+ )
+
+ @torngit_safe
+ def _is_admin_on_provider(self, user: Owner, owner: Owner) -> bool:
+ torngit_provider_adapter = get_provider(
+ owner.service,
+ {
+ **get_generic_adapter_params(user, owner.service),
+ **{
+ "owner": {
+ "username": owner.username,
+ "service_id": owner.service_id,
+ }
+ },
+ },
+ )
+
+ return async_to_sync(torngit_provider_adapter.get_is_admin)(
+ user={"username": user.username, "service_id": user.service_id}
+ )
+
+
+class MemberOfOrgPermissions(BasePermission):
+ """
+ Permissions class for asserting the user is member of the owner.
+ Requires that the view has a '.owner' property that returns this owner.
+ """
+
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ if not request.user.is_authenticated:
+ return False
+
+ current_owner = request.current_owner
+ if not current_owner:
+ return False
+
+ owner = view.owner
+ if current_owner == owner:
+ return True
+ if owner.ownerid in (current_owner.organizations or []):
+ return True
+ else:
+ raise Http404("No Owner matches the given query.")
diff --git a/apps/codecov-api/api/shared/pull/__init__.py b/apps/codecov-api/api/shared/pull/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/shared/pull/mixins.py b/apps/codecov-api/api/shared/pull/mixins.py
new file mode 100644
index 0000000000..5f9cd29171
--- /dev/null
+++ b/apps/codecov-api/api/shared/pull/mixins.py
@@ -0,0 +1,43 @@
+from django.db.models import OuterRef, Subquery
+from django.shortcuts import get_object_or_404
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework import filters, viewsets
+
+from api.shared.mixins import RepoPropertyMixin
+from api.shared.permissions import RepositoryArtifactPermissions
+from core.models import Commit
+
+
+class PullViewSetMixin(
+ viewsets.GenericViewSet,
+ RepoPropertyMixin,
+):
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filterset_fields = ["state"]
+ ordering = ["-pullid"]
+ ordering_fields = ["pullid"]
+ permission_classes = [RepositoryArtifactPermissions]
+ lookup_field = "pullid"
+
+ def get_object(self):
+ pullid = self.kwargs.get("pullid")
+ return get_object_or_404(self.get_queryset(), pullid=pullid)
+
+ def get_queryset(self):
+ return self.repo.pull_requests.annotate(
+ base_totals=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("compared_to"), repository=OuterRef("repository")
+ ).values("totals")[:1]
+ ),
+ head_totals=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"), repository=OuterRef("repository")
+ ).values("totals")[:1]
+ ),
+ ci_passed=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"), repository=OuterRef("repository")
+ ).values("ci_passed")[:1]
+ ),
+ )
diff --git a/apps/codecov-api/api/shared/repo/filter.py b/apps/codecov-api/api/shared/repo/filter.py
new file mode 100644
index 0000000000..705c63ad2b
--- /dev/null
+++ b/apps/codecov-api/api/shared/repo/filter.py
@@ -0,0 +1,49 @@
+from django_filters import BooleanFilter
+from django_filters import rest_framework as django_filters
+
+from core.models import Repository
+
+
+class StringListFilter(django_filters.Filter):
+ def __init__(self, query_param, *args, **kwargs):
+ super(StringListFilter, self).__init__(*args, **kwargs)
+ self.query_param = query_param
+
+ def filter(self, qs, value):
+ try:
+ request = self.parent.request
+ except AttributeError:
+ return None
+
+ values = request.GET.getlist(self.query_param)
+ if len(values) > 0:
+ return qs.filter(**{"%s__%s" % (self.field_name, self.lookup_expr): values})
+
+ return qs
+
+
+class RepositoryFilters(django_filters.FilterSet):
+ """Filter for active repositories"""
+
+ active = BooleanFilter(
+ field_name="active",
+ method="filter_active",
+ label="whether the repository has received an upload",
+ )
+
+ """Filter for getting multiple repositories by name"""
+ names = StringListFilter(
+ query_param="names",
+ field_name="name",
+ lookup_expr="in",
+ label="list of repository names",
+ )
+
+ def filter_active(self, queryset, name, value):
+ # The database currently stores 't' instead of 'true' for active repos, and nothing for inactive
+ # so if the query param active is set, we return repos with non-null value in active column
+ return queryset.filter(active=value)
+
+ class Meta:
+ model = Repository
+ fields = ["active", "names"]
diff --git a/apps/codecov-api/api/shared/repo/mixins.py b/apps/codecov-api/api/shared/repo/mixins.py
new file mode 100644
index 0000000000..7a9388dab1
--- /dev/null
+++ b/apps/codecov-api/api/shared/repo/mixins.py
@@ -0,0 +1,96 @@
+import logging
+
+from django.http import Http404
+from rest_framework import viewsets
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import SAFE_METHODS # ['GET', 'HEAD', 'OPTIONS']
+
+from api.shared.mixins import OwnerPropertyMixin
+from api.shared.permissions import RepositoryPermissionsService, UserIsAdminPermissions
+from core.models import Repository
+from services.decorators import torngit_safe
+
+from .repository_accessors import RepoAccessors
+
+log = logging.getLogger(__name__)
+
+
+class RepositoryViewSetMixin(
+ OwnerPropertyMixin,
+ viewsets.GenericViewSet,
+):
+ lookup_value_regex = r"[\w\.@\:\-~]+"
+ lookup_field = "repo_name"
+ accessors = RepoAccessors()
+
+ def _assert_is_admin(self):
+ admin_permissions = UserIsAdminPermissions()
+ if not admin_permissions.has_permission(self.request, self):
+ raise PermissionDenied()
+
+ def get_queryset(self):
+ return (
+ Repository.objects.filter(author=self.owner)
+ .viewable_repos(self.request.current_owner)
+ .select_related("author")
+ )
+
+ @torngit_safe
+ def check_object_permissions(self, request, repo):
+ # Below is some hacking to avoid requesting permissions from API in certain scenarios.
+ if not request.user.is_authenticated and not repo.private:
+ # Unauthenticated users only have read-access to public repositories,
+ # so we avoid this API call here
+ self.can_view, self.can_edit = True, False
+ elif not request.user.is_authenticated and repo.private:
+ raise Http404()
+ else:
+ # If the user is authenticated, we can fetch permissions from the provider
+ # to determine write permissions.
+ self.can_view, self.can_edit = self.accessors.get_repo_permissions(
+ self.request.current_owner, repo
+ )
+
+ if repo.private and not RepositoryPermissionsService().user_is_activated(
+ self.request.current_owner, self.owner
+ ):
+ log.info(
+ "An inactive user attempted to access a repo page",
+ extra=dict(
+ user=self.request.current_owner.username,
+ owner=self.owner.username,
+ repo=repo.name,
+ ),
+ )
+ raise PermissionDenied("User not activated")
+ if self.request.method not in SAFE_METHODS and not self.can_edit:
+ raise PermissionDenied()
+ if self.request.method == "DELETE":
+ self._assert_is_admin()
+ if not self.can_view:
+ raise Http404()
+
+ @torngit_safe
+ def get_object(self):
+ # Get request args and try to find the repo in the DB
+ repo_name = self.kwargs.get("repo_name")
+ org_name = self.kwargs.get("owner_username")
+ service = self.kwargs.get("service")
+
+ repo = self.accessors.get_repo_details(
+ user=self.request.current_owner,
+ repo_name=repo_name,
+ repo_owner_username=org_name,
+ repo_owner_service=service,
+ )
+
+ if repo is None:
+ repo = self.accessors.fetch_from_git_and_create_repo(
+ user=self.request.current_owner,
+ repo_name=repo_name,
+ repo_owner_username=org_name,
+ repo_owner_service=service,
+ )
+
+ self.check_object_permissions(self.request, repo)
+ return repo
diff --git a/apps/codecov-api/api/shared/repo/repository_accessors.py b/apps/codecov-api/api/shared/repo/repository_accessors.py
new file mode 100644
index 0000000000..0cee985c39
--- /dev/null
+++ b/apps/codecov-api/api/shared/repo/repository_accessors.py
@@ -0,0 +1,79 @@
+import logging
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils import timezone
+
+from codecov_auth.models import Owner
+from core.models import Repository
+from services.repo_providers import RepoProviderService
+
+log = logging.getLogger(__name__)
+
+
+class RepoAccessors:
+ """
+ Easily mockable wrappers for running torngit coroutines.
+ """
+
+ def get_repo_permissions(self, user, repo):
+ """
+ Returns repo permissions information from the provider
+
+ :param repo_name:
+ :param org_name:
+ :return:
+ """
+ if user == repo.author:
+ return True, True
+ return async_to_sync(
+ RepoProviderService().get_adapter(owner=user, repo=repo).get_authenticated
+ )()
+
+ @sentry_sdk.trace
+ def get_repo_details(
+ self, user, repo_name, repo_owner_username, repo_owner_service
+ ):
+ """
+ Returns repo from DB, if it exists.
+ """
+ try:
+ return (
+ Repository.objects.all()
+ .with_recent_coverage()
+ .get(
+ name=repo_name,
+ author__username=repo_owner_username,
+ author__service=repo_owner_service,
+ )
+ )
+ except ObjectDoesNotExist:
+ repo = None
+ return repo
+
+ def fetch_from_git_and_create_repo(
+ self, user, repo_name, repo_owner_username, repo_owner_service
+ ):
+ """
+ Fetch repository details for the provider and update the DB with new information.
+ """
+ # Try to fetch the repo from the git provider using shared.torngit
+ adapter = RepoProviderService().get_by_name(
+ owner=user,
+ repo_name=repo_name,
+ repo_owner_username=repo_owner_username,
+ repo_owner_service=repo_owner_service,
+ )
+ result = async_to_sync(adapter.get_repository)()
+
+ owner, _ = Owner.objects.get_or_create(
+ service=repo_owner_service,
+ username=result["owner"]["username"],
+ service_id=result["owner"]["service_id"],
+ defaults={"createstamp": timezone.now()},
+ )
+
+ return Repository.objects.get_or_create_from_git_repo(
+ git_repo=result["repo"], owner=owner
+ )[0]
diff --git a/apps/codecov-api/api/shared/report/__init__.py b/apps/codecov-api/api/shared/report/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/api/shared/report/serializers.py b/apps/codecov-api/api/shared/report/serializers.py
new file mode 100644
index 0000000000..e6fe212256
--- /dev/null
+++ b/apps/codecov-api/api/shared/report/serializers.py
@@ -0,0 +1,31 @@
+import math
+
+from rest_framework import serializers
+
+from services.path import Dir
+
+
+class TreeSerializer(serializers.Serializer):
+ name = serializers.CharField()
+ full_path = serializers.CharField()
+ coverage = serializers.FloatField()
+ lines = serializers.IntegerField()
+ hits = serializers.IntegerField()
+ partials = serializers.IntegerField()
+ misses = serializers.IntegerField()
+
+ def to_representation(self, instance):
+ depth = self.context.get("depth", 1)
+ max_depth = self.context.get("max_depth", math.inf)
+ res = super().to_representation(instance)
+ if isinstance(instance, Dir):
+ if depth < max_depth:
+ res["children"] = TreeSerializer(
+ instance.children,
+ many=True,
+ context={
+ "depth": depth + 1,
+ "max_depth": max_depth,
+ },
+ ).data
+ return res
diff --git a/apps/codecov-api/api/shared/serializers.py b/apps/codecov-api/api/shared/serializers.py
new file mode 100644
index 0000000000..4e3b0a1b37
--- /dev/null
+++ b/apps/codecov-api/api/shared/serializers.py
@@ -0,0 +1,45 @@
+from django.shortcuts import get_object_or_404
+from rest_framework import serializers
+from rest_framework.exceptions import NotFound
+
+from core.models import Branch, Commit, Pull
+
+
+class StringListField(serializers.ListField):
+ child = serializers.CharField()
+
+
+class CommitRefQueryParamSerializer(serializers.Serializer):
+ base = serializers.CharField(required=True)
+ head = serializers.CharField(required=True)
+
+ def _get_commit_or_branch(self, ref):
+ repo = self.context.get("repo")
+ commit = Commit.objects.filter(repository_id=repo.repoid, commitid=ref)
+ if commit.exists():
+ return commit.get()
+
+ branch = Branch.objects.filter(repository=repo, name=ref)
+ if branch.exists():
+ head = Commit.objects.filter(repository=repo, commitid=branch.get().head)
+ if head.exists():
+ return head.get()
+ raise NotFound(
+ f"Head commit '{branch.get().head}' for branch '{ref}' not found!"
+ )
+ raise NotFound(f"Commit or branch '{ref}' not found!")
+
+ def validate_base(self, base):
+ return self._get_commit_or_branch(base)
+
+ def validate_head(self, head):
+ return self._get_commit_or_branch(head)
+
+
+class PullIDQueryParamSerializer(serializers.Serializer):
+ pullid = serializers.CharField(required=True)
+
+ def validate(self, obj):
+ repo = self.context.get("repo")
+ pull = get_object_or_404(Pull, pullid=obj.get("pullid"), repository=repo)
+ return {"pull": pull}
diff --git a/apps/codecov-api/billing/__init__.py b/apps/codecov-api/billing/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/billing/apps.py b/apps/codecov-api/billing/apps.py
new file mode 100644
index 0000000000..729d58448d
--- /dev/null
+++ b/apps/codecov-api/billing/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BillingConfig(AppConfig):
+ name = "billing"
diff --git a/apps/codecov-api/billing/constants.py b/apps/codecov-api/billing/constants.py
new file mode 100644
index 0000000000..bc2f5b9116
--- /dev/null
+++ b/apps/codecov-api/billing/constants.py
@@ -0,0 +1,28 @@
+class StripeHTTPHeaders:
+ """
+ Header-strings associated with Stripe webhook events.
+ """
+
+ # https://stripe.com/docs/webhooks/signatures#verify-official-libraries
+ SIGNATURE = "HTTP_STRIPE_SIGNATURE"
+
+
+class StripeWebhookEvents:
+ subscribed_events = (
+ "checkout.session.completed",
+ "customer.created",
+ "customer.subscription.created",
+ "customer.subscription.updated",
+ "customer.subscription.deleted",
+ "customer.updated",
+ "invoice.payment_failed",
+ "invoice.payment_succeeded",
+ "payment_intent.succeeded",
+ "setup_intent.succeeded",
+ "subscription_schedule.created",
+ "subscription_schedule.released",
+ "subscription_schedule.updated",
+ )
+
+
+REMOVED_INVOICE_STATUSES = ["draft", "void"]
diff --git a/apps/codecov-api/billing/helpers.py b/apps/codecov-api/billing/helpers.py
new file mode 100644
index 0000000000..8bbef4df26
--- /dev/null
+++ b/apps/codecov-api/billing/helpers.py
@@ -0,0 +1,193 @@
+from django.conf import settings
+from django.db.models import QuerySet
+from shared.django_apps.codecov_auth.models import BillingRate
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, PlanPrice, TierName
+
+from codecov_auth.models import Owner, Plan
+
+
+def on_enterprise_plan(owner: Owner) -> bool:
+ plan = Plan.objects.select_related("tier").get(name=owner.plan)
+ return settings.IS_ENTERPRISE or (plan.tier.tier_name == TierName.ENTERPRISE.value)
+
+
+def get_all_admins_for_owners(owners: QuerySet[Owner]):
+ admin_ids = set()
+ for owner in owners:
+ if owner.admins:
+ admin_ids.update(owner.admins)
+
+ # Add the owner's email as well - for user owners, admins is empty.
+ if owner.email:
+ admin_ids.add(owner.ownerid)
+
+ admins: QuerySet[Owner] = Owner.objects.filter(pk__in=admin_ids)
+ return admins
+
+
+def mock_all_plans_and_tiers():
+ TierFactory(tier_name=TierName.BASIC.value)
+
+ trial_tier = TierFactory(tier_name=TierName.TRIAL.value)
+ PlanFactory(
+ tier=trial_tier,
+ name=PlanName.TRIAL_PLAN_NAME.value,
+ paid_plan=False,
+ marketing_name="Developer",
+ benefits=[
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ stripe_id="plan_trial",
+ )
+
+ pro_tier = TierFactory(tier_name=TierName.PRO.value)
+ PlanFactory(
+ name=PlanName.CODECOV_PRO_MONTHLY.value,
+ tier=pro_tier,
+ marketing_name="Pro",
+ benefits=[
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ billing_rate=BillingRate.MONTHLY.value,
+ base_unit_price=PlanPrice.MONTHLY.value,
+ paid_plan=True,
+ stripe_id="plan_pro",
+ )
+ PlanFactory(
+ name=PlanName.CODECOV_PRO_YEARLY.value,
+ tier=pro_tier,
+ marketing_name="Pro",
+ benefits=[
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ billing_rate=BillingRate.ANNUALLY.value,
+ base_unit_price=PlanPrice.YEARLY.value,
+ paid_plan=True,
+ stripe_id="plan_pro_yearly",
+ )
+
+ team_tier = TierFactory(tier_name=TierName.TEAM.value)
+ PlanFactory(
+ name=PlanName.TEAM_MONTHLY.value,
+ tier=team_tier,
+ marketing_name="Team",
+ benefits=[
+ "Up to 10 users",
+ "Unlimited repositories",
+ "2500 private repo uploads",
+ "Patch coverage analysis",
+ ],
+ billing_rate=BillingRate.MONTHLY.value,
+ base_unit_price=PlanPrice.TEAM_MONTHLY.value,
+ monthly_uploads_limit=2500,
+ paid_plan=True,
+ stripe_id="plan_team_monthly",
+ )
+ PlanFactory(
+ name=PlanName.TEAM_YEARLY.value,
+ tier=team_tier,
+ marketing_name="Team",
+ benefits=[
+ "Up to 10 users",
+ "Unlimited repositories",
+ "2500 private repo uploads",
+ "Patch coverage analysis",
+ ],
+ billing_rate=BillingRate.ANNUALLY.value,
+ base_unit_price=PlanPrice.TEAM_YEARLY.value,
+ monthly_uploads_limit=2500,
+ paid_plan=True,
+ stripe_id="plan_team_yearly",
+ )
+
+ sentry_tier = TierFactory(tier_name=TierName.SENTRY.value)
+ PlanFactory(
+ name=PlanName.SENTRY_MONTHLY.value,
+ tier=sentry_tier,
+ marketing_name="Sentry Pro",
+ billing_rate=BillingRate.MONTHLY.value,
+ base_unit_price=PlanPrice.MONTHLY.value,
+ paid_plan=True,
+ benefits=[
+ "Includes 5 seats",
+ "$12 per additional seat",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ stripe_id="plan_sentry_monthly",
+ )
+ PlanFactory(
+ name=PlanName.SENTRY_YEARLY.value,
+ tier=sentry_tier,
+ marketing_name="Sentry Pro",
+ billing_rate=BillingRate.ANNUALLY.value,
+ base_unit_price=PlanPrice.YEARLY.value,
+ paid_plan=True,
+ benefits=[
+ "Includes 5 seats",
+ "$10 per additional seat",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ stripe_id="plan_sentry_yearly",
+ )
+
+ enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value)
+ PlanFactory(
+ name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value,
+ tier=enterprise_tier,
+ marketing_name="Enterprise Cloud",
+ billing_rate=BillingRate.MONTHLY.value,
+ base_unit_price=PlanPrice.MONTHLY.value,
+ paid_plan=True,
+ benefits=[
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ stripe_id="plan_enterprise_cloud_monthly",
+ )
+ PlanFactory(
+ name=PlanName.ENTERPRISE_CLOUD_YEARLY.value,
+ tier=enterprise_tier,
+ marketing_name="Enterprise Cloud",
+ billing_rate=BillingRate.ANNUALLY.value,
+ base_unit_price=PlanPrice.YEARLY.value,
+ paid_plan=True,
+ benefits=[
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ stripe_id="plan_enterprise_cloud_yearly",
+ )
+
+ PlanFactory(
+ name=DEFAULT_FREE_PLAN,
+ tier=team_tier,
+ marketing_name="Developer",
+ billing_rate=None,
+ base_unit_price=0,
+ paid_plan=False,
+ monthly_uploads_limit=250,
+ benefits=[
+ "Up to 1 user",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ stripe_id="plan_default_free",
+ )
diff --git a/apps/codecov-api/billing/migrations/0001_initial.py b/apps/codecov-api/billing/migrations/0001_initial.py
new file mode 100644
index 0000000000..d25438f6bc
--- /dev/null
+++ b/apps/codecov-api/billing/migrations/0001_initial.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.13 on 2021-11-03 18:21
+
+import uuid
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Account",
+ fields=[
+ ("id", models.BigAutoField(primary_key=True, serialize=False)),
+ ("external_id", models.UUIDField(default=uuid.uuid4, editable=False)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("stripe_customer_id", models.TextField(null=True)),
+ ("stripe_subscription_id", models.TextField(null=True)),
+ ("plan", models.TextField(default="users-free")),
+ (
+ "plan_provider",
+ models.TextField(choices=[("github", "Github")], null=True),
+ ),
+ ("max_activated_user_count", models.SmallIntegerField(default=5)),
+ ("should_auto_activate_users", models.BooleanField(default=True)),
+ ],
+ options={"abstract": False},
+ )
+ ]
diff --git a/apps/codecov-api/billing/migrations/0002_auto_20220118_1232.py b/apps/codecov-api/billing/migrations/0002_auto_20220118_1232.py
new file mode 100644
index 0000000000..2d8d72a78a
--- /dev/null
+++ b/apps/codecov-api/billing/migrations/0002_auto_20220118_1232.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.13 on 2022-01-18 12:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [("billing", "0001_initial")]
+
+ operations = [
+ migrations.RunSQL(
+ "ALTER TYPE notifications ADD VALUE IF NOT exists 'checks_patch';"
+ ),
+ migrations.RunSQL(
+ "ALTER TYPE notifications ADD VALUE IF NOT exists 'checks_project';"
+ ),
+ migrations.RunSQL(
+ "ALTER TYPE notifications ADD VALUE IF NOT exists 'checks_changes';"
+ ),
+ ]
diff --git a/apps/codecov-api/billing/migrations/0003_delete_account.py b/apps/codecov-api/billing/migrations/0003_delete_account.py
new file mode 100644
index 0000000000..ce40e1bbbf
--- /dev/null
+++ b/apps/codecov-api/billing/migrations/0003_delete_account.py
@@ -0,0 +1,15 @@
+# Generated by Django 4.2.11 on 2024-07-24 00:23
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("billing", "0002_auto_20220118_1232"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="Account",
+ ),
+ ]
diff --git a/apps/codecov-api/billing/migrations/__init__.py b/apps/codecov-api/billing/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/billing/tests/__init__.py b/apps/codecov-api/billing/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/billing/tests/test_helpers.py b/apps/codecov-api/billing/tests/test_helpers.py
new file mode 100644
index 0000000000..f862b782e1
--- /dev/null
+++ b/apps/codecov-api/billing/tests/test_helpers.py
@@ -0,0 +1,31 @@
+from django.test import TestCase, override_settings
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from shared.plan.constants import PlanName
+
+from billing.helpers import mock_all_plans_and_tiers, on_enterprise_plan
+
+
+class HelpersTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_on_enterprise_plan_on_prem(self):
+ owner = OwnerFactory()
+ assert on_enterprise_plan(owner) == True
+
+ def test_on_enterprise_plan_enterprise_cloud(self):
+ plan_names = [
+ PlanName.ENTERPRISE_CLOUD_MONTHLY.value,
+ PlanName.ENTERPRISE_CLOUD_YEARLY.value,
+ ]
+
+ for plan in plan_names:
+ owner = OwnerFactory(plan=plan)
+ assert on_enterprise_plan(owner) == True
+
+ def test_on_enterprise_plan_cloud(self):
+ owner = OwnerFactory()
+ assert on_enterprise_plan(owner) == False
diff --git a/apps/codecov-api/billing/tests/test_views.py b/apps/codecov-api/billing/tests/test_views.py
new file mode 100644
index 0000000000..b45d9cc223
--- /dev/null
+++ b/apps/codecov-api/billing/tests/test_views.py
@@ -0,0 +1,1825 @@
+import time
+from datetime import datetime
+from unittest.mock import Mock, call, patch
+
+import stripe
+from django.conf import settings
+from freezegun import freeze_time
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APIRequestFactory, APITestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName
+
+from billing.helpers import mock_all_plans_and_tiers
+from billing.views import StripeWebhookHandler
+from codecov_auth.models import Plan
+
+from ..constants import StripeHTTPHeaders
+
+
+class MockSubscriptionPlan(object):
+ def __init__(self, params):
+ self.id = params["new_plan"]
+
+
+class MockSubscription(object):
+ def __init__(self, owner, params):
+ self.metadata = {"obo_organization": owner.ownerid, "obo": 15}
+ self.plan = MockSubscriptionPlan(params)
+ self.quantity = params["new_quantity"]
+ self.customer = "cus_123"
+ self.id = params["subscription_id"]
+ self.items = {
+ "data": [
+ {
+ "quantity": params["new_quantity"],
+ "plan": {"id": params["new_plan"]},
+ }
+ ]
+ }
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class MockCard(object):
+ def __init__(self):
+ self.brand = "visa"
+ self.last4 = "1234"
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, default=None):
+ return getattr(self, key, default)
+
+
+class MockPaymentMethod(object):
+ def __init__(self, noCard=False):
+ if noCard:
+ self.card = None
+ return
+
+ self.card = MockCard()
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, default=None):
+ return getattr(self, key, default)
+
+
+class MockPaymentIntent(object):
+ def __init__(self, noCard=False):
+ self.payment_method = MockPaymentMethod(noCard)
+ self.status = "succeeded"
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, default=None):
+ return getattr(self, key, default)
+
+
+class StripeWebhookHandlerTests(APITestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ def setUp(self):
+ self.owner = OwnerFactory(
+ stripe_customer_id="cus_123",
+ stripe_subscription_id="sub_123",
+ )
+
+ # Creates a second owner that shares billing details with self.owner.
+ # This is used to test the case where owners are manually set to share a
+ # subscription in Stripe.
+ def add_second_owner(self):
+ self.other_owner = OwnerFactory(
+ stripe_customer_id="cus_123",
+ stripe_subscription_id="sub_123",
+ )
+
+ def _send_event(self, payload, errorSig=None):
+ timestamp = time.time_ns()
+
+ request = APIRequestFactory().post(
+ reverse("stripe-webhook"), data=payload, format="json"
+ )
+
+ return self.client.post(
+ reverse("stripe-webhook"),
+ **{
+ StripeHTTPHeaders.SIGNATURE: errorSig
+ or "t={},v1={}".format(
+ timestamp,
+ stripe.WebhookSignature._compute_signature(
+ "{}.{}".format(timestamp, request.body.decode("utf-8")),
+ settings.STRIPE_ENDPOINT_SECRET,
+ ),
+ )
+ },
+ data=payload,
+ format="json",
+ )
+
+ def test_invalid_event_signature(self):
+ response = self._send_event(
+ payload={
+ "type": "blah",
+ "data": {},
+ },
+ errorSig="lol",
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_invoice_payment_succeeded_sets_owner_delinquent_false(self):
+ self.owner.delinquent = True
+ self.owner.save()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_succeeded",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is False
+
+ def test_invoice_payment_succeeded_sets_multiple_owners_delinquent_false(self):
+ self.add_second_owner()
+ self.owner.delinquent = True
+ self.owner.save()
+ self.other_owner.delinquent = True
+ self.other_owner.save()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_succeeded",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is False
+ assert self.other_owner.delinquent is False
+
+ @patch("services.task.TaskService.send_email")
+ def test_invoice_payment_succeeded_emails_only_emails_delinquents(
+ self,
+ mocked_send_email,
+ ):
+ self.add_second_owner()
+ self.owner.delinquent = False
+ self.owner.save()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_succeeded",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is False
+
+ mocked_send_email.assert_not_called()
+
+ @patch("services.task.TaskService.send_email")
+ def test_invoice_payment_succeeded_emails_delinquents(self, mocked_send_email):
+ non_admin = OwnerFactory(email="non-admin@codecov.io")
+ admin_1 = OwnerFactory(email="admin1@codecov.io")
+ admin_2 = OwnerFactory(email="admin2@codecov.io")
+ self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
+ self.owner.plan_activated_users = [non_admin.ownerid]
+ self.owner.email = "owner@codecov.io"
+ self.owner.delinquent = True
+ self.owner.save()
+ self.add_second_owner()
+ self.other_owner.delinquent = False
+ self.other_owner.save()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_succeeded",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is False
+ assert self.other_owner.delinquent is False
+
+ expected_calls = [
+ call(
+ to_addr=self.owner.email,
+ subject="You're all set",
+ template_name="success-after-failed-payment",
+ amount=240,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_1.email,
+ subject="You're all set",
+ template_name="success-after-failed-payment",
+ amount=240,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_2.email,
+ subject="You're all set",
+ template_name="success-after-failed-payment",
+ amount=240,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ ]
+
+ mocked_send_email.assert_has_calls(expected_calls)
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ def test_invoice_payment_failed_skips_delinquency_if_payment_intent_requires_action(
+ self, retrieve_paymentintent_mock
+ ):
+ self.owner.delinquent = False
+ self.owner.save()
+
+ retrieve_paymentintent_mock.return_value = stripe.PaymentIntent.construct_from(
+ {
+ "status": "requires_action",
+ "next_action": {"type": "verify_with_microdeposits"},
+ },
+ "payment_intent_asdf",
+ )
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_failed",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ "payment_intent": "payment_intent_asdf",
+ "default_payment_method": None,
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is False
+ retrieve_paymentintent_mock.assert_called_once_with("payment_intent_asdf")
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ def test_invoice_payment_failed_sets_owner_delinquent_true(
+ self, retrieve_paymentintent_mock
+ ):
+ self.owner.delinquent = False
+ self.owner.save()
+
+ retrieve_paymentintent_mock.return_value = stripe.PaymentIntent.construct_from(
+ {
+ "status": "requires_action",
+ "next_action": {"type": "verify_with_microdeposits"},
+ },
+ "payment_intent_asdf",
+ )
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_failed",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ "payment_intent": "payment_intent_asdf",
+ "default_payment_method": {},
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is True
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(
+ self, retrieve_paymentintent_mock
+ ):
+ self.add_second_owner()
+ self.owner.delinquent = False
+ self.owner.save()
+ self.other_owner.delinquent = False
+ self.other_owner.save()
+
+ retrieve_paymentintent_mock.return_value = MockPaymentIntent()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_failed",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ "payment_intent": "payment_intent_asdf",
+ "default_payment_method": {},
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is True
+ assert self.other_owner.delinquent is True
+
+ @patch("services.task.TaskService.send_email")
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ def test_invoice_payment_failed_sends_email_to_admins(
+ self,
+ retrieve_paymentintent_mock,
+ mocked_send_email,
+ ):
+ non_admin = OwnerFactory(email="non-admin@codecov.io")
+ admin_1 = OwnerFactory(email="admin1@codecov.io")
+ admin_2 = OwnerFactory(email="admin2@codecov.io")
+ self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
+ self.owner.plan_activated_users = [non_admin.ownerid]
+ self.owner.email = "owner@codecov.io"
+ self.owner.save()
+
+ retrieve_paymentintent_mock.return_value = MockPaymentIntent()
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_failed",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ "payment_intent": "payment_intent_asdf",
+ "default_payment_method": {},
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is True
+
+ expected_calls = [
+ call(
+ to_addr=self.owner.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=self.owner.username,
+ amount=240,
+ card_type="visa",
+ last_four="1234",
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_1.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=admin_1.username,
+ amount=240,
+ card_type="visa",
+ last_four="1234",
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_2.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=admin_2.username,
+ amount=240,
+ card_type="visa",
+ last_four="1234",
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ ]
+
+ mocked_send_email.assert_has_calls(expected_calls)
+
+ @patch("services.task.TaskService.send_email")
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ def test_invoice_payment_failed_sends_email_to_admins_no_card(
+ self,
+ retrieve_paymentintent_mock,
+ mocked_send_email,
+ ):
+ non_admin = OwnerFactory(email="non-admin@codecov.io")
+ admin_1 = OwnerFactory(email="admin1@codecov.io")
+ admin_2 = OwnerFactory(email="admin2@codecov.io")
+ self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
+ self.owner.plan_activated_users = [non_admin.ownerid]
+ self.owner.email = "owner@codecov.io"
+ self.owner.save()
+
+ retrieve_paymentintent_mock.return_value = MockPaymentIntent(noCard=True)
+
+ response = self._send_event(
+ payload={
+ "type": "invoice.payment_failed",
+ "data": {
+ "object": {
+ "customer": self.owner.stripe_customer_id,
+ "subscription": self.owner.stripe_subscription_id,
+ "default_payment_method": None,
+ "total": 24000,
+ "hosted_invoice_url": "https://stripe.com",
+ "payment_intent": {
+ "id": "payment_intent_asdf",
+ "status": "succeeded",
+ },
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert self.owner.delinquent is True
+
+ expected_calls = [
+ call(
+ to_addr=self.owner.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=self.owner.username,
+ amount=240,
+ card_type=None,
+ last_four=None,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_1.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=admin_1.username,
+ amount=240,
+ card_type=None,
+ last_four=None,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ call(
+ to_addr=admin_2.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=admin_2.username,
+ amount=240,
+ card_type=None,
+ last_four=None,
+ cta_link="https://stripe.com",
+ date=datetime.now().strftime("%B %-d, %Y"),
+ ),
+ ]
+ mocked_send_email.assert_has_calls(expected_calls)
+
+ def test_customer_subscription_deleted_sets_plan_to_free(self):
+ self.owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.owner.plan_user_count = 20
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.deleted",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"name": self.owner.plan},
+ "status": "active",
+ }
+ },
+ }
+ )
+ self.owner.refresh_from_db()
+
+ assert self.owner.plan == DEFAULT_FREE_PLAN
+ assert self.owner.plan_user_count == 1
+ assert self.owner.plan_activated_users is None
+ assert self.owner.stripe_subscription_id is None
+
+ def test_customer_subscription_deleted_sets_plan_to_free_mutliple_owner(self):
+ self.add_second_owner()
+ self.owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.owner.plan_user_count = 20
+ self.owner.save()
+ self.other_owner.plan = PlanName.CODECOV_PRO_YEARLY.value
+ self.other_owner.plan_user_count = 20
+ self.other_owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.deleted",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"name": self.owner.plan},
+ "status": "active",
+ }
+ },
+ }
+ )
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+
+ assert self.owner.plan == DEFAULT_FREE_PLAN
+ assert self.owner.plan_user_count == 1
+ assert self.owner.plan_activated_users is None
+ assert self.owner.stripe_subscription_id is None
+
+ assert self.other_owner.plan == DEFAULT_FREE_PLAN
+ assert self.other_owner.plan_user_count == 1
+ assert self.other_owner.plan_activated_users is None
+ assert self.other_owner.stripe_subscription_id is None
+
+ def test_customer_subscription_deleted_deactivates_all_repos(self):
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+
+ assert (
+ self.owner.repository_set.filter(activated=True, active=True).count() == 3
+ )
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.deleted",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"name": PlanName.CODECOV_PRO_MONTHLY.value},
+ "status": "active",
+ }
+ },
+ }
+ )
+
+ assert (
+ self.owner.repository_set.filter(activated=False, active=False).count() == 3
+ )
+
+ def test_customer_subscription_deleted_deactivates_all_repos_multiple_owner(self):
+ self.add_second_owner()
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+
+ assert (
+ self.owner.repository_set.filter(activated=True, active=True).count() == 3
+ )
+ assert (
+ self.other_owner.repository_set.filter(activated=True, active=True).count()
+ == 3
+ )
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.deleted",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"name": PlanName.CODECOV_PRO_MONTHLY.value},
+ "status": "active",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+
+ assert (
+ self.owner.repository_set.filter(activated=False, active=False).count() == 3
+ )
+ assert (
+ self.other_owner.repository_set.filter(
+ activated=False, active=False
+ ).count()
+ == 3
+ )
+
+ @patch("logging.Logger.info")
+ def test_customer_subscription_deleted_no_customer(self, log_info_mock):
+ self.owner.plan = PlanName.CODECOV_PRO_MONTHLY.value
+ self.owner.plan_user_count = 20
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.deleted",
+ "data": {
+ "object": {
+ "id": "HUH",
+ "customer": "nah",
+ "plan": {"name": self.owner.plan},
+ "status": "active",
+ }
+ },
+ }
+ )
+
+ log_info_mock.assert_called_with(
+ "Customer Subscription Deleted - Couldn't find owner, subscription likely already deleted",
+ extra={
+ "stripe_subscription_id": "HUH",
+ "stripe_customer_id": "nah",
+ },
+ )
+
+ def test_customer_created_logs_and_doesnt_crash(self):
+ self._send_event(
+ payload={
+ "type": "customer.created",
+ "data": {"object": {"id": "FOEKDCDEQ", "email": "test@email.com"}},
+ }
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ def test_customer_subscription_created_early_returns_if_unverified_payment(
+ self, mock_has_unverified
+ ):
+ mock_has_unverified.return_value = True
+ self.owner.stripe_subscription_id = None
+ self.owner.stripe_customer_id = None
+ self.owner.plan = "users-basic"
+ self.owner.save()
+
+ response = self._send_event(
+ payload={
+ "type": "customer.subscription.created",
+ "data": {
+ "object": {
+ "id": "sub_123",
+ "customer": "cus_123",
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ # Subscription and customer IDs should be set
+ assert self.owner.stripe_subscription_id == "sub_123"
+ assert self.owner.stripe_customer_id == "cus_123"
+ # But plan should not be updated since payment is unverified
+ assert self.owner.plan == "users-basic"
+ mock_has_unverified.assert_called_once()
+
+ def test_customer_subscription_created_does_nothing_if_no_plan_id(self):
+ self.owner.stripe_subscription_id = None
+ self.owner.stripe_customer_id = None
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.created",
+ "data": {
+ "object": {
+ "id": "FOEKDCDEQ",
+ "customer": "sdo050493",
+ "plan": {"id": None},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.stripe_subscription_id is None
+ assert self.owner.stripe_customer_id is None
+
+ def test_customer_subscription_created_does_nothing_if_plan_not_paid_user_plan(
+ self,
+ ):
+ self.owner.stripe_subscription_id = None
+ self.owner.stripe_customer_id = None
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.created",
+ "data": {
+ "object": {
+ "id": "FOEKDCDEQ",
+ "customer": "sdo050493",
+ "plan": {"id": "plan_free"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.stripe_subscription_id is None
+ assert self.owner.stripe_customer_id is None
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ def test_customer_subscription_created_sets_plan_info(
+ self, has_unverified_initial_payment_method_mock
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.stripe_subscription_id = None
+ self.owner.stripe_customer_id = None
+ self.owner.save()
+
+ stripe_subscription_id = "FOEKDCDEQ"
+ stripe_customer_id = "sdo050493"
+ plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ quantity = 20
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.created",
+ "data": {
+ "object": {
+ "id": stripe_subscription_id,
+ "customer": stripe_customer_id,
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": quantity,
+ "status": "active",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.stripe_subscription_id == stripe_subscription_id
+ assert self.owner.stripe_customer_id == stripe_customer_id
+ assert self.owner.plan_user_count == quantity
+ assert self.owner.plan_auto_activate is True
+ assert self.owner.plan == plan_name
+
+ @freeze_time("2023-06-19")
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("shared.plan.service.PlanService.expire_trial_when_upgrading")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_created_can_trigger_trial_expiration(
+ self,
+ c_mock,
+ pm_mock,
+ expire_trial_when_upgrading_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ stripe_subscription_id = "FOEKDCDEQ"
+ stripe_customer_id = "sdo050493"
+ quantity = 20
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.created",
+ "data": {
+ "object": {
+ "id": stripe_subscription_id,
+ "customer": stripe_customer_id,
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": quantity,
+ "default_payment_method": "blabla",
+ }
+ },
+ }
+ )
+
+ expire_trial_when_upgrading_mock.assert_called_once()
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_does_not_change_subscription_if_not_paid_user_plan(
+ self,
+ c_mock,
+ pm_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.plan = DEFAULT_FREE_PLAN
+ self.owner.plan_user_count = 0
+ self.owner.plan_auto_activate = False
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"id": "plan_free"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ "status": "active",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.plan == DEFAULT_FREE_PLAN
+ assert self.owner.plan_user_count == 0
+ assert self.owner.plan_auto_activate == False
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("logging.Logger.info")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_does_not_change_subscription_if_there_is_a_schedule(
+ self,
+ c_mock,
+ pm_mock,
+ log_info_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.plan = "users-pr-inappy"
+ self.owner.plan_user_count = 10
+ self.owner.plan_auto_activate = False
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ "status": "active",
+ "schedule": "sub_sched_1K8xfkGlVGuVgOrkxvroyZdH",
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.plan == "users-pr-inappy"
+ assert self.owner.plan_user_count == 10
+ assert self.owner.plan_auto_activate == False
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ log_info_mock.assert_called_once_with(
+ "Stripe webhook event received",
+ extra={"stripe_webhook_event": "customer.subscription.updated"},
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_sets_free_and_deactivates_all_repos_if_incomplete_expired(
+ self,
+ c_mock,
+ pm_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.plan = "users-pr-inappy"
+ self.owner.plan_user_count = 10
+ self.owner.plan_auto_activate = False
+ self.owner.save()
+
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ assert self.owner.repository_set.count() == 3
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {
+ "id": "plan_pro_yearly",
+ },
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ "status": "incomplete_expired",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+ self.owner.refresh_from_db()
+
+ assert self.owner.plan == DEFAULT_FREE_PLAN
+ assert self.owner.plan_user_count == 1
+ assert self.owner.plan_auto_activate == False
+ assert self.owner.stripe_subscription_id is None
+ assert (
+ self.owner.repository_set.filter(active=True, activated=True).count() == 0
+ )
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ def test_customer_subscription_updated_payment_failed(
+ self, has_unverified_initial_payment_method_mock
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.delinquent = False
+ self.owner.save()
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"id": "?"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ "status": "active",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ "pending_update": {
+ "expires_at": 1571194285,
+ "subscription_items": [
+ {
+ "id": "si_09IkI4u3ZypJUk5onGUZpe8O",
+ "price": "price_CBb6IXqvTLXp3f",
+ }
+ ],
+ },
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.delinquent == True
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_sets_free_and_deactivates_all_repos_if_incomplete_expired_multiple_owner(
+ self,
+ c_mock,
+ pm_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.add_second_owner()
+ self.owner.plan = "users-pr-inappy"
+ self.owner.plan_user_count = 10
+ self.owner.plan_auto_activate = False
+ self.owner.save()
+ self.other_owner.plan = "users-pr-inappy"
+ self.other_owner.plan_user_count = 10
+ self.other_owner.plan_auto_activate = False
+ self.other_owner.save()
+
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+ RepositoryFactory(author=self.other_owner, activated=True, active=True)
+ assert self.owner.repository_set.count() == 3
+ assert self.other_owner.repository_set.count() == 3
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {
+ "id": "plan_pro_yearly",
+ },
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": 20,
+ "status": "incomplete_expired",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+
+ assert self.owner.plan == DEFAULT_FREE_PLAN
+ assert self.owner.plan_user_count == 1
+ assert self.owner.plan_auto_activate == False
+ assert self.owner.stripe_subscription_id is None
+ assert (
+ self.owner.repository_set.filter(active=True, activated=True).count() == 0
+ )
+ assert self.other_owner.plan == DEFAULT_FREE_PLAN
+ assert self.other_owner.plan_user_count == 1
+ assert self.other_owner.plan_auto_activate == False
+ assert self.other_owner.stripe_subscription_id is None
+ assert (
+ self.other_owner.repository_set.filter(active=True, activated=True).count()
+ == 0
+ )
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_sets_fields_on_success(
+ self,
+ c_mock,
+ pm_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.owner.plan = "users-free"
+ self.owner.plan_user_count = 5
+ self.owner.plan_auto_activate = False
+
+ plan_name = "users-pr-inappy"
+ quantity = 20
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": quantity,
+ "status": "active",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.plan == plan_name
+ assert self.owner.plan_user_count == quantity
+ assert self.owner.plan_auto_activate == True
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ @patch("billing.views.StripeWebhookHandler._has_unverified_initial_payment_method")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_customer_subscription_updated_sets_fields_on_success_multiple_owner(
+ self,
+ c_mock,
+ pm_mock,
+ has_unverified_initial_payment_method_mock,
+ ):
+ has_unverified_initial_payment_method_mock.return_value = False
+ self.add_second_owner()
+ self.owner.plan = "users-free"
+ self.owner.plan_user_count = 5
+ self.owner.plan_auto_activate = False
+ self.other_owner.plan = "users-free"
+ self.other_owner.plan_user_count = 5
+ self.other_owner.plan_auto_activate = False
+
+ plan_name = "users-pr-inappy"
+ quantity = 20
+
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": self.owner.stripe_subscription_id,
+ "customer": self.owner.stripe_customer_id,
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": self.owner.ownerid},
+ "quantity": quantity,
+ "status": "active",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+ assert self.owner.plan == plan_name
+ assert self.owner.plan_user_count == quantity
+ assert self.owner.plan_auto_activate == True
+ assert self.other_owner.plan == plan_name
+ assert self.other_owner.plan_user_count == quantity
+ assert self.other_owner.plan_auto_activate == True
+ pm_mock.assert_called_once_with(
+ "pm_1LhiRsGlVGuVgOrkQguJXdeV", customer=self.owner.stripe_customer_id
+ )
+ c_mock.assert_called_once_with(
+ self.owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV"},
+ )
+
+ @patch("logging.Logger.error")
+ def test_customer_subscription_updated_logs_error_if_no_matching_owners(
+ self, log_error_mock
+ ):
+ self._send_event(
+ payload={
+ "type": "customer.subscription.updated",
+ "data": {
+ "object": {
+ "id": "sub_notexist",
+ "customer": "cus_notexist",
+ "plan": {"id": "plan_pro_yearly"},
+ "metadata": {"obo_organization": 1},
+ "quantity": 8,
+ "status": "active",
+ "schedule": None,
+ "default_payment_method": "pm_1LhiRsGlVGuVgOrkQguJXdeV",
+ }
+ },
+ }
+ )
+
+ log_error_mock.assert_called_with(
+ "Subscription update requested with for plan attached to no owners",
+ extra={
+ "stripe_subscription_id": "sub_notexist",
+ "stripe_customer_id": "cus_notexist",
+ "plan_id": "plan_pro_yearly",
+ },
+ )
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_subscription_schedule_released_updates_owner_with_existing_subscription(
+ self, retrieve_subscription_mock
+ ):
+ self.owner.plan = "users-pr-inappy"
+ self.owner.plan_user_count = 10
+ self.owner.save()
+
+ self.new_params = {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(
+ self.owner, self.new_params
+ )
+
+ self._send_event(
+ payload={
+ "type": "subscription_schedule.released",
+ "data": {
+ "object": {
+ "released_subscription": "sub_sched_1K8xfkGlVGuVgOrkxvroyZdH"
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ plan = Plan.objects.get(stripe_id=self.new_params["new_plan"])
+ assert self.owner.plan == plan.name
+ assert self.owner.plan_user_count == self.new_params["new_quantity"]
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_subscription_schedule_released_updates_multiple_owners_with_existing_subscription(
+ self, retrieve_subscription_mock
+ ):
+ self.add_second_owner()
+ self.owner.plan = "users-pr-inappy"
+ self.owner.plan_user_count = 10
+ self.owner.save()
+ self.other_owner.plan = "users-pr-inappy"
+ self.other_owner.plan_user_count = 10
+ self.other_owner.save()
+
+ self.new_params = {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(
+ self.owner, self.new_params
+ )
+
+ self._send_event(
+ payload={
+ "type": "subscription_schedule.released",
+ "data": {
+ "object": {
+ "released_subscription": "sub_sched_1K8xfkGlVGuVgOrkxvroyZdH"
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ self.other_owner.refresh_from_db()
+
+ plan = Plan.objects.get(stripe_id=self.new_params["new_plan"])
+ assert self.owner.plan == plan.name
+ assert self.owner.plan_user_count == self.new_params["new_quantity"]
+ assert self.other_owner.plan == plan.name
+ assert self.other_owner.plan_user_count == self.new_params["new_quantity"]
+
+ @patch("logging.Logger.error")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_subscription_schedule_released_logs_error_if_owner_does_not_exist(
+ self,
+ retrieve_subscription_mock,
+ log_error_mock,
+ ):
+ self.new_params = {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_notexist",
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(
+ self.owner, self.new_params
+ )
+
+ self._send_event(
+ payload={
+ "type": "subscription_schedule.released",
+ "data": {
+ "object": {
+ "released_subscription": "sub_sched_1K8xfkGlVGuVgOrkxvroyZdH"
+ }
+ },
+ }
+ )
+
+ log_error_mock.assert_called_with(
+ "Subscription schedule released requested with invalid subscription",
+ extra={
+ "stripe_subscription_id": "sub_notexist",
+ "stripe_customer_id": "cus_123",
+ "plan_id": "plan_pro_yearly",
+ },
+ )
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_subscription_schedule_created_logs_a_new_schedule(
+ self, retrieve_subscription_mock
+ ):
+ original_plan = "users-pr-inappy"
+ original_quantity = 10
+ subscription_id = "sub_1K8xfkGlVGuVgOrkxvroyZdH"
+ self.owner.plan = original_plan
+ self.owner.plan_user_count = original_quantity
+ self.owner.save()
+
+ self.params = {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": subscription_id,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(
+ self.owner, self.params
+ )
+
+ self._send_event(
+ payload={
+ "type": "subscription_schedule.created",
+ "data": {"object": {"subscription": subscription_id}},
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.plan == original_plan
+ assert self.owner.plan_user_count == original_quantity
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_subscription_schedule_updated_logs_changes_to_schedule(
+ self, retrieve_subscription_mock
+ ):
+ original_plan = "users-pr-inappy"
+ original_quantity = 10
+ subscription_id = "sub_1K8xfkGlVGuVgOrkxvroyZdH"
+ new_plan = "plan_pro_yearly"
+ new_quantity = 7
+ self.owner.plan = original_plan
+ self.owner.plan_user_count = original_quantity
+ self.owner.save()
+
+ self.params = {
+ "new_plan": new_plan,
+ "new_quantity": new_quantity,
+ "subscription_id": subscription_id,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(
+ self.owner, self.params
+ )
+
+ self._send_event(
+ payload={
+ "type": "subscription_schedule.updated",
+ "data": {
+ "object": {
+ "subscription": subscription_id,
+ "phases": [
+ {},
+ {"items": [{"plan": new_plan, "quantity": new_quantity}]},
+ ],
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.plan == original_plan
+ assert self.owner.plan_user_count == original_quantity
+
+ def test_checkout_session_completed_sets_stripe_ids(self):
+ self.owner.stripe_customer_id = None
+ self.owner.save()
+
+ expected_customer_id = "cus_1234"
+ expected_subscription_id = "sub_7890"
+
+ self._send_event(
+ payload={
+ "type": "checkout.session.completed",
+ "data": {
+ "object": {
+ "customer": expected_customer_id,
+ "client_reference_id": str(self.owner.ownerid),
+ "subscription": expected_subscription_id,
+ }
+ },
+ }
+ )
+
+ self.owner.refresh_from_db()
+ assert self.owner.stripe_customer_id == expected_customer_id
+ assert self.owner.stripe_subscription_id == expected_subscription_id
+
+ @patch("billing.views.stripe.Subscription.modify")
+ def test_customer_update_but_not_payment_method(self, subscription_modify_mock):
+ payment_method = "pm_123"
+ self._send_event(
+ payload={
+ "type": "customer.updated",
+ "data": {
+ "object": {
+ "invoice_settings": {"default_payment_method": None},
+ "subscriptions": {
+ "data": [{"default_payment_method": payment_method}]
+ },
+ }
+ },
+ }
+ )
+
+ subscription_modify_mock.assert_not_called()
+
+ @patch("billing.views.stripe.Subscription.modify")
+ def test_customer_update_but_payment_method_is_same(self, subscription_modify_mock):
+ payment_method = "pm_123"
+ self._send_event(
+ payload={
+ "type": "customer.updated",
+ "data": {
+ "object": {
+ "invoice_settings": {"default_payment_method": payment_method},
+ "subscriptions": {
+ "data": [{"default_payment_method": payment_method}]
+ },
+ }
+ },
+ }
+ )
+
+ subscription_modify_mock.assert_not_called()
+
+ @patch("billing.views.stripe.Subscription.modify")
+ def test_customer_update_payment_method(self, subscription_modify_mock):
+ payment_method = "pm_123"
+ old_payment_method = "pm_321"
+ self._send_event(
+ payload={
+ "type": "customer.updated",
+ "data": {
+ "object": {
+ "id": "cus_123",
+ "invoice_settings": {"default_payment_method": payment_method},
+ "subscriptions": {
+ "data": [
+ {
+ "id": "sub_123",
+ "default_payment_method": old_payment_method,
+ }
+ ]
+ },
+ }
+ },
+ }
+ )
+
+ subscription_modify_mock.assert_called_once_with(
+ "sub_123", default_payment_method=payment_method
+ )
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_has_unverified_initial_payment_method(
+ self, invoice_retrieve_mock, payment_intent_retrieve_mock
+ ):
+ subscription = Mock()
+ subscription.latest_invoice = "inv_123"
+
+ class MockPaymentIntent:
+ status = "requires_action"
+
+ invoice_retrieve_mock.return_value = Mock(payment_intent="pi_123")
+ payment_intent_retrieve_mock.return_value = stripe.PaymentIntent.construct_from(
+ {
+ "status": "requires_action",
+ "next_action": {"type": "verify_with_microdeposits"},
+ },
+ "payment_intent_asdf",
+ )
+
+ handler = StripeWebhookHandler()
+ result = handler._has_unverified_initial_payment_method(subscription)
+
+ assert result is True
+ invoice_retrieve_mock.assert_called_once_with("inv_123")
+ payment_intent_retrieve_mock.assert_called_once_with("pi_123")
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_has_unverified_initial_payment_method_no_payment_intent(
+ self, invoice_retrieve_mock, payment_intent_retrieve_mock
+ ):
+ subscription = Mock()
+ subscription.latest_invoice = "inv_123"
+
+ invoice_retrieve_mock.return_value = Mock(payment_intent=None)
+
+ handler = StripeWebhookHandler()
+ result = handler._has_unverified_initial_payment_method(subscription)
+
+ assert result is False
+ invoice_retrieve_mock.assert_called_once_with("inv_123")
+ payment_intent_retrieve_mock.assert_not_called()
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_has_unverified_initial_payment_method_payment_intent_succeeded(
+ self, invoice_retrieve_mock, payment_intent_retrieve_mock
+ ):
+ subscription = stripe.Subscription.construct_from(
+ {"latest_invoice": "inv_123"}, "sub_123"
+ )
+
+ invoice_retrieve_mock.return_value = stripe.Invoice.construct_from(
+ {"payment_intent": "pi_123"}, "inv_123"
+ )
+ payment_intent_retrieve_mock.return_value = stripe.PaymentIntent.construct_from(
+ {"status": "succeeded"}, "payment_intent_asdf"
+ )
+
+ handler = StripeWebhookHandler()
+ result = handler._has_unverified_initial_payment_method(subscription)
+
+ assert result is False
+ invoice_retrieve_mock.assert_called_once_with("inv_123")
+ payment_intent_retrieve_mock.assert_called_once_with("pi_123")
+
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.PaymentMethod.retrieve")
+ def test_check_and_handle_delayed_notification_payment_methods(
+ self,
+ payment_method_retrieve_mock,
+ subscription_modify_mock,
+ customer_modify_mock,
+ payment_method_attach_mock,
+ ):
+ class MockPaymentMethod:
+ type = "us_bank_account"
+ us_bank_account = {}
+ id = "pm_123"
+
+ payment_method_retrieve_mock.return_value = MockPaymentMethod()
+
+ self.owner.stripe_subscription_id = "sub_123"
+ self.owner.stripe_customer_id = "cus_123"
+ self.owner.save()
+
+ handler = StripeWebhookHandler()
+ handler._check_and_handle_delayed_notification_payment_methods(
+ "cus_123", "pm_123"
+ )
+
+ payment_method_retrieve_mock.assert_called_once_with("pm_123")
+ payment_method_attach_mock.assert_called_once_with(
+ payment_method_retrieve_mock.return_value, customer="cus_123"
+ )
+ customer_modify_mock.assert_called_once_with(
+ "cus_123",
+ invoice_settings={
+ "default_payment_method": payment_method_retrieve_mock.return_value
+ },
+ )
+ subscription_modify_mock.assert_called_once_with(
+ "sub_123", default_payment_method=payment_method_retrieve_mock.return_value
+ )
+
+ @patch("logging.Logger.error")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.PaymentMethod.retrieve")
+ def test_check_and_handle_delayed_notification_payment_methods_no_subscription(
+ self,
+ payment_method_retrieve_mock,
+ subscription_modify_mock,
+ customer_modify_mock,
+ payment_method_attach_mock,
+ log_error_mock,
+ ):
+ class MockPaymentMethod:
+ type = "us_bank_account"
+ us_bank_account = {}
+ id = "pm_123"
+
+ payment_method_retrieve_mock.return_value = MockPaymentMethod()
+
+ self.owner.stripe_subscription_id = None
+ self.owner.stripe_customer_id = "cus_123"
+ self.owner.save()
+
+ handler = StripeWebhookHandler()
+ handler._check_and_handle_delayed_notification_payment_methods(
+ "cus_123", "pm_123"
+ )
+
+ payment_method_retrieve_mock.assert_called_once_with("pm_123")
+ payment_method_attach_mock.assert_not_called()
+ customer_modify_mock.assert_not_called()
+ subscription_modify_mock.assert_not_called()
+
+ log_error_mock.assert_called_once_with(
+ "No owners found with that customer_id, something went wrong",
+ extra=dict(customer_id="cus_123"),
+ )
+
+ @patch("logging.Logger.error")
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.PaymentMethod.retrieve")
+ def test_check_and_handle_delayed_notification_payment_methods_no_customer(
+ self,
+ payment_method_retrieve_mock,
+ subscription_modify_mock,
+ customer_modify_mock,
+ payment_method_attach_mock,
+ log_error_mock,
+ ):
+ class MockPaymentMethod:
+ type = "us_bank_account"
+ us_bank_account = {}
+ id = "pm_123"
+
+ payment_method_retrieve_mock.return_value = MockPaymentMethod()
+
+ handler = StripeWebhookHandler()
+ handler._check_and_handle_delayed_notification_payment_methods(
+ "cus_1", "pm_123"
+ )
+
+ payment_method_retrieve_mock.assert_called_once_with("pm_123")
+ payment_method_attach_mock.assert_not_called()
+ customer_modify_mock.assert_not_called()
+ subscription_modify_mock.assert_not_called()
+
+ log_error_mock.assert_called_once_with(
+ "No owners found with that customer_id, something went wrong",
+ extra=dict(customer_id="cus_1"),
+ )
+
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.PaymentMethod.retrieve")
+ def test_check_and_handle_delayed_notification_payment_methods_multiple_subscriptions(
+ self,
+ payment_method_retrieve_mock,
+ subscription_modify_mock,
+ customer_modify_mock,
+ payment_method_attach_mock,
+ ):
+ class MockPaymentMethod:
+ type = "us_bank_account"
+ us_bank_account = {}
+ id = "pm_123"
+
+ payment_method_retrieve_mock.return_value = MockPaymentMethod()
+
+ self.owner.stripe_subscription_id = "sub_123"
+ self.owner.stripe_customer_id = "cus_123"
+ self.owner.save()
+
+ OwnerFactory(stripe_subscription_id="sub_124", stripe_customer_id="cus_123")
+
+ handler = StripeWebhookHandler()
+ handler._check_and_handle_delayed_notification_payment_methods(
+ "cus_123", "pm_123"
+ )
+
+ payment_method_retrieve_mock.assert_called_once_with("pm_123")
+ payment_method_attach_mock.assert_called_once_with(
+ payment_method_retrieve_mock.return_value, customer="cus_123"
+ )
+ customer_modify_mock.assert_called_once_with(
+ "cus_123",
+ invoice_settings={
+ "default_payment_method": payment_method_retrieve_mock.return_value
+ },
+ )
+ subscription_modify_mock.assert_has_calls(
+ [
+ call(
+ "sub_123",
+ default_payment_method=payment_method_retrieve_mock.return_value,
+ ),
+ call(
+ "sub_124",
+ default_payment_method=payment_method_retrieve_mock.return_value,
+ ),
+ ]
+ )
+
+ @patch(
+ "billing.views.StripeWebhookHandler._check_and_handle_delayed_notification_payment_methods"
+ )
+ @patch("logging.Logger.info")
+ def test_payment_intent_succeeded(
+ self, log_info_mock, check_and_handle_delayed_notification_mock
+ ):
+ class MockPaymentIntent:
+ id = "pi_123"
+ customer = "cus_123"
+ payment_method = "pm_123"
+
+ handler = StripeWebhookHandler()
+ handler.payment_intent_succeeded(MockPaymentIntent())
+
+ check_and_handle_delayed_notification_mock.assert_called_once_with(
+ "cus_123", "pm_123"
+ )
+ log_info_mock.assert_called_once_with(
+ "Payment intent succeeded",
+ extra=dict(
+ stripe_customer_id="cus_123",
+ payment_intent_id="pi_123",
+ payment_method_type="pm_123",
+ ),
+ )
+
+ @patch(
+ "billing.views.StripeWebhookHandler._check_and_handle_delayed_notification_payment_methods"
+ )
+ @patch("logging.Logger.info")
+ def test_setup_intent_succeeded(
+ self, log_info_mock, check_and_handle_delayed_notification_mock
+ ):
+ class MockSetupIntent:
+ id = "seti_123"
+ customer = "cus_123"
+ payment_method = "pm_123"
+
+ handler = StripeWebhookHandler()
+ handler.setup_intent_succeeded(MockSetupIntent())
+
+ check_and_handle_delayed_notification_mock.assert_called_once_with(
+ "cus_123", "pm_123"
+ )
+ log_info_mock.assert_called_once_with(
+ "Setup intent succeeded",
+ extra=dict(
+ stripe_customer_id="cus_123",
+ setup_intent_id="seti_123",
+ payment_method_type="pm_123",
+ ),
+ )
diff --git a/apps/codecov-api/billing/urls.py b/apps/codecov-api/billing/urls.py
new file mode 100644
index 0000000000..36b1a7ba82
--- /dev/null
+++ b/apps/codecov-api/billing/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from .views import StripeWebhookHandler
+
+urlpatterns = [
+ path("stripe/webhooks", StripeWebhookHandler.as_view(), name="stripe-webhook")
+]
diff --git a/apps/codecov-api/billing/views.py b/apps/codecov-api/billing/views.py
new file mode 100644
index 0000000000..19954146bd
--- /dev/null
+++ b/apps/codecov-api/billing/views.py
@@ -0,0 +1,630 @@
+import logging
+from datetime import datetime
+from typing import Any, List
+
+import stripe
+from django.conf import settings
+from django.db.models import QuerySet
+from django.http import HttpRequest
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.plan.service import PlanService
+
+from billing.helpers import get_all_admins_for_owners
+from codecov_auth.models import Owner, Plan
+from services.task.task import TaskService
+
+from .constants import StripeHTTPHeaders, StripeWebhookEvents
+
+if settings.STRIPE_API_KEY:
+ stripe.api_key = settings.STRIPE_API_KEY
+ stripe.api_version = "2024-12-18.acacia"
+
+log = logging.getLogger(__name__)
+
+
+class StripeWebhookHandler(APIView):
+ permission_classes = [AllowAny]
+
+ def _log_updated(self, updated: List[Owner]) -> None:
+ if len(updated) >= 1:
+ log.info(
+ f"Successfully updated info for {len(updated)} owner(s)",
+ extra=dict(owners=[owner.ownerid for owner in updated]),
+ )
+
+ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None:
+ log.info(
+ "Invoice Payment Succeeded - Setting delinquency status False",
+ extra=dict(
+ stripe_customer_id=invoice.customer,
+ stripe_subscription_id=invoice.subscription,
+ ),
+ )
+ owners: QuerySet[Owner] = Owner.objects.filter(
+ stripe_customer_id=invoice.customer,
+ stripe_subscription_id=invoice.subscription,
+ delinquent=True,
+ )
+
+ if not owners.exists():
+ return
+
+ admins = get_all_admins_for_owners(owners)
+ owners.update(delinquent=False)
+ self._log_updated(list(owners))
+
+ # Send a success email to all admins
+
+ task_service = TaskService()
+ template_vars = {
+ "amount": invoice.total / 100,
+ "date": datetime.now().strftime("%B %-d, %Y"),
+ "cta_link": invoice.hosted_invoice_url,
+ }
+
+ for admin in admins:
+ if admin.email:
+ task_service.send_email(
+ to_addr=admin.email,
+ subject="You're all set",
+ template_name="success-after-failed-payment",
+ **template_vars,
+ )
+
+ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
+ """
+ Stripe invoice.payment_failed webhook event is emitted when an invoice payment fails
+ (initial or recurring). Note that delayed payment methods (including ACH with
+ microdeposits) may have a failed initial invoice until the account is verified.
+ """
+ if invoice.default_payment_method is None:
+ if invoice.payment_intent:
+ payment_intent = stripe.PaymentIntent.retrieve(invoice.payment_intent)
+ if (
+ payment_intent
+ and payment_intent.get("status") == "requires_action"
+ and payment_intent.get("next_action", {}).get("type")
+ == "verify_with_microdeposits"
+ ):
+ log.info(
+ "Invoice payment failed but still awaiting known customer action, skipping Delinquency actions",
+ extra=dict(
+ stripe_customer_id=invoice.customer,
+ stripe_subscription_id=invoice.subscription,
+ payment_intent_id=invoice.payment_intent,
+ payment_intent_status=payment_intent.status,
+ ),
+ )
+ return
+
+ log.info(
+ "Invoice Payment Failed - Setting Delinquency status True",
+ extra=dict(
+ stripe_customer_id=invoice.customer,
+ stripe_subscription_id=invoice.subscription,
+ ),
+ )
+ owners: QuerySet[Owner] = Owner.objects.filter(
+ stripe_customer_id=invoice.customer,
+ stripe_subscription_id=invoice.subscription,
+ )
+ owners.update(delinquent=True)
+ self._log_updated(list(owners))
+
+ # Send failed payment email to all owner admins
+ admins = get_all_admins_for_owners(owners)
+
+ task_service = TaskService()
+ payment_intent = stripe.PaymentIntent.retrieve(
+ invoice["payment_intent"], expand=["payment_method"]
+ )
+
+ try:
+ card = payment_intent.payment_method.card
+ except AttributeError:
+ card = None
+
+ template_vars = {
+ "amount": invoice.total / 100,
+ "card_type": card.brand if card else None,
+ "last_four": card.last4 if card else None,
+ "cta_link": invoice.hosted_invoice_url,
+ "date": datetime.now().strftime("%B %-d, %Y"),
+ }
+
+ for admin in admins:
+ if admin.email:
+ task_service.send_email(
+ to_addr=admin.email,
+ subject="Your Codecov payment failed",
+ template_name="failed-payment",
+ name=admin.username,
+ **template_vars,
+ )
+
+ def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
+ """
+ Stripe customer.subscription.deleted webhook event is emitted when a subscription is deleted.
+ This happens when an org goes from paid to free (see payment_service.delete_subscription)
+ or when cleaning up an incomplete subscription that never activated (e.g., abandoned async
+ ACH microdeposits verification).
+ """
+ log.info(
+ "Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ previous_subscription_status=subscription.status,
+ ),
+ )
+ owners: QuerySet[Owner] = Owner.objects.filter(
+ stripe_customer_id=subscription.customer,
+ stripe_subscription_id=subscription.id,
+ )
+ if not owners.exists():
+ log.info(
+ "Customer Subscription Deleted - Couldn't find owner, subscription likely already deleted",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ ),
+ )
+ return
+
+ for owner in owners:
+ plan_service = PlanService(current_org=owner)
+ plan_service.set_default_plan_data()
+ owner.repository_set.update(active=False, activated=False)
+
+ self._log_updated(list(owners))
+
+ def subscription_schedule_created(
+ self, schedule: stripe.SubscriptionSchedule
+ ) -> None:
+ subscription = stripe.Subscription.retrieve(schedule["subscription"])
+ sub_item_plan_id = subscription.plan.id
+ plan_name = Plan.objects.get(stripe_id=sub_item_plan_id).name
+ log.info(
+ "Schedule created for customer",
+ extra=dict(
+ stripe_customer_id=subscription.customer,
+ stripe_subscription_id=subscription.id,
+ ownerid=subscription.metadata.get("obo_organization"),
+ plan=plan_name,
+ quantity=subscription.quantity,
+ ),
+ )
+
+ def subscription_schedule_updated(
+ self, schedule: stripe.SubscriptionSchedule
+ ) -> None:
+ if schedule["subscription"]:
+ subscription = stripe.Subscription.retrieve(schedule["subscription"])
+ scheduled_phase = schedule["phases"][-1]
+ scheduled_plan = scheduled_phase["items"][0]
+ plan_id = scheduled_plan["plan"]
+ plan_name = Plan.objects.get(stripe_id=plan_id).name
+ quantity = scheduled_plan["quantity"]
+ log.info(
+ "Schedule updated for customer",
+ extra=dict(
+ stripe_customer_id=subscription.customer,
+ stripe_subscription_id=subscription.id,
+ ownerid=subscription.metadata.get("obo_organization"),
+ plan=plan_name,
+ quantity=quantity,
+ ),
+ )
+
+ def subscription_schedule_released(
+ self, schedule: stripe.SubscriptionSchedule
+ ) -> None:
+ subscription = stripe.Subscription.retrieve(schedule["released_subscription"])
+ owners: QuerySet[Owner] = Owner.objects.filter(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ )
+ if not owners.exists():
+ log.error(
+ "Subscription schedule released requested with invalid subscription",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan_id=subscription.plan.id,
+ ),
+ )
+ return
+
+ sub_item_plan_id = subscription.plan.id
+ plan_name = Plan.objects.get(stripe_id=sub_item_plan_id).name
+ for owner in owners:
+ plan_service = PlanService(current_org=owner)
+ plan_service.update_plan(name=plan_name, user_count=subscription.quantity)
+
+ requesting_user_id = subscription.metadata.get("obo")
+ log.info(
+ "Successfully updated customer plan info",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan=plan_name,
+ quantity=subscription.quantity,
+ owners=[owner.ownerid for owner in owners],
+ requesting_user_id=requesting_user_id,
+ ),
+ )
+
+ def customer_created(self, customer: stripe.Customer) -> None:
+ # Based on what stripe doesn't gives us (an ownerid!)
+ # in this event we cannot reliably create a customer,
+ # so we're just logging that we created the event and
+ # relying on customer.subscription.created to handle sub creation
+ log.info("Customer created", extra=dict(stripe_customer_id=customer.id))
+
+ def customer_subscription_created(self, subscription: stripe.Subscription) -> None:
+ """
+ Stripe customer.subscription.created webhook event is emitted when a subscription is created.
+ This happens when an owner completes a CheckoutSession for a new subscription.
+ """
+ sub_item_plan_id = subscription.plan.id
+
+ if not sub_item_plan_id:
+ log.warning(
+ "Subscription created, but missing plan_id",
+ extra=dict(
+ stripe_customer_id=subscription.customer,
+ ownerid=subscription.metadata.get("obo_organization"),
+ subscription_plan=subscription.plan,
+ ),
+ )
+ return
+
+ try:
+ plan = Plan.objects.get(stripe_id=sub_item_plan_id)
+ except Plan.DoesNotExist:
+ log.warning(
+ "Subscription creation requested for invalid plan",
+ extra=dict(
+ stripe_customer_id=subscription.customer,
+ ownerid=subscription.metadata.get("obo_organization"),
+ plan_id=sub_item_plan_id,
+ ),
+ )
+ return
+
+ plan_name = plan.name
+
+ log.info(
+ "Subscription created for customer",
+ extra=dict(
+ stripe_customer_id=subscription.customer,
+ stripe_subscription_id=subscription.id,
+ ownerid=subscription.metadata.get("obo_organization"),
+ plan=plan_name,
+ quantity=subscription.quantity,
+ ),
+ )
+ # add the subscription_id and customer_id to the owner
+ owner = Owner.objects.get(ownerid=subscription.metadata.get("obo_organization"))
+ owner.stripe_subscription_id = subscription.id
+ owner.stripe_customer_id = subscription.customer
+ owner.save()
+
+ # We may reach here if the subscription was created with a payment method
+ # that is awaiting verification (e.g. ACH microdeposits)
+ if self._has_unverified_initial_payment_method(subscription):
+ log.info(
+ "Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
+ extra=dict(
+ subscription_id=subscription.id,
+ customer_id=subscription.customer,
+ ),
+ )
+ return
+
+ plan_service = PlanService(current_org=owner)
+ plan_service.expire_trial_when_upgrading()
+
+ plan_service.update_plan(name=plan_name, user_count=subscription.quantity)
+
+ log.info(
+ "Successfully updated customer plan info",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan=plan_name,
+ quantity=subscription.quantity,
+ ),
+ )
+
+ self._log_updated([owner])
+
+ def _has_unverified_initial_payment_method(
+ self, subscription: stripe.Subscription
+ ) -> bool:
+ """
+ Helper method to check if a subscription's latest invoice has a payment intent
+ that requires verification (e.g. ACH microdeposits). This indicates that
+ there is an unverified payment method from the initial CheckoutSession.
+ """
+ latest_invoice = stripe.Invoice.retrieve(subscription.latest_invoice)
+ if latest_invoice and latest_invoice.payment_intent:
+ payment_intent = stripe.PaymentIntent.retrieve(
+ latest_invoice.payment_intent
+ )
+ return (
+ payment_intent
+ and payment_intent.get("status") == "requires_action"
+ and payment_intent.get("next_action")
+ and payment_intent.get("next_action", {}).get("type")
+ == "verify_with_microdeposits"
+ )
+ return False
+
+ def customer_subscription_updated(self, subscription: stripe.Subscription) -> None:
+ """
+ Stripe customer.subscription.updated webhook event is emitted when a subscription is updated.
+ This can happen when an owner updates the subscription's default payment method using our
+ update_payment_method api
+ """
+ owners: QuerySet[Owner] = Owner.objects.filter(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ )
+ if not owners.exists():
+ log.error(
+ "Subscription update requested with for plan attached to no owners",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan_id=subscription.plan.id,
+ ),
+ )
+ return
+
+ if self._has_unverified_initial_payment_method(subscription):
+ log.info(
+ "Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
+ extra=dict(
+ subscription_id=subscription.id,
+ customer_id=subscription.customer,
+ ),
+ )
+ return
+
+ indication_of_payment_failure = getattr(subscription, "pending_update", None)
+ if indication_of_payment_failure:
+ # payment failed, raise this to user by setting as delinquent
+ owners.update(delinquent=True)
+ log.info(
+ "Stripe subscription upgrade failed",
+ extra=dict(
+ pending_update=indication_of_payment_failure,
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ owners=[owner.ownerid for owner in owners],
+ ),
+ )
+ return
+
+ # Properly attach the payment method on the customer
+ # This hook will be called after a checkout session completes,
+ # updating the subscription created with it
+ default_payment_method = subscription.default_payment_method
+ if default_payment_method:
+ stripe.PaymentMethod.attach(
+ default_payment_method, customer=subscription.customer
+ )
+ stripe.Customer.modify(
+ subscription.customer,
+ invoice_settings={"default_payment_method": default_payment_method},
+ )
+
+ try:
+ plan = Plan.objects.get(stripe_id=subscription.plan.id)
+ except Plan.DoesNotExist:
+ log.error(
+ "Subscription update requested with invalid plan",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan_id=subscription.plan.id,
+ ),
+ )
+ return
+
+ subscription_schedule_id = subscription.schedule
+ plan_name = plan.name
+ incomplete_expired = subscription.status == "incomplete_expired"
+
+ # Only update if there is not a scheduled subscription
+ if subscription_schedule_id:
+ return
+
+ owner_ids = []
+ for owner in owners:
+ plan_service = PlanService(current_org=owner)
+ if incomplete_expired:
+ plan_service.set_default_plan_data()
+ owner.repository_set.update(active=False, activated=False)
+ else:
+ plan_service.update_plan(
+ name=plan_name, user_count=subscription.quantity
+ )
+ owner_ids.append(owner.ownerid)
+
+ if incomplete_expired:
+ log.info(
+ "Subscription status updated to incomplete_expired, cancelling to free",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ owners=owner_ids,
+ ),
+ )
+ return
+ log.info(
+ "Successfully updated customer subscription",
+ extra=dict(
+ stripe_subscription_id=subscription.id,
+ stripe_customer_id=subscription.customer,
+ plan=plan_name,
+ quantity=subscription.quantity,
+ owners=owner_ids,
+ ),
+ )
+
+ def customer_updated(self, customer: stripe.Customer) -> None:
+ new_default_payment_method = customer["invoice_settings"][
+ "default_payment_method"
+ ]
+
+ if new_default_payment_method is None:
+ return
+
+ for subscription in customer.get("subscriptions", {}).get("data", []):
+ if new_default_payment_method == subscription["default_payment_method"]:
+ continue
+ log.info(
+ "Customer updated their payment method, updating the subscription payment as well",
+ extra=dict(
+ customer_id=customer["id"], subscription_id=subscription["id"]
+ ),
+ )
+ stripe.Subscription.modify(
+ subscription["id"], default_payment_method=new_default_payment_method
+ )
+
+ def checkout_session_completed(
+ self, checkout_session: stripe.checkout.Session
+ ) -> None:
+ log.info(
+ "Checkout session completed",
+ extra=dict(
+ ownerid=checkout_session.client_reference_id,
+ stripe_customer_id=checkout_session.customer,
+ ),
+ )
+ owner = Owner.objects.get(ownerid=checkout_session.client_reference_id)
+ owner.stripe_customer_id = checkout_session.customer
+ owner.stripe_subscription_id = checkout_session.subscription
+ owner.save()
+
+ self._log_updated([owner])
+
+ def _check_and_handle_delayed_notification_payment_methods(
+ self, customer_id: str, payment_method_id: str
+ ):
+ """
+ Helper method to handle payment methods that require delayed verification (like ACH).
+ When verification succeeds, this attaches the payment method to the customer and sets
+ it as the default payment method for both the customer and subscription.
+ """
+ payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
+
+ is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(
+ payment_method, "us_bank_account"
+ )
+
+ should_set_as_default = is_us_bank_account
+
+ # attach the payment method + set as default on the invoice and subscription
+ if should_set_as_default:
+ # retrieve the number of owners to update
+ owners = Owner.objects.filter(
+ stripe_customer_id=customer_id, stripe_subscription_id__isnull=False
+ )
+
+ if owners.exists():
+ # Even if multiple results are returned, these two stripe calls are
+ # just for a single customer
+ stripe.PaymentMethod.attach(payment_method, customer=customer_id)
+ stripe.Customer.modify(
+ customer_id,
+ invoice_settings={"default_payment_method": payment_method},
+ )
+
+ # But this one is for each subscription an owner may have
+ for owner in owners:
+ stripe.Subscription.modify(
+ owner.stripe_subscription_id,
+ default_payment_method=payment_method,
+ )
+ else:
+ log.error(
+ "No owners found with that customer_id, something went wrong",
+ extra=dict(customer_id=customer_id),
+ )
+
+ def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None:
+ """
+ Stripe payment_intent.succeeded webhook event is emitted when a
+ payment intent goes to a success state.
+ We create a Stripe PaymentIntent for the initial checkout session.
+ """
+ log.info(
+ "Payment intent succeeded",
+ extra=dict(
+ stripe_customer_id=payment_intent.customer,
+ payment_intent_id=payment_intent.id,
+ payment_method_type=payment_intent.payment_method,
+ ),
+ )
+
+ self._check_and_handle_delayed_notification_payment_methods(
+ payment_intent.customer, payment_intent.payment_method
+ )
+
+ def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None:
+ """
+ Stripe setup_intent.succeeded webhook event is emitted when a setup intent
+ goes to a success state. We create a Stripe SetupIntent for the gazebo UI
+ PaymentElement to modify payment methods.
+ """
+ log.info(
+ "Setup intent succeeded",
+ extra=dict(
+ stripe_customer_id=setup_intent.customer,
+ setup_intent_id=setup_intent.id,
+ payment_method_type=setup_intent.payment_method,
+ ),
+ )
+
+ self._check_and_handle_delayed_notification_payment_methods(
+ setup_intent.customer, setup_intent.payment_method
+ )
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ if settings.STRIPE_ENDPOINT_SECRET is None:
+ log.critical(
+ "Stripe endpoint secret improperly configured -- webhooks will not be processed."
+ )
+ try:
+ self.event = stripe.Webhook.construct_event(
+ self.request.body,
+ self.request.META.get(StripeHTTPHeaders.SIGNATURE),
+ settings.STRIPE_ENDPOINT_SECRET,
+ )
+ except stripe.SignatureVerificationError as e:
+ log.warning(f"Stripe webhook event received with invalid signature -- {e}")
+ return Response("Invalid signature", status=status.HTTP_400_BAD_REQUEST)
+ if self.event.type not in StripeWebhookEvents.subscribed_events:
+ log.warning(
+ "Unsupported Stripe webhook event received, exiting",
+ extra=dict(stripe_webhook_event=self.event.type),
+ )
+ return Response("Unsupported event type", status=204)
+
+ log.info(
+ "Stripe webhook event received",
+ extra=dict(stripe_webhook_event=self.event.type),
+ )
+
+ # Converts event names of the format X.Y.Z into X_Y_Z, and calls
+ # the relevant method in this class
+ getattr(self, self.event.type.replace(".", "_"))(self.event.data.object)
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apps/codecov-api/ci.yml b/apps/codecov-api/ci.yml
new file mode 100644
index 0000000000..74b454c2f3
--- /dev/null
+++ b/apps/codecov-api/ci.yml
@@ -0,0 +1,87 @@
+setup:
+ codecov_url: https://codecov.io
+ debug: no
+ loglvl: INFO
+ encryption_secret: "zp^P9*i8aR3"
+ media:
+ assets: https://codecov-cdn.storage.googleapis.com/4.4.4-fd6aa1e
+ dependancies: https://codecov-cdn.storage.googleapis.com/4.4.4-fd6aa1e
+ http:
+ force_https: yes
+ cookie_secret: abc123
+ timeouts:
+ connect: 10
+ receive: 15
+ tasks:
+ celery:
+ soft_timelimit: 200
+ hard_timelimit: 240
+ upload:
+ queue: uploads
+ cache:
+ yaml: 600 # 10 minutes
+ tree: 600 # 10 minutes
+ diff: 300 # 5 minutes
+ chunks: 300 # 5 minutes
+ uploads: 86400 # 1 day
+
+services:
+ database_url: postgres://postgres:@postgres:5432/circle_test
+ database:
+ username: postgres
+ name: circle_test
+ password: ""
+ host: postgres
+ redis_url: redis://redis:@localhost:6379/
+ minio:
+ hash_key: testixik8qdauiab1yiffydimvi72ekq # never change this
+ access_key_id: codecov-default-key
+ secret_access_key: codecov-default-secret
+ verify_ssl: false
+
+github:
+ bot:
+ username: codecov-io
+ integration:
+ id: 254
+ pem: src/certs/github.pem
+
+bitbucket:
+ bot:
+ username: codecov-io
+
+gitlab:
+ bot:
+ username: codecov-io
+
+site:
+ codecov:
+ require_ci_to_pass: yes
+
+ coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project: yes
+ patch: yes
+ changes: no
+
+ parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+ javascript:
+ enable_partials: no
+
+ comment:
+ layout: "reach, diff, flags, files, footer"
+ behavior: default
+ require_changes: no
+ require_base: no
+ require_head: yes
diff --git a/apps/codecov-api/codecov.yml b/apps/codecov-api/codecov.yml
new file mode 100644
index 0000000000..ea8787240f
--- /dev/null
+++ b/apps/codecov-api/codecov.yml
@@ -0,0 +1,8 @@
+ignore:
+ - "**tests**/test_*.py"
+
+codecov:
+ require_ci_to_pass: false
+
+test_analytics:
+ flake_detection: true
diff --git a/apps/codecov-api/codecov/__init__.py b/apps/codecov-api/codecov/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov/admin.py b/apps/codecov-api/codecov/admin.py
new file mode 100644
index 0000000000..4ff06444fa
--- /dev/null
+++ b/apps/codecov-api/codecov/admin.py
@@ -0,0 +1,71 @@
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import format_html
+from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin
+from shared.django_apps.rollouts.models import FeatureFlag, FeatureFlagVariant
+
+from codecov.forms import AutocompleteSearchForm
+
+
+class AdminMixin(object):
+ def save_model(self, request, new_obj, form, change) -> None:
+ if change:
+ old_obj = self.model.objects.get(pk=new_obj.pk)
+ new_obj.changed_fields = dict()
+
+ for changed_field in form.changed_data:
+ prev_value = getattr(old_obj, changed_field)
+ new_value = getattr(new_obj, changed_field)
+ new_obj.changed_fields[changed_field] = (
+ f"prev value: {prev_value}, new value: {new_value}"
+ )
+
+ return super().save_model(request, new_obj, form, change)
+
+ def log_change(self, request, object, message):
+ message.append(object.changed_fields)
+ return super().log_change(request, object, message)
+
+
+class FeatureFlagVariantInline(admin.StackedInline):
+ model = FeatureFlagVariant
+ exclude = ["override_repo_ids", "override_owner_ids"]
+ fields = ["name", "proportion", "value", "view_link"]
+ readonly_fields = [
+ "view_link",
+ ]
+ extra = 0
+
+ def view_link(self, obj):
+ link = reverse(
+ "admin:rollouts_featureflagvariant_change", args=[obj.variant_id]
+ )
+ return format_html('View', link)
+
+ view_link.short_description = "More Details"
+
+
+class FeatureFlagAdmin(admin.ModelAdmin):
+ list_display = ["name", "is_active", "number_of_variants", "proportion_percentage"]
+ search_fields = ["name"]
+ inlines = [FeatureFlagVariantInline]
+
+ def number_of_variants(self, obj):
+ return obj.variants.count()
+
+ number_of_variants.short_description = "# of Variants"
+
+ def proportion_percentage(self, obj):
+ return str(round(obj.proportion * 100)) + "%"
+
+ proportion_percentage.short_description = "Experiment Proportion"
+
+
+class FeatureFlagVariantAdmin(admin.ModelAdmin, DynamicArrayMixin):
+ list_display = ["variant_id", "name", "feature_flag"]
+ search_fields = ["variant_id", "name", "feature_flag__name"]
+ form = AutocompleteSearchForm
+
+
+admin.site.register(FeatureFlag, FeatureFlagAdmin)
+admin.site.register(FeatureFlagVariant, FeatureFlagVariantAdmin)
diff --git a/apps/codecov-api/codecov/commands/__init__.py b/apps/codecov-api/codecov/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov/commands/base.py b/apps/codecov-api/codecov/commands/base.py
new file mode 100644
index 0000000000..dc40515d2f
--- /dev/null
+++ b/apps/codecov-api/codecov/commands/base.py
@@ -0,0 +1,111 @@
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
+
+import services.self_hosted as self_hosted
+from codecov.commands.exceptions import (
+ MissingService,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner, User
+from core.models import Repository
+
+
+class BaseCommand:
+ def __init__(self, current_owner: Owner, service: str, current_user: User = None):
+ self.current_user = current_user or AnonymousUser()
+ self.current_owner = current_owner
+ self.service = service
+ self.executor = None
+
+ def get_interactor(self, InteractorKlass):
+ return InteractorKlass(
+ current_owner=self.current_owner,
+ service=self.service,
+ current_user=self.current_user,
+ )
+
+ def get_command(self, namespace):
+ """
+ Allow a command to call another command
+ """
+ if not self.executor:
+ # local import to avoid circular import; I'm not too happy about
+ # this pattern yet
+ from .executor import get_executor_from_command
+
+ self.executor = get_executor_from_command(self)
+ return self.executor.get_command(namespace)
+
+
+class BaseInteractor:
+ requires_service = True
+
+ def __init__(self, current_owner: Owner, service: str, current_user: User = None):
+ self.current_user = current_user or AnonymousUser()
+ self.current_owner = current_owner
+ self.service = service
+
+ if not self.service and self.requires_service:
+ raise MissingService()
+
+ if self.current_owner:
+ self.current_user = self.current_owner.user
+
+ def ensure_is_admin(self, owner: Owner) -> None:
+ """
+ Ensures that the `current_owner` is an admin of `owner`,
+ or raise `Unauthorized` otherwise.
+ """
+
+ if not current_user_part_of_org(self.current_owner, owner):
+ raise Unauthorized()
+
+ if settings.IS_ENTERPRISE:
+ if not self_hosted.is_admin_owner(self.current_owner):
+ raise Unauthorized()
+ else:
+ if not owner.is_admin(self.current_owner):
+ raise Unauthorized()
+
+ def resolve_owner_and_repo(
+ self,
+ owner_username: str,
+ repo_name: str,
+ ensure_is_admin: bool = False,
+ only_viewable: bool = False,
+ only_active: bool = False,
+ ) -> tuple[Owner, Repository]:
+ """
+ Resolves the `Owner` and `Repository` based on the passed `owner_username`
+ and `repo_name` respectively.
+
+ If `ensure_is_admin` is set, this will also ensure that the `current_owner` is an
+ admin on the resolved `Owner`.
+ """
+ if ensure_is_admin and not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ owner = Owner.objects.filter(
+ service=self.service, username=owner_username
+ ).first()
+
+ if not owner:
+ raise ValidationError("Owner not found")
+
+ if ensure_is_admin:
+ self.ensure_is_admin(owner)
+
+ repo_query = Repository.objects
+ if only_viewable:
+ repo_query = repo_query.viewable_repos(self.current_owner)
+ if only_active:
+ repo_query = repo_query.filter(active=True)
+
+ repo = repo_query.filter(author=owner, name=repo_name).first()
+ if not repo:
+ raise ValidationError("Repo not found")
+
+ return (owner, repo)
diff --git a/apps/codecov-api/codecov/commands/exceptions.py b/apps/codecov-api/codecov/commands/exceptions.py
new file mode 100644
index 0000000000..ce980a9e68
--- /dev/null
+++ b/apps/codecov-api/codecov/commands/exceptions.py
@@ -0,0 +1,32 @@
+from graphql import GraphQLError
+
+
+class BaseException(Exception):
+ pass
+
+
+class Unauthenticated(BaseException):
+ message = "You are not authenticated"
+
+
+class ValidationError(BaseException):
+ @property
+ def message(self):
+ return str(self)
+
+
+class Unauthorized(BaseException):
+ message = "You are not authorized"
+
+
+class NotFound(BaseException):
+ message = "Cant find the requested resource"
+
+
+class MissingService(BaseException):
+ message = "Missing required service"
+
+
+class UnauthorizedGuestAccess(GraphQLError):
+ def __init__(self):
+ super().__init__("Unauthorized", extensions={"status": 403})
diff --git a/apps/codecov-api/codecov/commands/executor.py b/apps/codecov-api/codecov/commands/executor.py
new file mode 100644
index 0000000000..6c797595ea
--- /dev/null
+++ b/apps/codecov-api/codecov/commands/executor.py
@@ -0,0 +1,49 @@
+from codecov_auth.commands.owner import OwnerCommands
+from codecov_auth.models import Owner, User
+from compare.commands.compare import CompareCommands
+from core.commands.branch import BranchCommands
+from core.commands.commit import CommitCommands
+from core.commands.component import ComponentCommands
+from core.commands.flag import FlagCommands
+from core.commands.pull import PullCommands
+from core.commands.repository import RepositoryCommands
+from utils.services import get_long_service_name
+
+mapping = {
+ "commit": CommitCommands,
+ "owner": OwnerCommands,
+ "repository": RepositoryCommands,
+ "branch": BranchCommands,
+ "compare": CompareCommands,
+ "pull": PullCommands,
+ "flag": FlagCommands,
+ "component": ComponentCommands,
+}
+
+
+class Executor:
+ def __init__(self, current_owner: Owner, service: str, current_user: User):
+ self.current_user = current_user
+ self.current_owner = current_owner
+ self.service = service
+
+ def get_command(self, namespace):
+ KlassCommand = mapping[namespace]
+ return KlassCommand(self.current_owner, self.service, self.current_user)
+
+
+def get_executor_from_request(request):
+ service_in_url = request.resolver_match.kwargs["service"]
+ return Executor(
+ current_owner=request.current_owner,
+ service=get_long_service_name(service_in_url),
+ current_user=request.user,
+ )
+
+
+def get_executor_from_command(command):
+ return Executor(
+ current_owner=command.current_owner,
+ service=command.service,
+ current_user=command.current_user,
+ )
diff --git a/apps/codecov-api/codecov/commands/tests/__init__.py b/apps/codecov-api/codecov/commands/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov/commands/tests/test_base.py b/apps/codecov-api/codecov/commands/tests/test_base.py
new file mode 100644
index 0000000000..92e75858e2
--- /dev/null
+++ b/apps/codecov-api/codecov/commands/tests/test_base.py
@@ -0,0 +1,29 @@
+import pytest
+from django.contrib.auth.models import AnonymousUser
+
+from codecov.commands.exceptions import MissingService
+from core.commands.commit import CommitCommands
+
+from ..base import BaseCommand, BaseInteractor
+
+
+def test_base_command():
+ command = BaseCommand(None, "github")
+ # test command is properly init
+ assert command.current_owner is None
+ assert command.service == "github"
+ # test get_interactor
+ interactor = command.get_interactor(BaseInteractor)
+ assert interactor.current_owner is None
+ assert interactor.current_user == AnonymousUser()
+ assert interactor.service == "github"
+ # test get_command
+ command_command = command.get_command("commit")
+ assert isinstance(command_command, CommitCommands)
+
+
+def test_base_interactor_with_missing_required_service():
+ with pytest.raises(MissingService) as excinfo:
+ BaseInteractor(None, None)
+
+ assert excinfo.value.message == "Missing required service"
diff --git a/apps/codecov-api/codecov/commands/tests/test_executor.py b/apps/codecov-api/codecov/commands/tests/test_executor.py
new file mode 100644
index 0000000000..6c54bfcbae
--- /dev/null
+++ b/apps/codecov-api/codecov/commands/tests/test_executor.py
@@ -0,0 +1,26 @@
+from django.contrib.auth.models import AnonymousUser
+from django.test import RequestFactory
+from django.urls import ResolverMatch
+
+from codecov_auth.commands.owner import OwnerCommands
+
+from ..executor import get_executor_from_command, get_executor_from_request
+
+
+def test_get_executor_from_request():
+ request_factory = RequestFactory()
+ request = request_factory.get("")
+ request.current_owner = None
+ match = ResolverMatch(func=lambda: None, args=(), kwargs={"service": "gh"})
+ request.resolver_match = match
+ request.user = AnonymousUser()
+ executor = get_executor_from_request(request)
+ assert executor.service == "github"
+ assert executor.current_owner is None
+
+
+def test_get_executor_from_command():
+ command = OwnerCommands(None, "github")
+ executor = get_executor_from_command(command)
+ assert executor.service == "github"
+ assert executor.current_owner is None
diff --git a/apps/codecov-api/codecov/db/__init__.py b/apps/codecov-api/codecov/db/__init__.py
new file mode 100644
index 0000000000..28a91e3aa4
--- /dev/null
+++ b/apps/codecov-api/codecov/db/__init__.py
@@ -0,0 +1,69 @@
+import logging
+
+from django.conf import settings
+from django.db.models import Field, Lookup
+
+log = logging.getLogger(__name__)
+
+
+class DatabaseRouter:
+ """
+ A router to control all database operations on models across multiple databases.
+ https://docs.djangoproject.com/en/4.0/topics/db/multi-db/#automatic-database-routing
+ """
+
+ def db_for_read(self, model, **hints):
+ if model._meta.app_label == "timeseries":
+ if settings.TIMESERIES_DATABASE_READ_REPLICA_ENABLED:
+ return "timeseries_read"
+ else:
+ return "timeseries"
+ else:
+ if settings.DATABASE_READ_REPLICA_ENABLED:
+ return "default_read"
+ else:
+ return "default"
+
+ def db_for_write(self, model, **hints):
+ if model._meta.app_label == "timeseries":
+ return "timeseries"
+ else:
+ return "default"
+
+ def allow_migrate(self, db, app_label, model_name=None, **hints):
+ if (
+ db == "timeseries" or db == "timeseries_read"
+ ) and not settings.TIMESERIES_ENABLED:
+ log.warning("Skipping timeseries migration")
+ return False
+ if db == "default_read" or db == "timeseries_read":
+ log.warning("Skipping migration of read-only database")
+ return False
+ if app_label == "timeseries":
+ return db == "timeseries"
+ else:
+ return db == "default"
+
+ def allow_relation(self, obj1, obj2, **hints):
+ obj1_app = obj1._meta.app_label
+ obj2_app = obj2._meta.app_label
+
+ # cannot form relationship across default <-> timeseries dbs
+ if obj1_app == "timeseries" and obj2_app != "timeseries":
+ return False
+ if obj1_app != "timeseries" and obj2_app == "timeseries":
+ return False
+
+ # otherwise we allow it
+ return True
+
+
+@Field.register_lookup
+class IsNot(Lookup):
+ lookup_name = "isnot"
+
+ def as_sql(self, compiler, connection):
+ lhs, lhs_params = self.process_lhs(compiler, connection)
+ rhs, rhs_params = self.process_rhs(compiler, connection)
+ params = tuple(lhs_params) + tuple(rhs_params)
+ return "%s is not %s" % (lhs, rhs), params
diff --git a/apps/codecov-api/codecov/forms.py b/apps/codecov-api/codecov/forms.py
new file mode 100644
index 0000000000..f2d76d861d
--- /dev/null
+++ b/apps/codecov-api/codecov/forms.py
@@ -0,0 +1,46 @@
+from dal import autocomplete
+from django import forms
+from shared.django_apps.rollouts.models import FeatureFlagVariant
+
+from codecov_auth.models import Owner
+from core.models import Repository
+
+
+class AutocompleteSearchForm(forms.ModelForm):
+ repository = forms.ModelChoiceField(
+ queryset=Repository.objects.all(),
+ widget=autocomplete.ModelSelect2(url="admin-repository-autocomplete"),
+ required=False,
+ label="Add repo override",
+ help_text="Search for a repo and hit `Save and continue editing` to add it",
+ )
+
+ owner = forms.ModelChoiceField(
+ queryset=Owner.objects.all(),
+ widget=autocomplete.ModelSelect2(url="admin-owner-autocomplete"),
+ required=False,
+ label="Add owner override",
+ help_text="Search for an owner and hit `Save and continue editing` to add it",
+ )
+
+ class Meta:
+ model = FeatureFlagVariant
+ fields = "__all__"
+
+ def save(self, commit=True):
+ instance = super(AutocompleteSearchForm, self).save(commit=False)
+
+ if self.cleaned_data["repository"]:
+ if instance.override_repo_ids is None:
+ instance.override_repo_ids = []
+ instance.override_repo_ids.append(self.cleaned_data["repository"].repoid)
+
+ if self.cleaned_data["owner"]:
+ if instance.override_owner_ids is None:
+ instance.override_repo_ids = []
+ instance.override_owner_ids.append(self.cleaned_data["owner"].ownerid)
+
+ if commit:
+ instance.save()
+
+ return instance
diff --git a/apps/codecov-api/codecov/models.py b/apps/codecov-api/codecov/models.py
new file mode 100644
index 0000000000..ff7e9b937c
--- /dev/null
+++ b/apps/codecov-api/codecov/models.py
@@ -0,0 +1,13 @@
+import uuid
+
+from django.db import models
+
+
+class BaseCodecovModel(models.Model):
+ id = models.BigAutoField(primary_key=True)
+ external_id = models.UUIDField(default=uuid.uuid4, editable=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
diff --git a/apps/codecov-api/codecov/settings_base.py b/apps/codecov-api/codecov/settings_base.py
new file mode 100644
index 0000000000..55dbaba7cf
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_base.py
@@ -0,0 +1,456 @@
+import os
+
+import sentry_sdk
+from corsheaders.defaults import default_headers
+from sentry_sdk.integrations.celery import CeleryIntegration
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.httpx import HttpxIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
+from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
+from shared.django_apps.db_settings import *
+from shared.license import startup_license_logging
+
+from utils.config import SettingsModule, get_config, get_settings_module
+
+SECRET_KEY = get_config("django", "secret_key", default="*")
+
+# Application definition
+
+INSTALLED_APPS = [
+ "legacy_migrations",
+ "dal",
+ "dal_select2", # needs to be ahead of django.contrib.admin
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "django.contrib.postgres",
+ "django_filters",
+ "drf_spectacular",
+ "drf_spectacular_sidecar",
+ "ariadne_django",
+ "corsheaders",
+ "rest_framework",
+ "billing",
+ "codecov_auth",
+ "api",
+ "compare",
+ "core",
+ "graphql_api",
+ "labelanalysis",
+ "reports",
+ "staticanalysis",
+ "timeseries",
+ "django_prometheus",
+ "psqlextra",
+ "django_better_admin_arrayfield",
+ # New Shared Models
+ "shared.django_apps.rollouts",
+ "shared.django_apps.user_measurements",
+ "shared.django_apps.codecov_metrics",
+ "shared.django_apps.bundle_analysis",
+]
+
+MIDDLEWARE = [
+ "core.middleware.AppMetricsBeforeMiddlewareWithUA",
+ "django.middleware.security.SecurityMiddleware",
+ "whitenoise.middleware.WhiteNoiseMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "codecov_auth.middleware.CorsMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "core.middleware.ServiceMiddleware",
+ "codecov_auth.middleware.CurrentOwnerMiddleware",
+ "codecov_auth.middleware.ImpersonationMiddleware",
+ "core.middleware.AppMetricsAfterMiddlewareWithUA",
+ "csp.middleware.CSPMiddleware",
+]
+
+ROOT_URLCONF = "codecov.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [
+ "templates",
+ ],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ]
+ },
+ }
+]
+
+WSGI_APPLICATION = "codecov.wsgi.application"
+
+# GraphQL
+
+GRAPHQL_QUERY_COST_THRESHOLD = get_config(
+ "setup", "graphql", "query_cost_threshold", default=10000
+)
+
+GRAPHQL_RATE_LIMIT_ENABLED = get_config(
+ "setup", "graphql", "rate_limit_enabled", default=True
+)
+
+GRAPHQL_RATE_LIMIT_RPM = get_config("setup", "graphql", "rate_limit_rpm", default=300)
+
+GRAPHQL_INTROSPECTION_ENABLED = False
+
+GRAPHQL_MAX_DEPTH = get_config("setup", "graphql", "max_depth", default=20)
+
+GRAPHQL_MAX_ALIASES = get_config("setup", "graphql", "max_aliases", default=10)
+
+# Database
+# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
+
+DATABASE_ROUTERS = ["codecov.db.DatabaseRouter"]
+
+# GCS
+GCS_BUCKET_NAME = get_config("services", "minio", "bucket", default="codecov")
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
+ },
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+]
+
+PROMETHEUS_EXPORT_MIGRATIONS = False
+
+REST_FRAMEWORK = {
+ "DEFAULT_PERMISSION_CLASSES": (
+ "rest_framework.permissions.IsAuthenticatedOrReadOnly",
+ ),
+ "DEFAULT_AUTHENTICATION_CLASSES": (
+ "codecov_auth.authentication.UserTokenAuthentication",
+ "rest_framework.authentication.BasicAuthentication",
+ "codecov_auth.authentication.SessionAuthentication",
+ ),
+ "DEFAULT_PAGINATION_CLASS": "api.shared.pagination.StandardPageNumberPagination",
+ "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
+ "PAGE_SIZE": 20,
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+}
+
+# API auto-documentation settings
+# https://drf-spectacular.readthedocs.io/en/latest/settings.html
+SPECTACULAR_SETTINGS = {
+ "TITLE": "Codecov API",
+ "DESCRIPTION": "Public Codecov API",
+ "VERSION": "2.0.0",
+ "SERVE_INCLUDE_SCHEMA": False,
+ "SERVE_URLCONF": "api.public.v2.urls",
+ "SERVERS": [{"url": "/api/v2"}],
+ "AUTHENTICATION_WHITELIST": [
+ "codecov_auth.authentication.UserTokenAuthentication",
+ ],
+ "REDOC_DIST": "SIDECAR", # serve Redoc from Django (not CDN)
+}
+
+# The frame-ancestors directive restricts the URLs which can embed the resource using
+# frame, iframe, object, or embed. This configuration denies doing so.
+CSP_FRAME_ANCESTORS = "'none'"
+
+# Allows GraphQL Playground to render
+CSP_DEFAULT_SRC = [
+ "'self'",
+ "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='",
+ "'sha256-eKdXhLyOdPl2/gp1Ob116rCU2Ox54rseyz1MwCmzb6w='",
+ "'sha256-a1pELtDJXf8fPX1YL2JiBM91RQBeIAswunzgwMEsvwA='",
+ "'sha256-cNIcuS0BVLuBVP5rpfeFE42xHz7r5hMyf9YdfknWuCg='",
+ "'sha256-bmwAzHxhO1mBINfkKkKPopyKEv4ppCHx/z84wQJ9nOY='",
+ "'sha256-jQoC6QpIonlMBPFbUGlJFRJFFWbbijMl7Z8XqWrb46o='",
+ "'sha256-8bfsSkoReSu+APs9CT6QDx2VMgtiw9/lrZLMZNUmhc0='",
+ "'sha256-WoezM4J4TynepdEmsbDslXjZN6zQbvY3M0cX0ujGGUo='",
+ "'sha256-OdsouMbSygCsanIn5RN0skp0SiM8rjt4PqX+YWPG3d0='",
+ "https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js",
+ "https://cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png",
+ "https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css",
+ "blob:",
+]
+
+CSP_WORKER_SRC = ["'self'", "blob:"]
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.1/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
+STATIC_URL = "/static/"
+STATIC_ROOT = os.path.join(PROJECT_ROOT, "static")
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "standard": {
+ "format": "%(message)s %(asctime)s %(name)s %(levelname)s %(lineno)s %(pathname)s %(funcName)s %(threadName)s",
+ "class": "utils.logging_configuration.CustomLocalJsonFormatter",
+ },
+ "json": {
+ "format": "%(message)s %(asctime)s %(name)s %(levelname)s %(lineno)s %(pathname)s %(funcName)s %(threadName)s",
+ "class": "utils.logging_configuration.CustomDatadogJsonFormatter",
+ },
+ "gunicorn_json": {
+ "class": "utils.logging_configuration.CustomGunicornLogFormatter",
+ "datefmt": "%Y-%m-%dT%H:%M:%S%z",
+ "format": '%(h)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
+ },
+ },
+ "filters": {
+ "health_check_filter": {"()": "utils.logging_configuration.HealthCheckFilter"}
+ },
+ "root": {"handlers": ["default"], "level": "INFO", "propagate": True},
+ "handlers": {
+ "default": {
+ "level": "INFO",
+ "formatter": (
+ "standard"
+ if get_settings_module() == SettingsModule.DEV.value
+ else "json"
+ ),
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stdout", # Default is stderr
+ },
+ "json-gunicorn-console": {
+ "level": "INFO",
+ "formatter": "gunicorn_json",
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stdout", # Default is stderr
+ "filters": ["health_check_filter"],
+ },
+ },
+ "loggers": {
+ "gunicorn.access": {
+ "level": "INFO",
+ "handlers": ["json-gunicorn-console"],
+ }
+ },
+}
+
+MINIO_ACCESS_KEY = get_config("services", "minio", "access_key_id")
+MINIO_SECRET_KEY = get_config("services", "minio", "secret_access_key")
+MINIO_LOCATION = "codecov.s3.amazonaws.com"
+MINIO_HASH_KEY = get_config("services", "minio", "hash_key")
+ARCHIVE_BUCKET_NAME = "codecov"
+ENCRYPTION_SECRET = get_config("setup", "encryption_secret")
+
+COOKIE_SAME_SITE = "Lax"
+COOKIE_SECRET = get_config("setup", "http", "cookie_secret")
+COOKIES_DOMAIN = get_config("setup", "http", "cookies_domain", default=".codecov.io")
+SESSION_COOKIE_DOMAIN = get_config(
+ "setup", "http", "cookies_domain", default=".codecov.io"
+)
+SESSION_COOKIE_SECURE = get_config("setup", "secure_cookie", default=True)
+# Defaulting to 'not found' as opposed to 'None' to avoid None somehow getting through as a bearer token. Token strings can't have spaces, hence 'not found' can never be forced as a header input value
+SUPER_API_TOKEN = os.getenv("SUPER_API_TOKEN", "not found")
+CODECOV_INTERNAL_TOKEN = os.getenv("CODECOV_INTERNAL_TOKEN", "not found")
+
+CIRCLECI_TOKEN = get_config("circleci", "token")
+
+GITHUB_CLIENT_ID = get_config("github", "client_id")
+GITHUB_CLIENT_SECRET = get_config("github", "client_secret")
+GITHUB_BOT_KEY = get_config("github", "bot", "key")
+GITHUB_TOKENLESS_BOT_KEY = get_config(
+ "github", "bots", "tokenless", "key", default=GITHUB_BOT_KEY
+)
+GITHUB_ACTIONS_TOKEN = get_config("github", "actions_token")
+
+GITHUB_ENTERPRISE_URL = get_config("github_enterprise", "url")
+GITHUB_ENTERPRISE_API_URL = get_config("github_enterprise", "api_url")
+GITHUB_ENTERPRISE_CLIENT_ID = get_config("github_enterprise", "client_id")
+GITHUB_ENTERPRISE_CLIENT_SECRET = get_config("github_enterprise", "client_secret")
+GITHUB_ENTERPRISE_BOT_KEY = get_config("github_enterprise", "bot", "key")
+GITHUB_ENTERPRISE_TOKENLESS_BOT_KEY = get_config(
+ "github_enterprise", "bots", "tokenless", "key", default=GITHUB_ENTERPRISE_BOT_KEY
+)
+GITHUB_ENTERPRISE_ACTIONS_TOKEN = get_config("github_enterprise", "actions_token")
+
+BITBUCKET_CLIENT_ID = get_config("bitbucket", "client_id")
+BITBUCKET_CLIENT_SECRET = get_config("bitbucket", "client_secret")
+BITBUCKET_BOT_KEY = get_config("bitbucket", "bot", "key")
+BITBUCKET_TOKENLESS_BOT_KEY = get_config(
+ "bitbucket", "bots", "tokenless", "key", default=BITBUCKET_BOT_KEY
+)
+BITBUCKET_REDIRECT_URI = get_config(
+ "bitbucket", "redirect_uri", default="https://codecov.io/login/bitbucket"
+)
+
+BITBUCKET_SERVER_URL = get_config("bitbucket_server", "url")
+BITBUCKET_SERVER_CLIENT_ID = get_config("bitbucket_server", "client_id")
+BITBUCKET_SERVER_CLIENT_SECRET = get_config("bitbucket_server", "client_secret")
+BITBUCKET_SERVER_BOT_KEY = get_config("bitbucket_server", "bot", "key")
+BITBUCKET_SERVER_TOKENLESS_BOT_KEY = get_config(
+ "bitbucket_server", "bots", "tokenless", "key", default=BITBUCKET_SERVER_BOT_KEY
+)
+
+GITLAB_CLIENT_ID = get_config("gitlab", "client_id")
+GITLAB_CLIENT_SECRET = get_config("gitlab", "client_secret")
+GITLAB_REDIRECT_URI = get_config(
+ "gitlab", "redirect_uri", default="https://codecov.io/login/gitlab"
+)
+GITLAB_SCOPE = get_config("gitlab", "scope", default="api")
+GITLAB_BOT_KEY = get_config("gitlab", "bot", "key")
+GITLAB_TOKENLESS_BOT_KEY = get_config(
+ "gitlab", "bots", "tokenless", "key", default=GITLAB_BOT_KEY
+)
+
+GITLAB_ENTERPRISE_CLIENT_ID = get_config("gitlab_enterprise", "client_id")
+GITLAB_ENTERPRISE_CLIENT_SECRET = get_config("gitlab_enterprise", "client_secret")
+GITLAB_ENTERPRISE_REDIRECT_URI = get_config(
+ "gitlab_enterprise",
+ "redirect_uri",
+ default="https://codecov.io/login/gitlab_enterprise",
+)
+GITLAB_ENTERPRISE_BOT_KEY = get_config("gitlab_enterprise", "bot", "key")
+GITLAB_ENTERPRISE_TOKENLESS_BOT_KEY = get_config(
+ "gitlab_enterprise", "bots", "tokenless", "key", default=GITLAB_ENTERPRISE_BOT_KEY
+)
+GITLAB_ENTERPRISE_URL = get_config("gitlab_enterprise", "url")
+GITLAB_ENTERPRISE_API_URL = get_config("gitlab_enterprise", "api_url")
+
+CORS_ALLOW_HEADERS = (
+ list(default_headers)
+ + ["token-type"]
+ + get_config("setup", "api_cors_extra_headers", default=["baggage"])
+)
+
+SKIP_RISKY_MIGRATION_STEPS = get_config("migrations", "skip_risky_steps", default=False)
+
+DJANGO_ADMIN_URL = get_config("django", "admin_url", default="admin")
+
+IS_ENTERPRISE = get_settings_module() == SettingsModule.ENTERPRISE.value
+IS_DEV = get_settings_module() == SettingsModule.DEV.value
+
+DATA_UPLOAD_MAX_MEMORY_SIZE = int(
+ get_config("setup", "http", "upload_max_memory_size", default=2621440)
+)
+FILE_UPLOAD_MAX_MEMORY_SIZE = int(
+ get_config("setup", "http", "file_upload_max_memory_size", default=2621440)
+)
+
+CORS_ALLOWED_ORIGIN_REGEXES = get_config(
+ "setup", "api_cors_allowed_origin_regexes", default=[]
+)
+CORS_ALLOWED_ORIGINS: list[str] = []
+
+GRAPHQL_PLAYGROUND = get_settings_module() in [
+ SettingsModule.DEV.value,
+ SettingsModule.STAGING.value,
+ SettingsModule.TESTING.value,
+]
+
+UPLOAD_THROTTLING_ENABLED = get_config(
+ "setup", "upload_throttling_enabled", default=True
+)
+
+HIDE_ALL_CODECOV_TOKENS = get_config("setup", "hide_all_codecov_tokens", default=False)
+
+SENTRY_JWT_SHARED_SECRET = get_config(
+ "sentry", "jwt_shared_secret", default=None
+) or get_config("setup", "sentry", "jwt_shared_secret", default=None)
+SENTRY_USER_WEBHOOK_URL = get_config(
+ "sentry", "webhook_url", default=None
+) or get_config("setup", "sentry", "webhook_url", default=None)
+SENTRY_OAUTH_CLIENT_ID = get_config("sentry", "client_id") or get_config(
+ "setup", "sentry", "oauth_client_id"
+)
+SENTRY_OAUTH_CLIENT_SECRET = get_config("sentry", "client_secret") or get_config(
+ "setup", "sentry", "oauth_client_secret"
+)
+SENTRY_OIDC_SHARED_SECRET = get_config("sentry", "oidc_shared_secret") or get_config(
+ "setup", "sentry", "oidc_shared_secret"
+)
+
+OKTA_OAUTH_CLIENT_ID = get_config("setup", "okta", "oauth_client_id")
+OKTA_OAUTH_CLIENT_SECRET = get_config("setup", "okta", "oauth_client_secret")
+OKTA_OAUTH_REDIRECT_URL = get_config("setup", "okta", "oauth_redirect_url")
+OKTA_ISS = get_config("setup", "okta", "iss", default=None)
+
+DISABLE_GIT_BASED_LOGIN = IS_ENTERPRISE and get_config(
+ "setup", "disable_git_based_login", default=False
+)
+
+SHELTER_SHARED_SECRET = get_config("setup", "shelter_shared_secret", default=None)
+SHELTER_ENABLED = get_config("setup", "shelter_enabled", default=True)
+
+SENTRY_ENV = os.environ.get("CODECOV_ENV", False)
+SENTRY_DSN = os.environ.get("SERVICES__SENTRY__SERVER_DSN", None)
+SENTRY_DENY_LIST = DEFAULT_DENYLIST + ["_headers", "token_to_use"]
+
+if SENTRY_DSN is not None:
+ SENTRY_SAMPLE_RATE = float(os.environ.get("SERVICES__SENTRY__SAMPLE_RATE", 0.1))
+ sentry_sdk.init(
+ dsn=SENTRY_DSN,
+ event_scrubber=EventScrubber(denylist=SENTRY_DENY_LIST),
+ integrations=[
+ DjangoIntegration(),
+ CeleryIntegration(),
+ RedisIntegration(),
+ HttpxIntegration(),
+ ],
+ environment=SENTRY_ENV,
+ traces_sample_rate=SENTRY_SAMPLE_RATE,
+ profiles_sample_rate=float(
+ os.environ.get("SERVICES__SENTRY__PROFILE_SAMPLE_RATE", 0.01)
+ ),
+ )
+ if os.getenv("CLUSTER_ENV"):
+ sentry_sdk.set_tag("cluster", os.getenv("CLUSTER_ENV"))
+elif IS_DEV:
+ sentry_sdk.init(
+ spotlight=IS_DEV,
+ event_scrubber=EventScrubber(denylist=SENTRY_DENY_LIST),
+ )
+
+SHELTER_PUBSUB_PROJECT_ID = get_config("setup", "shelter", "pubsub_project_id")
+SHELTER_PUBSUB_SYNC_REPO_TOPIC_ID = get_config("setup", "shelter", "sync_repo_topic_id")
+
+STRIPE_PAYMENT_METHOD_CONFIGURATION_ID = get_config(
+ "setup", "stripe", "payment_method_configuration_id", default=None
+)
+
+AMPLITUDE_API_KEY = os.environ.get("AMPLITUDE_API_KEY", None)
+
+# Allows to do migrations from another module
+MIGRATION_MODULES = {
+ "codecov_auth": "shared.django_apps.codecov_auth.migrations",
+ "compare": "shared.django_apps.compare.migrations",
+ "core": "shared.django_apps.core.migrations",
+ "labelanalysis": "shared.django_apps.labelanalysis.migrations",
+ "legacy_migrations": "shared.django_apps.legacy_migrations.migrations",
+ "profiling": "shared.django_apps.profiling.migrations",
+ "reports": "shared.django_apps.reports.migrations",
+ "staticanalysis": "shared.django_apps.staticanalysis.migrations",
+ "timeseries": "shared.django_apps.timeseries.migrations",
+}
+
+# to aid in debugging, print out this info on startup. If no license, prints nothing
+startup_license_logging()
diff --git a/apps/codecov-api/codecov/settings_dev.py b/apps/codecov-api/codecov/settings_dev.py
new file mode 100644
index 0000000000..dc882f4ef9
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_dev.py
@@ -0,0 +1,42 @@
+from .settings_base import *
+
+# Remove CSP headers from local development build to allow GQL Playground
+MIDDLEWARE.remove("csp.middleware.CSPMiddleware")
+
+DEBUG = True
+# for shelter add "host.docker.internal" and make sure to map it to localhost
+# in your /etc/hosts
+ALLOWED_HOSTS = get_config(
+ "setup",
+ "api_allowed_hosts",
+ default=["localhost", "api.lt.codecov.dev", "host.docker.internal"],
+)
+
+WEBHOOK_URL = "" # NGROK TUNNEL HERE
+
+STRIPE_API_KEY = get_config("services", "stripe", "api_key", default="default")
+STRIPE_ENDPOINT_SECRET = get_config(
+ "services", "stripe", "endpoint_secret", default="default"
+)
+
+CORS_ALLOW_CREDENTIALS = True
+
+CODECOV_URL = "localhost"
+CODECOV_API_URL = get_config("setup", "codecov_api_url", default=CODECOV_URL)
+CODECOV_DASHBOARD_URL = "http://localhost:3000"
+
+CORS_ALLOWED_ORIGINS = [
+ CODECOV_DASHBOARD_URL,
+ "http://localhost",
+ "http://localhost:9000",
+]
+
+COOKIES_DOMAIN = "localhost"
+SESSION_COOKIE_DOMAIN = "localhost"
+
+# add for shelter
+# SHELTER_SHARED_SECRET = "test-supertoken"
+
+GUEST_ACCESS = True
+
+GRAPHQL_INTROSPECTION_ENABLED = True
diff --git a/apps/codecov-api/codecov/settings_enterprise.py b/apps/codecov-api/codecov/settings_enterprise.py
new file mode 100644
index 0000000000..5fd3991d40
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_enterprise.py
@@ -0,0 +1,76 @@
+import os
+
+from utils.config import get_config
+
+from .settings_base import *
+
+DEBUG = False
+THIS_POD_IP = os.environ.get("THIS_POD_IP")
+ALLOWED_HOSTS = get_config("setup", "api_allowed_hosts", default=["*"])
+if THIS_POD_IP:
+ ALLOWED_HOSTS.append(THIS_POD_IP)
+CORS_ALLOW_CREDENTIALS = True
+# Setting default to localhost to avoid errors when running compilation steps.
+# This is "fine" because the app surely won't be in a working state without a valid url.
+CODECOV_URL = get_config("setup", "codecov_url", default="http://localhost")
+CODECOV_API_URL = get_config("setup", "codecov_api_url", default=CODECOV_URL)
+
+DEFAULT_TRUSTED_ORIGIN = None
+
+# select out CODECOV_URL domain
+if CODECOV_URL.startswith("https://"):
+ DEFAULT_WHITELISTED_DOMAIN = CODECOV_URL[8:]
+elif CODECOV_URL.startswith("http://"):
+ DEFAULT_WHITELISTED_DOMAIN = CODECOV_URL[7:]
+# select out CODECOV_API_URL domain
+if CODECOV_API_URL.startswith("https://"):
+ API_DOMAIN = CODECOV_API_URL[8:]
+ DEFAULT_TRUSTED_ORIGIN = f"https://*.{API_DOMAIN}"
+elif CODECOV_API_URL.startswith("http://"):
+ API_DOMAIN = CODECOV_API_URL[7:]
+ DEFAULT_TRUSTED_ORIGIN = f"http://*.{API_DOMAIN}"
+
+CORS_ALLOWED_ORIGINS = get_config(
+ "setup", "api_cors_allowed_origins", default=[CODECOV_URL]
+)
+ALLOWED_HOSTS.append(DEFAULT_WHITELISTED_DOMAIN)
+# only add api domain if it is different than codecov url
+if API_DOMAIN != DEFAULT_WHITELISTED_DOMAIN:
+ ALLOWED_HOSTS.append(API_DOMAIN)
+# Referenced at module level of services/billing.py, so it needs to be defined
+STRIPE_API_KEY = None
+SILENCED_SYSTEM_CHECKS = ["urls.W002"]
+UPLOAD_THROTTLING_ENABLED = False
+
+BITBUCKET_REDIRECT_URI = get_config(
+ "bitbucket", "redirect_uri", default=f"{CODECOV_URL}/login/bitbucket"
+)
+GITLAB_REDIRECT_URI = get_config(
+ "gitlab", "redirect_uri", default=f"{CODECOV_URL}/login/gitlab"
+)
+
+
+GITLAB_ENTERPRISE_REDIRECT_URI = get_config(
+ "gitlab_enterprise",
+ "redirect_uri",
+ default=f"{CODECOV_URL}/login/gle",
+)
+
+CODECOV_DASHBOARD_URL = get_config(
+ "setup", "codecov_dashboard_url", default=CODECOV_URL
+)
+
+COOKIES_DOMAIN = get_config(
+ "setup", "http", "cookies_domain", default=f".{DEFAULT_WHITELISTED_DOMAIN}"
+)
+SESSION_COOKIE_DOMAIN = COOKIES_DOMAIN
+
+ADMINS_LIST = get_config("setup", "admins", default=[])
+
+CSRF_TRUSTED_ORIGINS = [
+ get_config("setup", "trusted_origin", default=DEFAULT_TRUSTED_ORIGIN)
+]
+
+GUEST_ACCESS = get_config("setup", "guest_access", default=True)
+
+SHELTER_ENABLED = get_config("setup", "shelter_enabled", default=False)
diff --git a/apps/codecov-api/codecov/settings_prod.py b/apps/codecov-api/codecov/settings_prod.py
new file mode 100644
index 0000000000..ca3c2c91d6
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_prod.py
@@ -0,0 +1,45 @@
+import os
+
+from .settings_base import *
+
+DEBUG = False
+THIS_POD_IP = os.environ.get("THIS_POD_IP")
+ALLOWED_HOSTS = get_config(
+ "setup", "api_allowed_hosts", default=["codecov.io-shadow", ".codecov.io"]
+)
+if THIS_POD_IP:
+ ALLOWED_HOSTS.append(THIS_POD_IP)
+
+WEBHOOK_URL = get_config("setup", "webhook_url", default="https://codecov.io")
+
+
+STRIPE_API_KEY = os.environ.get("SERVICES__STRIPE__API_KEY", None)
+STRIPE_ENDPOINT_SECRET = os.environ.get("SERVICES__STRIPE__ENDPOINT_SECRET", None)
+
+CORS_ALLOW_HEADERS += ["sentry-trace", "baggage"]
+CORS_ALLOW_CREDENTIALS = True
+CODECOV_URL = get_config("setup", "codecov_url", default="https://codecov.io")
+CODECOV_API_URL = get_config("setup", "codecov_api_url", default=CODECOV_URL)
+CODECOV_DASHBOARD_URL = get_config(
+ "setup", "codecov_dashboard_url", default="https://app.codecov.io"
+)
+CORS_ALLOWED_ORIGINS = [
+ CODECOV_URL,
+ CODECOV_DASHBOARD_URL,
+ "https://gazebo.netlify.app", # to access unreleased URL of gazebo
+]
+# We are also using the CORS settings to verify if the domain is safe to
+# Redirect after authentication, update this setting with care
+CORS_ALLOWED_ORIGIN_REGEXES = []
+
+# 25MB in bytes
+DATA_UPLOAD_MAX_MEMORY_SIZE = 26214400
+
+SILENCED_SYSTEM_CHECKS = ["urls.W002"]
+
+# Reinforcing the Cookie SameSite configuration to be sure it's Lax in prod
+COOKIE_SAME_SITE = "Lax"
+
+CSRF_TRUSTED_ORIGINS = [
+ get_config("setup", "trusted_origin", default="https://*.codecov.io")
+]
diff --git a/apps/codecov-api/codecov/settings_staging.py b/apps/codecov-api/codecov/settings_staging.py
new file mode 100644
index 0000000000..1049e192de
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_staging.py
@@ -0,0 +1,65 @@
+import os
+
+from .settings_base import *
+
+DEBUG = False
+THIS_POD_IP = os.environ.get("THIS_POD_IP")
+ALLOWED_HOSTS = get_config(
+ "setup", "api_allowed_hosts", default=["stage-api.codecov.dev"]
+)
+if THIS_POD_IP:
+ ALLOWED_HOSTS.append(THIS_POD_IP)
+
+WEBHOOK_URL = get_config(
+ "setup", "webhook_url", default="https://stage-api.codecov.dev"
+)
+
+STRIPE_API_KEY = os.environ.get("SERVICES__STRIPE__API_KEY", None)
+STRIPE_ENDPOINT_SECRET = os.environ.get("SERVICES__STRIPE__ENDPOINT_SECRET", None)
+COOKIES_DOMAIN = ".codecov.dev"
+SESSION_COOKIE_DOMAIN = ".codecov.dev"
+
+CORS_ALLOW_HEADERS += ["sentry-trace", "baggage"]
+CORS_ALLOWED_ORIGIN_REGEXES = [
+ r"^(https:\/\/)?deploy-preview-\d+--codecov\.netlify\.app$",
+ r"^(https:\/\/)?deploy-preview-\d+--stage-app\.netlify\.app$",
+ r"^(https:\/\/)?deploy-preview-\d+--codecov-stage\.netlify\.app$",
+ r"^(https:\/\/)?deploy-preview-\d+--gazebo\.netlify\.app$",
+ r"^(https:\/\/)?deploy-preview-\d+--gazebo-staging\.netlify\.app$",
+ r"^(https:\/\/)?\w+--gazebo\.netlify\.app$",
+ r"^(https:\/\/)?preview-[\w\d\-]+\.codecov\.dev$",
+]
+CORS_ALLOW_CREDENTIALS = True
+
+CODECOV_URL = get_config(
+ "setup", "codecov_url", default="https://stage-web.codecov.dev"
+)
+CODECOV_API_URL = get_config("setup", "codecov_api_url", default=CODECOV_URL)
+CODECOV_DASHBOARD_URL = get_config(
+ "setup", "codecov_dashboard_url", default="https://stage-app.codecov.dev"
+)
+CORS_ALLOWED_ORIGINS = [
+ CODECOV_URL,
+ CODECOV_DASHBOARD_URL,
+ "https://gazebo.netlify.app",
+ "https://gazebo-staging.netlify.app",
+ "http://localhost:3000",
+]
+
+# 25MB in bytes
+DATA_UPLOAD_MAX_MEMORY_SIZE = 26214400
+
+GRAPHQL_RATE_LIMIT_RPM = get_config("setup", "graphql", "rate_limit_rpm", default=1000)
+
+# Same site is set to none on Staging as we want to be able to call the API
+# From Netlify preview deploy
+COOKIE_SAME_SITE = "None"
+SESSION_COOKIE_SAMESITE = "None"
+
+GCS_BUCKET_NAME = get_config("services", "minio", "bucket", default="codecov-staging")
+
+CSRF_TRUSTED_ORIGINS = [
+ get_config("setup", "trusted_origin", default="https://*.codecov.dev")
+]
+
+GRAPHQL_INTROSPECTION_ENABLED = True
diff --git a/apps/codecov-api/codecov/settings_test.py b/apps/codecov-api/codecov/settings_test.py
new file mode 100644
index 0000000000..2e98ad868a
--- /dev/null
+++ b/apps/codecov-api/codecov/settings_test.py
@@ -0,0 +1,15 @@
+import os
+
+from .settings_dev import *
+
+ALLOWED_HOSTS = ["localhost"]
+CORS_ALLOWED_ORIGINS = ["http://localhost:9000", "http://localhost"]
+SHELTER_ENABLED = True
+SHELTER_PUBSUB_PROJECT_ID = "test-project-id"
+SHELTER_PUBSUB_SYNC_REPO_TOPIC_ID = "test-topic-id"
+
+# Mock the Pub/Sub host for testing
+# this prevents the pubsub SDK from trying to load credentials
+os.environ["PUBSUB_EMULATOR_HOST"] = "localhost"
+
+GRAPHQL_INTROSPECTION_ENABLED = True
diff --git a/apps/codecov-api/codecov/static/__init__.py b/apps/codecov-api/codecov/static/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov/tests/__init__.py b/apps/codecov-api/codecov/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov/tests/base_test.py b/apps/codecov-api/codecov/tests/base_test.py
new file mode 100644
index 0000000000..3eca9ab304
--- /dev/null
+++ b/apps/codecov-api/codecov/tests/base_test.py
@@ -0,0 +1,18 @@
+import json
+
+from django.conf import settings
+from django.test import TestCase
+
+
+class InternalAPITest(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # internal apis are behind a debug flag currently
+ # and django/pytest set DEBUG to false by default
+ # https://docs.djangoproject.com/en/dev/topics/testing/overview/#other-test-conditions
+ settings.DEBUG = False
+
+ @staticmethod
+ def json_content(response):
+ return json.loads(response.content.decode())
diff --git a/apps/codecov-api/codecov/tests/test_urls.py b/apps/codecov-api/codecov/tests/test_urls.py
new file mode 100644
index 0000000000..2ec92c2194
--- /dev/null
+++ b/apps/codecov-api/codecov/tests/test_urls.py
@@ -0,0 +1,9 @@
+from django.test import TestCase
+from django.test.client import Client
+
+
+class ViewTest(TestCase):
+ def test_health(self):
+ client = Client()
+ response = client.get("")
+ assert response.status_code == 200
diff --git a/apps/codecov-api/codecov/tests/test_views.py b/apps/codecov-api/codecov/tests/test_views.py
new file mode 100644
index 0000000000..ac5a923dd0
--- /dev/null
+++ b/apps/codecov-api/codecov/tests/test_views.py
@@ -0,0 +1,137 @@
+from json import loads
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov_auth.models import Service, User
+
+
+class OwnerAutocompleteSearchTest(TestCase):
+ def setUp(self):
+ self.user = User.objects.create(name="staff", is_staff=True)
+ self.unauthorized_user = User.objects.create(name="nonstaff", is_staff=False)
+ OwnerFactory(service=Service.GITHUB, service_id=1, username="user1")
+ OwnerFactory(service=Service.GITLAB, service_id=2, username="user2")
+ OwnerFactory(service=Service.GITHUB, service_id=3, username="user3")
+
+ def test_unauthorized_access(self):
+ self.client.force_login(self.unauthorized_user)
+ response = self.client.get("/admin-owner-autocomplete/", {"q": "github/user1"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(data["results"]), 0)
+
+ def test_search_by_two_terms(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-owner-autocomplete/", {"q": "github/user1"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(data["results"]), 1)
+
+ def test_search_by_one_term_service(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-owner-autocomplete/", {"q": "github"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 2)
+
+ def test_search_by_one_term_owner(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-owner-autocomplete/", {"q": "user1"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 1)
+
+
+class RepositoryAutocompleteSearchTest(TestCase):
+ def setUp(self):
+ self.user = User.objects.create(name="staff", is_staff=True)
+ self.unauthorized_user = User.objects.create(name="nonstaff", is_staff=False)
+
+ a = OwnerFactory(service=Service.GITHUB, service_id=4, username="user1")
+ b = OwnerFactory(service=Service.GITHUB, service_id=5, username="user3")
+ c = OwnerFactory(service=Service.GITLAB, service_id=6, username="user2")
+
+ RepositoryFactory(author=a, name="repo1")
+ RepositoryFactory(author=a, name="repo2")
+ RepositoryFactory(author=b, name="repo3")
+ RepositoryFactory(author=c, name="repo4")
+
+ def test_unauthorized_access(self):
+ self.client.force_login(self.unauthorized_user)
+ response = self.client.get(
+ "/admin-repository-autocomplete/", {"q": Service.GITHUB}
+ )
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(data["results"]), 0)
+
+ def test_search_by_three_terms(self):
+ self.client.force_login(self.user)
+ response = self.client.get(
+ "/admin-repository-autocomplete/", {"q": "github/user1/repo"}
+ )
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 2)
+
+ def test_search_by_three_terms_invalid_service(self):
+ self.client.force_login(self.user)
+ response = self.client.get(
+ "/admin-repository-autocomplete/", {"q": "geehub/user1/repo"}
+ )
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 0)
+
+ def test_search_by_two_terms_service(self):
+ self.client.force_login(self.user)
+ response = self.client.get(
+ "/admin-repository-autocomplete/", {"q": "github/user1"}
+ )
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 2)
+
+ def test_search_by_two_terms_owner(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-repository-autocomplete/", {"q": "user2/re"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 1)
+
+ def test_search_by_one_term_repo(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-repository-autocomplete/", {"q": "repo4"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 1)
+
+ def test_search_by_one_term_service(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin-repository-autocomplete/", {"q": "github"})
+ json_string = response._container[0].decode("utf-8")
+ data = loads(json_string)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(len(data["results"]) == 3)
diff --git a/apps/codecov-api/codecov/urls.py b/apps/codecov-api/codecov/urls.py
new file mode 100644
index 0000000000..5274507d2f
--- /dev/null
+++ b/apps/codecov-api/codecov/urls.py
@@ -0,0 +1,40 @@
+from django.conf import settings
+from django.contrib import admin
+from django.urls import include, path, re_path
+
+from api.internal.constants import INTERNAL_API_PREFIX
+from codecov import views
+
+urlpatterns = [
+ path("billing/", include("billing.urls")),
+ path("api/v2/", include("api.public.v2.urls")),
+ path("api/v1/", include("api.public.v1.urls")),
+ path("api/", include("api.public.v1.urls")), # for backwards compat
+ path(INTERNAL_API_PREFIX, include("api.internal.urls")),
+ re_path("^validate/?", include("validate.urls")),
+ path("health/", views.health),
+ path("api_health/", views.health),
+ path("", views.health),
+ path("///", include("graphs.urls")),
+ path("upload/", include("upload.urls")),
+ path("webhooks/", include("webhook_handlers.urls")),
+ path("graphql/", include("graphql_api.urls")),
+ path("", include("codecov_auth.urls")),
+ path(f"{settings.DJANGO_ADMIN_URL}/", admin.site.urls),
+ path("staticanalysis/", include("staticanalysis.urls")),
+ path("labels/", include("labelanalysis.urls")),
+ path(
+ "admin-repository-autocomplete/",
+ views.RepositoryAutoCompleteSearch.as_view(),
+ name="admin-repository-autocomplete",
+ ),
+ path(
+ "admin-owner-autocomplete/",
+ views.OwnerAutoCompleteSearch.as_view(),
+ name="admin-owner-autocomplete",
+ ),
+ # /monitoring/metrics will be a public route unless you take steps at a
+ # higher level to null-route or redirect it.
+ path("monitoring/", include("django_prometheus.urls")),
+ path("gen_ai/", include("api.gen_ai.urls")),
+]
diff --git a/apps/codecov-api/codecov/views.py b/apps/codecov-api/codecov/views.py
new file mode 100644
index 0000000000..08051037fc
--- /dev/null
+++ b/apps/codecov-api/codecov/views.py
@@ -0,0 +1,119 @@
+from dal import autocomplete
+from django.db import connection
+from django.http import HttpResponse
+
+from codecov_auth.models import Owner, Service
+from core.models import Constants, Repository
+
+_version = None
+
+
+def _get_version():
+ global _version
+ if _version is None:
+ _version = Constants.objects.get(key="version")
+ return _version
+
+
+def health(request):
+ # will raise if connection cannot be established
+ connection.ensure_connection()
+
+ version = _get_version()
+ return HttpResponse("%s is live!" % version.value)
+
+
+SERVICE_CHOICES = dict(Service.choices)
+
+
+class RepositoryAutoCompleteSearch(autocomplete.Select2QuerySetView):
+ def get_queryset(self):
+ # must be authorized to query
+ if not self.request.user.is_staff:
+ return Repository.objects.none()
+
+ repos = Repository.objects.all()
+
+ terms = self.q.split("/") if self.q else []
+
+ if len(terms) >= 3:
+ repos = self.filter_repos_with_three_terms(terms, repos)
+ elif len(terms) == 2:
+ repos = self.filter_repos_with_two_terms(terms, repos)
+ elif len(terms) == 1:
+ repos = self.filter_repos_with_one_terms(terms, repos)
+
+ return repos
+
+ def filter_repos_with_three_terms(self, terms, repos):
+ assert len(terms) >= 3
+
+ service = terms[0]
+
+ if service not in SERVICE_CHOICES:
+ return Repository.objects.none()
+
+ owner = terms[1]
+ repo = "/".join(terms[2:])
+ return repos.filter(
+ author__service=service, author__username=owner, name__startswith=repo
+ )
+
+ def filter_repos_with_two_terms(self, terms, repos):
+ assert len(terms) == 2
+
+ if terms[0] in SERVICE_CHOICES:
+ service = terms[0]
+ owner = terms[1]
+ return repos.filter(
+ author__service=service, author__username__startswith=owner
+ )
+ else:
+ owner = terms[0]
+ repo = terms[1]
+ return repos.filter(author__username=owner, name__startswith=repo)
+
+ def filter_repos_with_one_terms(self, terms, repos):
+ assert len(terms) == 1
+
+ if terms[0] in SERVICE_CHOICES:
+ service = terms[0]
+ return repos.filter(author__service=service)
+ else:
+ repo = terms[0]
+ return repos.filter(name__startswith=repo)
+
+
+class OwnerAutoCompleteSearch(autocomplete.Select2QuerySetView):
+ def get_queryset(self):
+ # must be authorized to query
+ if not self.request.user.is_staff:
+ return Owner.objects.none()
+
+ owners = Owner.objects.all()
+
+ terms = self.q.split("/") if self.q else []
+
+ if len(terms) >= 2:
+ owners = self.filter_owners_with_two_terms(terms, owners)
+ elif len(terms) == 1:
+ owners = self.filter_owners_with_one_term(terms, owners)
+
+ return owners
+
+ def filter_owners_with_two_terms(self, terms, owners):
+ assert len(terms) >= 2
+
+ service = terms[0]
+ username = "/".join(terms[1:])
+ return owners.filter(service=service, username__startswith=username)
+
+ def filter_owners_with_one_term(self, terms, owners):
+ assert len(terms) == 1
+
+ if terms[0] in SERVICE_CHOICES:
+ service = terms[0]
+ return owners.filter(service=service)
+ else:
+ username = terms[0]
+ return owners.filter(username__startswith=username)
diff --git a/apps/codecov-api/codecov/wsgi.py b/apps/codecov-api/codecov/wsgi.py
new file mode 100644
index 0000000000..38285feb8a
--- /dev/null
+++ b/apps/codecov-api/codecov/wsgi.py
@@ -0,0 +1,18 @@
+"""
+WSGI config for codecov project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+from utils.config import get_settings_module
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", get_settings_module())
+
+application = get_wsgi_application()
diff --git a/apps/codecov-api/codecov_auth/__init__.py b/apps/codecov-api/codecov_auth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/admin.py b/apps/codecov-api/codecov_auth/admin.py
new file mode 100644
index 0000000000..4699f8185c
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/admin.py
@@ -0,0 +1,826 @@
+import logging
+from datetime import timedelta
+from typing import Optional, Sequence
+
+import django.forms as forms
+from django.conf import settings
+from django.contrib import admin, messages
+from django.contrib.admin.models import LogEntry
+from django.db.models import OuterRef, Subquery
+from django.db.models.fields import BLANK_CHOICE_DASH
+from django.forms import CheckboxInput, Select, Textarea
+from django.http import HttpRequest
+from django.shortcuts import redirect, render
+from django.utils import timezone
+from django.utils.html import format_html
+from shared.django_apps.codecov_auth.models import (
+ Account,
+ AccountsUsers,
+ InvoiceBilling,
+ Plan,
+ StripeBilling,
+ Tier,
+)
+from shared.plan.service import PlanService
+
+from codecov.admin import AdminMixin
+from codecov.commands.exceptions import ValidationError
+from codecov_auth.helpers import History
+from codecov_auth.models import OrganizationLevelToken, Owner, SentryUser, Session, User
+from codecov_auth.services.org_level_token_service import OrgLevelTokenService
+from services.task import TaskService
+from utils.services import get_short_service_name
+
+log = logging.getLogger(__name__)
+
+
+class ExtendTrialForm(forms.Form):
+ end_date = forms.DateTimeField(
+ label="Trial End Date (YYYY-MM-DD HH:MM:SS):", required=True
+ )
+
+
+def extend_trial(self, request, queryset):
+ if "extend_trial" in request.POST:
+ form = ExtendTrialForm(request.POST)
+ if form.is_valid():
+ for org in queryset:
+ plan_service = PlanService(current_org=org)
+ try:
+ plan_service.start_trial_manually(
+ current_owner=request.current_owner,
+ end_date=form.cleaned_data["end_date"],
+ )
+ except ValidationError as e:
+ self.message_user(
+ request,
+ e.message + f" for {org.username}",
+ level=messages.ERROR,
+ )
+ else:
+ self.message_user(
+ request, f"Successfully started trial for {org.username}"
+ )
+ return
+ else:
+ form = ExtendTrialForm()
+
+ return render(
+ request,
+ "admin/extend_trial_form.html",
+ context={
+ "form": form,
+ "datasets": queryset,
+ },
+ )
+
+
+extend_trial.short_description = "Start and extend trial up to a selected date"
+
+
+def impersonate_owner(self, request, queryset):
+ if queryset.count() != 1:
+ self.message_user(
+ request, "You must impersonate exactly one Owner.", level=messages.ERROR
+ )
+ return
+
+ owner = queryset.first()
+ response = redirect(
+ f"{settings.CODECOV_URL}/{get_short_service_name(owner.service)}/"
+ )
+
+ # this cookie is read by the `ImpersonationMiddleware` and
+ # will reset `request.current_owner` to the impersonated owner
+ max_age = 900 # 15 minutes
+ response.set_cookie(
+ "staff_user",
+ owner.ownerid,
+ domain=settings.COOKIES_DOMAIN,
+ samesite=settings.COOKIE_SAME_SITE,
+ max_age=max_age,
+ )
+ History.log(
+ Owner.objects.get(ownerid=owner.ownerid),
+ "Impersonation successful",
+ request.user,
+ )
+ return response
+
+
+impersonate_owner.short_description = "Impersonate the selected owner"
+
+
+class AccountsUsersInline(admin.TabularInline):
+ model = AccountsUsers
+ max_num = 10
+ extra = 1
+ verbose_name_plural = "Accounts Users (click save to commit changes)"
+ verbose_name = "Account User"
+ can_delete = False
+ can_edit = False
+
+
+class OwnerUserInline(admin.TabularInline):
+ model = Owner
+ max_num = 5
+ extra = 0
+ verbose_name_plural = "Owners (read only)"
+ verbose_name = "Owner"
+ exclude = ("oauth_token",)
+ can_delete = False
+
+ readonly_fields = [
+ "name",
+ "username",
+ "email",
+ "service",
+ "student",
+ ]
+
+ fields = [] + readonly_fields
+
+
+@admin.register(User)
+class UserAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = (
+ "name",
+ "email",
+ )
+ inlines = [AccountsUsersInline, OwnerUserInline]
+ search_fields = (
+ "name__iregex",
+ "email__iregex",
+ )
+
+ readonly_fields = (
+ "id",
+ "external_id",
+ )
+
+ fields = readonly_fields + (
+ "name",
+ "email",
+ "is_staff",
+ "terms_agreement",
+ "terms_agreement_at",
+ )
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+
+ if not request.user.is_superuser:
+ form.base_fields["is_staff"].disabled = True
+
+ return form
+
+ def has_add_permission(self, _, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+
+@admin.register(SentryUser)
+class SentryUserAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = (
+ "name",
+ "email",
+ )
+ search_fields = (
+ "name__iregex",
+ "email__iregex",
+ )
+ readonly_fields = (
+ "id",
+ "external_id",
+ "sentry_id",
+ "user",
+ )
+ fields = readonly_fields + (
+ "name",
+ "email",
+ )
+
+ def has_add_permission(self, _, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+
+class OrgUploadTokenInline(admin.TabularInline):
+ model = OrganizationLevelToken
+ readonly_fields = ["token", "refresh"]
+ fields = ["token", "valid_until", "token_type", "refresh"]
+ extra = 0
+ max_num = 1
+ verbose_name = "Organization Level Token"
+
+ def refresh(self, obj: OrganizationLevelToken):
+ # 0 in this case refers to the 0th index of the inline
+ # But there can only ever be 1 token per org, so it's fine to use that.
+ return format_html(
+ ''
+ )
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_staff
+
+ def has_add_permission(self, request: HttpRequest, obj: Optional[Owner]) -> bool:
+ has_token = OrganizationLevelToken.objects.filter(owner=obj).count() > 0
+ return (not has_token) and request.user.is_staff
+
+
+class InvoiceBillingInline(admin.StackedInline):
+ model = InvoiceBilling
+ extra = 0
+ can_delete = False
+ verbose_name_plural = "Invoice Billing"
+ verbose_name = "Invoice Billing (click save to commit changes)"
+
+
+@admin.register(InvoiceBilling)
+class InvoiceBillingAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("id", "account", "is_active")
+ search_fields = (
+ "account__name",
+ "account__id__iexact",
+ "id__iexact",
+ "account_manager",
+ )
+ search_help_text = (
+ "Search by account name, account id (exact), id (exact), or account_manager"
+ )
+ autocomplete_fields = ("account",)
+
+ readonly_fields = [
+ "id",
+ "created_at",
+ "updated_at",
+ ]
+
+ fields = readonly_fields + [
+ "account",
+ "account_manager",
+ "invoice_notes",
+ "is_active",
+ ]
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super().get_form(request, obj, **kwargs)
+ field = form.base_fields["account"]
+ field.widget.can_add_related = False
+ field.widget.can_change_related = False
+ field.widget.can_delete_related = False
+ return form
+
+
+class StripeBillingInline(admin.StackedInline):
+ can_delete = False
+ extra = 0
+ model = StripeBilling
+ verbose_name_plural = "Stripe Billing"
+ verbose_name = "Stripe Billing (click save to commit changes)"
+
+
+@admin.register(StripeBilling)
+class StripeBillingAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("id", "account", "is_active")
+ search_fields = (
+ "account__name",
+ "account__id__iexact",
+ "id__iexact",
+ "customer_id__iexact",
+ "subscription_id__iexact",
+ )
+ search_help_text = "Search by account name, account id (exact), id (exact), customer_id (exact), or subscription_id (exact)"
+ autocomplete_fields = ("account",)
+
+ readonly_fields = [
+ "id",
+ "created_at",
+ "updated_at",
+ ]
+
+ fields = readonly_fields + [
+ "account",
+ "customer_id",
+ "subscription_id",
+ "is_active",
+ ]
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super().get_form(request, obj, **kwargs)
+ field = form.base_fields["account"]
+ field.widget.can_add_related = False
+ field.widget.can_change_related = False
+ field.widget.can_delete_related = False
+ return form
+
+
+class OwnerOrgInline(admin.TabularInline):
+ model = Owner
+ max_num = 100
+ extra = 0
+ verbose_name_plural = "Organizations (read only)"
+ verbose_name = "Organization"
+ exclude = ("oauth_token",)
+ can_delete = False
+
+ readonly_fields = [
+ "name",
+ "username",
+ "plan",
+ "plan_activated_users",
+ "service",
+ ]
+
+ fields = [] + readonly_fields
+
+
+def find_and_remove_stale_users(
+ orgs: Sequence[Owner], date_threshold: timedelta | None = None
+) -> tuple[set[int], set[int]]:
+ """
+ This functions finds all the stale `plan_activated_users` in any of the given `orgs`.
+
+ It then removes all those stale users from the given `orgs`,
+ returning the set of stale users (`ownerid`), and the set of `orgs` that were updated (`ownerid`).
+
+ A user is considered stale if it had no API or login `Session` or any opened PR within `date_threshold`.
+ If no `date_threshold` is given, it defaults to *90 days*.
+ """
+
+ active_users = set()
+ for org in orgs:
+ active_users.update(set(org.plan_activated_users))
+
+ if not active_users:
+ return (set(), set())
+
+ # NOTE: the `annotate_last_pull_timestamp` manager/queryset method does the same `annotate` with `Subquery`.
+ sessions = Session.objects.filter(owner=OuterRef("pk")).order_by("-lastseen")
+ resolved_users = list(
+ Owner.objects.filter(ownerid__in=active_users)
+ .annotate(latest_session=Subquery(sessions.values("lastseen")[:1]))
+ .annotate_last_pull_timestamp()
+ .values_list("ownerid", "latest_session", "last_pull_timestamp", named=True)
+ )
+
+ threshold = timezone.now() - (date_threshold or timedelta(days=90))
+
+ def is_stale(user: dict) -> bool:
+ return (user.latest_session is None or user.latest_session < threshold) and (
+ # NOTE: `last_pull_timestamp` is not timezone-aware, so we explicitly compare without timezones here
+ user.last_pull_timestamp is None
+ or user.last_pull_timestamp.replace(tzinfo=None)
+ < threshold.replace(tzinfo=None)
+ )
+
+ stale_users = {user.ownerid for user in resolved_users if is_stale(user)}
+ affected_orgs = {
+ org for org in orgs if stale_users.intersection(set(org.plan_activated_users))
+ }
+
+ if not affected_orgs:
+ return (set(), set())
+
+ # TODO: it might make sense to run all this within a transaction and locking the `affected_orgs` for update,
+ # as we have a slight chance of races between querying the `orgs` at the very beginning and updating them here:
+ for org in affected_orgs:
+ org.plan_activated_users = list(
+ set(org.plan_activated_users).difference(stale_users)
+ )
+ Owner.objects.bulk_update(affected_orgs, ["plan_activated_users"])
+
+ return (stale_users, {org.ownerid for org in affected_orgs})
+
+
+@admin.register(Account)
+class AccountAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("name", "is_active", "organizations_count", "all_user_count")
+ search_fields = ("name__iregex", "id")
+ search_help_text = "Search by name (can use regex), or id (exact)"
+ inlines = [OwnerOrgInline, StripeBillingInline, InvoiceBillingInline]
+ actions = ["seat_check", "link_users_to_account", "deactivate_stale_users"]
+
+ readonly_fields = ["id", "created_at", "updated_at", "users"]
+
+ fields = readonly_fields + [
+ "name",
+ "is_active",
+ "plan",
+ "plan_seat_count",
+ "free_seat_count",
+ "plan_auto_activate",
+ "is_delinquent",
+ ]
+
+ @admin.action(description="Deactivate all stale `plan_activated_users`")
+ def deactivate_stale_users(self, request, queryset):
+ orgs = [org for account in queryset for org in account.organizations.all()]
+ stale_users, updated_orgs = find_and_remove_stale_users(orgs)
+
+ if not stale_users or not updated_orgs:
+ self.message_user(
+ request,
+ "No stale users found in selected accounts / organizations.",
+ messages.INFO,
+ )
+ else:
+ self.message_user(
+ request,
+ f"Removed {len(stale_users)} stale users from {len(updated_orgs)} affected organizations.",
+ messages.SUCCESS,
+ )
+
+ @admin.action(
+ description="Count current plan_activated_users across all Organizations"
+ )
+ def seat_check(self, request, queryset):
+ self.link_users_to_account(request, queryset, dry_run=True)
+
+ @admin.action(description="Link Users to Account")
+ def link_users_to_account(self, request, queryset, dry_run=False):
+ for account in queryset:
+ account_plan_activated_user_ownerids = set()
+ for org in account.organizations.all():
+ account_plan_activated_user_ownerids.update(
+ set(org.plan_activated_users)
+ )
+
+ account_plan_activated_user_owners = Owner.objects.filter(
+ ownerid__in=account_plan_activated_user_ownerids
+ ).prefetch_related("user")
+
+ non_student_count = account_plan_activated_user_owners.exclude(
+ student=True
+ ).count()
+ total_seats_for_account = account.plan_seat_count + account.free_seat_count
+ if non_student_count > total_seats_for_account:
+ self.message_user(
+ request,
+ f"Request failed: Account plan does not have enough seats; "
+ f"current plan activated users (non-students): {non_student_count}, total seats for account: {total_seats_for_account}",
+ messages.ERROR,
+ )
+ return
+ if dry_run:
+ self.message_user(
+ request,
+ f"Request succeeded: Account plan has enough seats! "
+ f"current plan activated users (non-students): {non_student_count}, total seats for account: {total_seats_for_account}",
+ messages.SUCCESS,
+ )
+ return
+
+ owners_without_user_objects = account_plan_activated_user_owners.filter(
+ user__isnull=True
+ )
+ owners_with_new_user_objects = []
+ for userless_owner in owners_without_user_objects:
+ new_user = User.objects.create(
+ name=userless_owner.name, email=userless_owner.email
+ )
+ userless_owner.user = new_user
+ owners_with_new_user_objects.append(userless_owner)
+ total = Owner.objects.bulk_update(owners_with_new_user_objects, ["user"])
+ self.message_user(
+ request,
+ f"Created a User for {total} Owners",
+ messages.INFO,
+ )
+ if total > 0:
+ log.info(
+ f"Admin operation for {account} - Created a User for {total} Owners",
+ extra=dict(
+ owners_with_new_user_objects=[
+ str(owner) for owner in owners_with_new_user_objects
+ ],
+ account_id=account.id,
+ ),
+ )
+
+ # redo this query to get all Owners and Users
+ account_plan_activated_user_owners = Owner.objects.filter(
+ ownerid__in=account_plan_activated_user_ownerids
+ ).prefetch_related("user")
+
+ already_linked_account_users = AccountsUsers.objects.filter(account=account)
+
+ not_yet_linked_owners = account_plan_activated_user_owners.exclude(
+ user_id__in=already_linked_account_users.values_list(
+ "user_id", flat=True
+ )
+ )
+
+ account_users_that_should_be_unlinked = (
+ already_linked_account_users.exclude(
+ user_id__in=account_plan_activated_user_owners.values_list(
+ "user_id", flat=True
+ )
+ )
+ )
+ deleted_ids_for_log = list(
+ account_users_that_should_be_unlinked.values_list("id", flat=True)
+ )
+ deleted_count, _ = account_users_that_should_be_unlinked.delete()
+
+ new_accounts_users = []
+ for owner in not_yet_linked_owners:
+ new_account_user = AccountsUsers(
+ user_id=owner.user_id, account_id=account.id
+ )
+ new_accounts_users.append(new_account_user)
+ total = AccountsUsers.objects.bulk_create(new_accounts_users)
+ self.message_user(
+ request,
+ f"Created {len(total)} AccountsUsers, removed {deleted_count} AccountsUsers",
+ messages.SUCCESS,
+ )
+ if len(total) > 0 or deleted_count > 0:
+ log.info(
+ f"Admin operation for {account} - Created {len(total)} AccountsUsers, removed {deleted_count} AccountsUsers",
+ extra=dict(
+ new_accounts_users=total,
+ removed_accounts_users_ids=deleted_ids_for_log,
+ account_id=account.id,
+ ),
+ )
+
+
+@admin.register(Owner)
+class OwnerAdmin(AdminMixin, admin.ModelAdmin):
+ exclude = ("oauth_token",)
+ list_display = ("name", "username", "email", "service")
+ readonly_fields = []
+ search_fields = ("name__iregex", "username__iregex", "email__iregex", "ownerid")
+ actions = [impersonate_owner, extend_trial]
+ autocomplete_fields = ("bot", "account")
+ inlines = [OrgUploadTokenInline]
+
+ readonly_fields = (
+ "ownerid",
+ "username",
+ "service",
+ "email",
+ "business_email",
+ "name",
+ "service_id",
+ "createstamp",
+ "parent_service_id",
+ "root_parent_service_id",
+ "private_access",
+ "cache",
+ "free",
+ "invoice_details",
+ "yaml",
+ "updatestamp",
+ "permission",
+ "student",
+ "student_created_at",
+ "student_updated_at",
+ "user",
+ "trial_fired_by",
+ )
+
+ fields = readonly_fields + (
+ "admins",
+ "plan_auto_activate",
+ "onboarding_completed",
+ "staff",
+ "plan",
+ "plan_provider",
+ "plan_user_count",
+ "plan_activated_users",
+ "uses_invoice",
+ "delinquent",
+ "integration_id",
+ "bot",
+ "stripe_customer_id",
+ "stripe_subscription_id",
+ "organizations",
+ "max_upload_limit",
+ "account",
+ "upload_token_required_for_public_repos",
+ )
+
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form = super().get_form(request, obj, change, **kwargs)
+ PLANS_CHOICES = [
+ (x, x)
+ for x in Plan.objects.filter(is_active=True).values_list("name", flat=True)
+ ]
+ form.base_fields["plan"].widget = Select(
+ choices=BLANK_CHOICE_DASH + PLANS_CHOICES
+ )
+ form.base_fields["uses_invoice"].widget = CheckboxInput()
+
+ is_superuser = request.user.is_superuser
+ if not is_superuser:
+ form.base_fields["staff"].disabled = True
+
+ field = form.base_fields["account"]
+ field.widget.can_add_related = False
+ field.widget.can_change_related = False
+ field.widget.can_delete_related = False
+
+ return form
+
+ def has_add_permission(self, _, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return bool(request.user and request.user.is_superuser)
+
+ def delete_queryset(self, request, queryset) -> None:
+ for owner in queryset:
+ TaskService().delete_owner(ownerid=owner.ownerid)
+
+ def delete_model(self, request, obj) -> None:
+ TaskService().delete_owner(ownerid=obj.ownerid)
+
+ def get_deleted_objects(self, objs, request):
+ (
+ deleted_objects,
+ model_count,
+ perms_needed,
+ protected,
+ ) = super().get_deleted_objects(objs, request)
+
+ if request.user and request.user.is_superuser:
+ perms_needed = set()
+
+ deleted_objects = ()
+ return deleted_objects, model_count, perms_needed, protected
+
+ def save_related(self, request: HttpRequest, form, formsets, change: bool) -> None:
+ if formsets:
+ token_formset = formsets[0]
+ token_id = token_formset.data.get("organization_tokens-0-id")
+ token_refresh = token_formset.data.get("organization_tokens-0-REFRESH")
+ # token_id only exists if the token already exists (edit operation)
+ if token_formset.is_valid() and token_id and token_refresh:
+ OrgLevelTokenService.refresh_token(token_id)
+ return super().save_related(request, form, formsets, change)
+
+
+@admin.register(LogEntry)
+class LogEntryAdmin(admin.ModelAdmin):
+ readonly_fields = (
+ "action_time",
+ "user",
+ "content_type",
+ "object_id",
+ "object_repr",
+ "action_flag",
+ "change_message",
+ )
+ list_display = ["__str__", "action_time", "user", "change_message"]
+ search_fields = ("object_repr", "change_message")
+
+ # keep only view permission
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+
+@admin.register(AccountsUsers)
+class AccountsUsersAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("id", "user", "account")
+ search_fields = (
+ "account__name",
+ "account__id__iexact",
+ "id__iexact",
+ "user__id__iexact",
+ "user__name",
+ "user__email",
+ )
+ search_help_text = "Search by account name, account id (exact), id (exact), user id (exact), user's name or email"
+ autocomplete_fields = ("account", "user")
+
+ readonly_fields = [
+ "id",
+ "created_at",
+ "updated_at",
+ ]
+
+ fields = readonly_fields + ["account", "user"]
+
+
+class PlansInline(admin.TabularInline):
+ model = Plan
+ extra = 1
+ verbose_name_plural = "Plans (click save to commit changes)"
+ verbose_name = "Plan"
+ fields = [
+ "name",
+ "marketing_name",
+ "base_unit_price",
+ "billing_rate",
+ "max_seats",
+ "monthly_uploads_limit",
+ "paid_plan",
+ "is_active",
+ "stripe_id",
+ ]
+ formfield_overrides = {
+ Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
+ }
+
+
+@admin.register(Tier)
+class TierAdmin(admin.ModelAdmin):
+ list_display = (
+ "tier_name",
+ "bundle_analysis",
+ "test_analytics",
+ "flaky_test_detection",
+ "project_coverage",
+ "private_repo_support",
+ )
+ list_editable = (
+ "bundle_analysis",
+ "test_analytics",
+ "flaky_test_detection",
+ "project_coverage",
+ "private_repo_support",
+ )
+ search_fields = ("tier_name__iregex",)
+ inlines = [PlansInline]
+ fields = [
+ "tier_name",
+ "bundle_analysis",
+ "test_analytics",
+ "flaky_test_detection",
+ "project_coverage",
+ "private_repo_support",
+ ]
+
+
+class PlanAdminForm(forms.ModelForm):
+ class Meta:
+ model = Plan
+ fields = "__all__"
+
+ def clean_base_unit_price(self) -> int | None:
+ base_unit_price = self.cleaned_data.get("base_unit_price")
+ if base_unit_price is not None and base_unit_price < 0:
+ raise forms.ValidationError("Base unit price cannot be negative.")
+ return base_unit_price
+
+ def clean_max_seats(self) -> int | None:
+ max_seats = self.cleaned_data.get("max_seats")
+ if max_seats is not None and max_seats < 0:
+ raise forms.ValidationError("Max seats cannot be negative.")
+ return max_seats
+
+ def clean_monthly_uploads_limit(self) -> int | None:
+ monthly_uploads_limit = self.cleaned_data.get("monthly_uploads_limit")
+ if monthly_uploads_limit is not None and monthly_uploads_limit < 0:
+ raise forms.ValidationError("Monthly uploads limit cannot be negative.")
+ return monthly_uploads_limit
+
+
+@admin.register(Plan)
+class PlanAdmin(admin.ModelAdmin):
+ form = PlanAdminForm
+ list_display = (
+ "name",
+ "marketing_name",
+ "is_active",
+ "tier",
+ "paid_plan",
+ "billing_rate",
+ "base_unit_price",
+ "max_seats",
+ "monthly_uploads_limit",
+ )
+ list_filter = ("is_active", "paid_plan", "billing_rate", "tier")
+ search_fields = ("name__iregex", "marketing_name__iregex")
+ fields = [
+ "tier",
+ "name",
+ "marketing_name",
+ "base_unit_price",
+ "benefits",
+ "billing_rate",
+ "is_active",
+ "max_seats",
+ "monthly_uploads_limit",
+ "paid_plan",
+ "stripe_id",
+ ]
+ formfield_overrides = {
+ Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
+ }
+ autocomplete_fields = ["tier"] # a dropdown for selecting related Tiers
diff --git a/apps/codecov-api/codecov_auth/apps.py b/apps/codecov-api/codecov_auth/apps.py
new file mode 100644
index 0000000000..ee2fabc65d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class CodecovAuthConfig(AppConfig):
+ name = "codecov_auth"
+
+ def ready(self):
+ import codecov_auth.signals # noqa: F401
diff --git a/apps/codecov-api/codecov_auth/authentication/__init__.py b/apps/codecov-api/codecov_auth/authentication/__init__.py
new file mode 100644
index 0000000000..3cfc03f763
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/authentication/__init__.py
@@ -0,0 +1,73 @@
+import logging
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+from rest_framework import authentication, exceptions
+
+from codecov_auth.authentication.types import (
+ InternalToken,
+ InternalUser,
+ SuperToken,
+ SuperUser,
+)
+from codecov_auth.models import UserToken
+
+log = logging.getLogger(__name__)
+
+
+class UserTokenAuthentication(authentication.TokenAuthentication):
+ keyword = "Bearer"
+
+ def authenticate(self, request):
+ # we save the request here so that we can set `current_owner` below
+ self.request = request
+ res = super().authenticate(request)
+ self.request = None
+ return res
+
+ def authenticate_credentials(self, token):
+ try:
+ token = UserToken.objects.select_related("owner").get(token=token)
+ except (UserToken.DoesNotExist, ValidationError):
+ raise exceptions.AuthenticationFailed("Invalid token.")
+
+ if token.valid_until is not None and token.valid_until <= timezone.now():
+ raise exceptions.AuthenticationFailed("Invalid token.")
+
+ if self.request:
+ # some permissions checking relies on this being available
+ self.request.current_owner = token.owner
+
+ # NOTE: this is a bit unconventional in that it will result in
+ # `request.user` being an `Owner` instance instead of a `User`.
+ # If we returend `token.owner.user` here instead then we'd potentially
+ # break existing API clients with tokens being used on behalf of owners
+ # that have not logged into Codecov (and created a corresponding user record).
+ # i.e. `token.owner.user` could potentially be `None`
+ return (token.owner, token)
+
+
+class SuperTokenAuthentication(authentication.TokenAuthentication):
+ keyword = "Bearer"
+
+ def authenticate_credentials(self, key):
+ if key == settings.SUPER_API_TOKEN:
+ return (SuperUser(), SuperToken(token=key))
+ return None
+
+
+class InternalTokenAuthentication(authentication.TokenAuthentication):
+ keyword = "Bearer"
+
+ def authenticate_credentials(self, key):
+ if key == settings.CODECOV_INTERNAL_TOKEN:
+ return (InternalUser(), InternalToken(token=key))
+
+ raise exceptions.AuthenticationFailed("Invalid token.")
+
+
+class SessionAuthentication(authentication.SessionAuthentication):
+ def enforce_csrf(self, request):
+ # disable CSRF for the REST API
+ pass
diff --git a/apps/codecov-api/codecov_auth/authentication/helpers.py b/apps/codecov-api/codecov_auth/authentication/helpers.py
new file mode 100644
index 0000000000..e5ee82aa27
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/authentication/helpers.py
@@ -0,0 +1,29 @@
+import re
+from typing import NamedTuple
+
+from django.http import HttpRequest
+
+
+class UploadInfo(NamedTuple):
+ service: str
+ encoded_slug: str
+ commitid: str | None
+
+
+def get_upload_info_from_request_path(request: HttpRequest) -> UploadInfo | None:
+ path_info = request.get_full_path_info()
+ # The repo part comes from https://stackoverflow.com/a/22312124
+ upload_views_prefix_regex = r"\/upload\/(\w+)\/([\w\.@:_/\-~]+)\/(commits|upload-coverage)(?:\/([a-f0-9]{40}))?"
+ match = re.search(upload_views_prefix_regex, path_info)
+
+ if match is None:
+ return None
+
+ service = match.group(1)
+ encoded_slug = match.group(2)
+ if match.group(3) == "commits":
+ commitid = match.group(4)
+ else:
+ commitid = None
+
+ return UploadInfo(service, encoded_slug, commitid)
diff --git a/apps/codecov-api/codecov_auth/authentication/repo_auth.py b/apps/codecov-api/codecov_auth/authentication/repo_auth.py
new file mode 100644
index 0000000000..628e084bd1
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/authentication/repo_auth.py
@@ -0,0 +1,526 @@
+import json
+import logging
+from typing import Any, Dict, List, Optional, Tuple
+from uuid import UUID
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import QuerySet
+from django.http import HttpRequest
+from django.utils import timezone
+from jwt import PyJWTError
+from rest_framework import authentication, exceptions, serializers
+from rest_framework.exceptions import NotAuthenticated, ValidationError
+from rest_framework.response import Response
+from rest_framework.views import exception_handler
+from shared.django_apps.codecov_auth.models import Owner
+
+from codecov_auth.authentication.helpers import get_upload_info_from_request_path
+from codecov_auth.authentication.types import RepositoryAsUser, RepositoryAuthInterface
+from codecov_auth.models import (
+ OrganizationLevelToken,
+ RepositoryToken,
+ Service,
+ TokenTypeChoices,
+)
+from core.models import Commit, Repository
+from upload.helpers import get_global_tokens, get_repo_with_github_actions_oidc_token
+from upload.views.helpers import (
+ get_repository_and_owner_from_string,
+ get_repository_from_string,
+)
+from utils import is_uuid
+
+log = logging.getLogger(__name__)
+
+
+def repo_auth_custom_exception_handler(
+ exc: Exception, context: Dict[str, Any]
+) -> Response:
+ """
+ User arrives here if they have correctly supplied a Token or the Tokenless Headers,
+ but their Token has not matched with any of our Authentication methods. The goal is to
+ give the user something better than "Invalid Token" or "Authentication credentials were not provided."
+ """
+ response = exception_handler(exc, context)
+ # we were having issues with this block, I made it super cautions.
+ # Re-evaluate later whether this is overly cautious.
+ if (
+ response is not None
+ and hasattr(response, "status_code")
+ and response.status_code == 401
+ and hasattr(response, "data")
+ ):
+ try:
+ exc_code = response.data.get("detail").code
+ except (TypeError, AttributeError):
+ return response
+ if exc_code == NotAuthenticated.default_code:
+ response.data["detail"] = (
+ "Failed token authentication, please double-check that your repository token matches in the Codecov UI, "
+ "or review the docs https://docs.codecov.com/docs/adding-the-codecov-token"
+ )
+ return response
+
+
+class LegacyTokenRepositoryAuth(RepositoryAuthInterface):
+ def __init__(self, repository: Repository, auth_data: Dict[str, Any]) -> None:
+ self._auth_data = auth_data
+ self._repository = repository
+
+ def get_scopes(self) -> List[TokenTypeChoices]:
+ return [TokenTypeChoices.UPLOAD]
+
+ def get_repositories(self) -> List[Repository]:
+ return [self._repository]
+
+ def allows_repo(self, repository: Repository) -> bool:
+ return repository in self.get_repositories()
+
+
+class OIDCTokenRepositoryAuth(LegacyTokenRepositoryAuth):
+ pass
+
+
+class TableTokenRepositoryAuth(RepositoryAuthInterface):
+ def __init__(self, repository: Repository, token: RepositoryToken) -> None:
+ self._token = token
+ self._repository = repository
+
+ def get_scopes(self) -> List[str]:
+ return [self._token.token_type]
+
+ def get_repositories(self) -> List[Repository]:
+ return [self._repository]
+
+ def allows_repo(self, repository: Repository) -> bool:
+ return repository in self.get_repositories()
+
+
+class OrgLevelTokenRepositoryAuth(RepositoryAuthInterface):
+ def __init__(self, token: OrganizationLevelToken) -> None:
+ self._token = token
+ self._org = token.owner
+
+ def get_scopes(self) -> List[str]:
+ return [self._token.token_type]
+
+ def allows_repo(self, repository: Repository) -> bool:
+ return repository.author.ownerid == self._org.ownerid
+
+ def get_repositories_queryset(self) -> QuerySet:
+ """Returns the QuerySet that generates get_repositories list.
+ Because QuerySets are lazy you can add further filters on top of it improving performance.
+ """
+ return Repository.objects.filter(author=self._org)
+
+ def get_repositories(self) -> List[Repository]:
+ # This might be an expensive function depending on the owner in question (thousands of repos)
+ # Consider using get_repositories_queryset if possible and adding more filters to it
+ return list(Repository.objects.filter(author=self._org).all())
+
+
+class TokenlessAuth(RepositoryAuthInterface):
+ def __init__(self, repository: Repository) -> None:
+ self._repository = repository
+
+ def get_scopes(self) -> List[TokenTypeChoices]:
+ return [TokenTypeChoices.UPLOAD]
+
+ def allows_repo(self, repository: Repository) -> bool:
+ return repository in self.get_repositories()
+
+ def get_repositories(self) -> List[Repository]:
+ return [self._repository]
+
+
+class RepositoryLegacyQueryTokenAuthentication(authentication.BaseAuthentication):
+ def authenticate(
+ self, request: HttpRequest
+ ) -> Optional[Tuple[RepositoryAsUser, LegacyTokenRepositoryAuth]]:
+ token = request.GET.get("token")
+ if not token:
+ return None
+ try:
+ token = UUID(token)
+ except ValueError:
+ return None
+ try:
+ repository = Repository.objects.get(upload_token=token)
+ except Repository.DoesNotExist:
+ return None
+ return (
+ RepositoryAsUser(repository),
+ LegacyTokenRepositoryAuth(repository, {"token": token}),
+ )
+
+
+class RepositoryLegacyTokenAuthentication(authentication.TokenAuthentication):
+ def authenticate_credentials(
+ self, token: str
+ ) -> Optional[Tuple[RepositoryAsUser, LegacyTokenRepositoryAuth]]:
+ try:
+ token_uuid = UUID(token)
+ repository = Repository.objects.get(upload_token=token_uuid)
+ except (ValueError, TypeError, Repository.DoesNotExist):
+ return None # continue to next auth class
+ return (
+ RepositoryAsUser(repository),
+ LegacyTokenRepositoryAuth(repository, {"token": token_uuid}),
+ )
+
+
+class RepositoryTokenAuthentication(authentication.TokenAuthentication):
+ keyword = "Repotoken"
+
+ def authenticate_credentials(
+ self, key: str
+ ) -> Optional[Tuple[RepositoryAsUser, TableTokenRepositoryAuth]]:
+ try:
+ token = RepositoryToken.objects.select_related("repository").get(key=key)
+ except RepositoryToken.DoesNotExist:
+ raise exceptions.AuthenticationFailed("Invalid token.")
+
+ if not token.repository.active:
+ raise exceptions.AuthenticationFailed("User inactive or deleted.")
+ if token.valid_until is not None and token.valid_until <= timezone.now():
+ raise exceptions.AuthenticationFailed("Invalid token.")
+ return (
+ RepositoryAsUser(token.repository),
+ TableTokenRepositoryAuth(token.repository, token),
+ )
+
+
+class GlobalTokenAuthentication(authentication.TokenAuthentication):
+ def authenticate(
+ self, request: HttpRequest
+ ) -> Optional[Tuple[RepositoryAsUser, LegacyTokenRepositoryAuth]]:
+ global_tokens = get_global_tokens()
+ token = self.get_token(request)
+ using_global_token = token in global_tokens
+ if not using_global_token:
+ return None # continue to next auth class
+
+ service = global_tokens.get(token, "")
+ upload_info = get_upload_info_from_request_path(request)
+ if upload_info is None:
+ return None # continue to next auth class
+ # It's important NOT to use the service returned in upload_info
+ # To avoid someone uploading with GlobalUploadToken to a different service
+ # Than what it configured
+ repository = get_repository_from_string(
+ Service(service), upload_info.encoded_slug
+ )
+ if repository is None:
+ raise exceptions.AuthenticationFailed(
+ "Could not find a repository, try using repo upload token"
+ )
+ return (
+ RepositoryAsUser(repository),
+ LegacyTokenRepositoryAuth(repository, {"token": token}),
+ )
+
+ def get_token(self, request: HttpRequest) -> str | None:
+ auth_header = request.headers.get("Authorization")
+ if not auth_header:
+ return None
+ if " " in auth_header:
+ _, token = auth_header.split(" ", 1)
+ return token
+ return auth_header
+
+
+class OrgLevelTokenAuthentication(authentication.TokenAuthentication):
+ def authenticate_credentials(
+ self, key: str
+ ) -> Optional[Tuple[Owner, OrgLevelTokenRepositoryAuth]]:
+ if is_uuid(key): # else, continue to next auth class
+ # Actual verification for org level tokens
+ token = OrganizationLevelToken.objects.filter(token=key).first()
+
+ if token is None:
+ return None
+ if token.valid_until and token.valid_until <= timezone.now():
+ raise exceptions.AuthenticationFailed("Token is expired.")
+
+ return (
+ token.owner,
+ OrgLevelTokenRepositoryAuth(token),
+ )
+
+
+class GitHubOIDCTokenAuthentication(authentication.TokenAuthentication):
+ def authenticate_credentials(
+ self, token: str
+ ) -> Optional[Tuple[RepositoryAsUser, OIDCTokenRepositoryAuth]]:
+ if not token or is_uuid(token):
+ return None # continue to next auth class
+
+ try:
+ repository = get_repo_with_github_actions_oidc_token(token)
+ except (ObjectDoesNotExist, PyJWTError, ValidationError):
+ return None # continue to next auth class
+
+ log.info(
+ "GitHubOIDCTokenAuthentication Success",
+ extra=dict(repository=str(repository)), # Repo
+ )
+
+ return (
+ RepositoryAsUser(repository),
+ OIDCTokenRepositoryAuth(repository, {"token": token}),
+ )
+
+
+class TokenlessAuthentication(authentication.TokenAuthentication):
+ # TODO: replace this with the message from repo_auth_custom_exception_handler
+ auth_failed_message = "Not valid tokenless upload"
+
+ def _get_info_from_request_path(
+ self, request: HttpRequest
+ ) -> tuple[Repository, str | None]:
+ upload_info = get_upload_info_from_request_path(request)
+
+ if upload_info is None:
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+ service, encoded_slug, commitid = upload_info
+ # Validate provider
+ try:
+ service_enum = Service(service)
+ except ValueError:
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ # Validate that next group exists and decode slug
+ repo = get_repository_from_string(service_enum, encoded_slug)
+ if repo is None:
+ # Purposefully using the generic message so that we don't tell that
+ # we don't have a certain repo
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ return repo, commitid
+
+ def get_branch(
+ self,
+ request: HttpRequest,
+ repoid: Optional[int] = None,
+ commitid: Optional[str] = None,
+ ) -> Optional[str]:
+ if repoid and commitid:
+ commit = Commit.objects.filter(
+ repository_id=repoid, commitid=commitid
+ ).first()
+ if not commit:
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+ return commit.branch
+ else:
+ try:
+ body = json.loads(str(request.body, "utf8"))
+ except json.JSONDecodeError:
+ return None
+ else:
+ return body.get("branch")
+
+ def authenticate(
+ self, request: HttpRequest
+ ) -> Tuple[RepositoryAsUser, TokenlessAuth]:
+ repository, commitid = self._get_info_from_request_path(request)
+
+ if repository is None or repository.private:
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ branch = self.get_branch(request, repository.repoid, commitid)
+
+ if (branch and ":" in branch) or request.method == "GET":
+ return (
+ RepositoryAsUser(repository),
+ TokenlessAuth(repository),
+ )
+ else:
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+
+class TestAnalyticsTokenlessAuthentication(TokenlessAuthentication):
+ def _get_info_from_request_path(
+ self, request: HttpRequest
+ ) -> tuple[Repository, str | None]:
+ try:
+ body = json.loads(str(request.body, "utf8"))
+
+ # Validate provider
+ service_enum = Service(body.get("service"))
+
+ # Validate that next group exists and decode slug
+ repo = get_repository_from_string(service_enum, body.get("slug"))
+ if repo is None:
+ # Purposefully using the generic message so that we don't tell that
+ # we don't have a certain repo
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ return repo, body.get("commit")
+ except json.JSONDecodeError:
+ # Validate request body format
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+ except ValueError:
+ # Validate provider
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ def get_branch(
+ self,
+ request: HttpRequest,
+ repoid: Optional[int] = None,
+ commitid: Optional[str] = None,
+ ) -> str:
+ body = json.loads(str(request.body, "utf8"))
+
+ # If commit is not created yet (ie first upload for this commit), we just validate branch format.
+ # However, if a commit exists already (ie not the first upload for this commit), we must additionally
+ # validate the saved commit branch matches what is requested in this upload call.
+ commit = Commit.objects.filter(repository_id=repoid, commitid=commitid).first()
+ if commit and commit.branch != body.get("branch"):
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ return body.get("branch")
+
+
+class BundleAnalysisTokenlessAuthentication(TokenlessAuthentication):
+ def _get_info_from_request_path(
+ self, request: HttpRequest
+ ) -> tuple[Repository, str | None]:
+ try:
+ body = json.loads(str(request.body, "utf8"))
+
+ # Validate provider
+ service_enum = Service(body.get("git_service"))
+
+ # Validate that next group exists and decode slug
+ repo = get_repository_from_string(service_enum, body.get("slug"))
+ if repo is None:
+ # Purposefully using the generic message so that we don't tell that
+ # we don't have a certain repo
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ return repo, body.get("commit")
+ except json.JSONDecodeError:
+ # Validate request body format
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+ except ValueError:
+ # Validate provider
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ def get_branch(
+ self,
+ request: HttpRequest,
+ repoid: Optional[int] = None,
+ commitid: Optional[str] = None,
+ ) -> str:
+ body = json.loads(str(request.body, "utf8"))
+
+ # If commit is not created yet (ie first upload for this commit), we just validate branch format.
+ # However, if a commit exists already (ie not the first upload for this commit), we must additionally
+ # validate the saved commit branch matches what is requested in this upload call.
+ commit = Commit.objects.filter(repository_id=repoid, commitid=commitid).first()
+ if commit and commit.branch != body.get("branch"):
+ raise exceptions.AuthenticationFailed(self.auth_failed_message)
+
+ return body.get("branch")
+
+
+class UploadTokenRequiredAuthenticationCheck(authentication.TokenAuthentication):
+ """
+ If repo is public, OwnerOrg can set upload_token_required_for_public_repos=False
+ to allow uploads with no token. If this is the case, catch it first and exclude from
+ other Auth checks.
+ """
+
+ def _get_info_from_request_path(
+ self, request: HttpRequest
+ ) -> tuple[Repository | None, Owner | None]:
+ upload_info = get_upload_info_from_request_path(request)
+
+ if upload_info is None:
+ return None, None # continue to next auth class
+ service, encoded_slug, _ = upload_info
+ # Validate provider
+ try:
+ service_enum = Service(service)
+ except ValueError:
+ return None, None # continue to next auth class
+
+ repository, owner = get_repository_and_owner_from_string(
+ service_enum, encoded_slug
+ )
+
+ return repository, owner
+
+ def get_repository_and_owner(
+ self, request: HttpRequest
+ ) -> tuple[Repository | None, Owner | None]:
+ return self._get_info_from_request_path(request)
+
+ def authenticate(
+ self, request: HttpRequest
+ ) -> tuple[RepositoryAsUser, TokenlessAuth] | None:
+ repository, owner = self.get_repository_and_owner(request)
+
+ if (
+ repository is None
+ or repository.private
+ or owner is None
+ or owner.upload_token_required_for_public_repos
+ ):
+ return None # continue to next auth class
+
+ return (
+ RepositoryAsUser(repository),
+ TokenlessAuth(repository),
+ )
+
+
+class UploadTokenRequiredGetFromBodySerializer(serializers.Serializer):
+ slug = serializers.CharField(required=True)
+ service = serializers.CharField(required=False) # git_service from TA
+ git_service = serializers.CharField(required=False) # git_service from BA
+
+
+class UploadTokenRequiredGetFromBodyAuthenticationCheck(
+ UploadTokenRequiredAuthenticationCheck
+):
+ """
+ Get Repository and Owner from request body instead of path,
+ then use the same authenticate() as parent class.
+ """
+
+ def _get_git(self, validated_data: Dict[str, str]) -> Optional[str]:
+ """
+ BA sends this in as git_service, TA sends this in as service.
+ Use this function so this Check class can be used by both views.
+ """
+ git_service = validated_data.get("git_service") or validated_data.get("service")
+ return git_service
+
+ def _get_info_from_request_body(
+ self, request: HttpRequest
+ ) -> tuple[Repository | None, Owner | None]:
+ try:
+ body = json.loads(str(request.body, "utf8"))
+
+ serializer = UploadTokenRequiredGetFromBodySerializer(data=body)
+
+ if serializer.is_valid():
+ git_service = self._get_git(validated_data=serializer.validated_data)
+ service_enum = Service(git_service)
+ return get_repository_and_owner_from_string(
+ service=service_enum,
+ repo_identifier=serializer.validated_data["slug"],
+ )
+
+ except (json.JSONDecodeError, ValueError):
+ # exceptions raised by json.loads() and Service()
+ # catch rather than raise to continue to next auth class
+ pass
+
+ return None, None # continue to next auth class
+
+ def get_repository_and_owner(
+ self, request: HttpRequest
+ ) -> tuple[Repository | None, Owner | None]:
+ return self._get_info_from_request_body(request)
diff --git a/apps/codecov-api/codecov_auth/authentication/types.py b/apps/codecov-api/codecov_auth/authentication/types.py
new file mode 100644
index 0000000000..bf7889eea7
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/authentication/types.py
@@ -0,0 +1,95 @@
+from typing import List
+
+from django.contrib.auth.models import Group, Permission
+from django.db.models.manager import EmptyManager
+
+from core.models import Repository
+
+
+class RepositoryAsUser(object):
+ def __init__(self, repository):
+ self._repository = repository
+
+ def is_authenticated(self):
+ return True
+
+
+class RepositoryAuthInterface(object):
+ def get_scopes():
+ raise NotImplementedError()
+
+ def get_repositories() -> List[Repository]:
+ raise NotImplementedError()
+
+ def allows_repo(self, repository: Repository) -> bool:
+ raise NotImplementedError()
+
+
+class DjangoUser(object):
+ id = None
+ pk = None
+ is_staff = False
+ is_superuser = False
+ is_active = False
+ _groups = EmptyManager(Group)
+ _user_permissions = EmptyManager(Permission)
+
+ @property
+ def is_anonymous(self):
+ return False
+
+ @property
+ def is_authenticated(self):
+ return False
+
+ @property
+ def groups(self):
+ return False
+
+ @property
+ def user_permissions(self):
+ return False
+
+ def get_user_permissions(self, obj=None):
+ return False
+
+ def get_group_permissions(self, obj=None):
+ return False
+
+ def get_all_permissions(self, obj=None):
+ return False
+
+ def has_perm(self, perm, obj=None):
+ return False
+
+ def has_perms(self, perm_list, obj=None):
+ return False
+
+
+class SuperUser(DjangoUser):
+ is_super_user = True
+
+ pass
+
+
+class InternalUser(DjangoUser):
+ is_internal_user = True
+
+ pass
+
+
+class DjangoToken(object):
+ def __init__(self, token=None):
+ self.token = token
+
+
+class SuperToken(DjangoToken):
+ is_super_token = True
+
+ pass
+
+
+class InternalToken(DjangoToken):
+ is_internal_token = True
+
+ pass
diff --git a/apps/codecov-api/codecov_auth/commands/__init__.py b/apps/codecov-api/codecov_auth/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/commands/owner/__init__.py b/apps/codecov-api/codecov_auth/commands/owner/__init__.py
new file mode 100644
index 0000000000..826743c705
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/__init__.py
@@ -0,0 +1,3 @@
+from .owner import OwnerCommands
+
+__all__ = ["OwnerCommands"]
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/__init__.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/cancel_trial.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/cancel_trial.py
new file mode 100644
index 0000000000..9834dc418d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/cancel_trial.py
@@ -0,0 +1,29 @@
+from asgiref.sync import sync_to_async
+from shared.plan.service import PlanService
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+
+
+class CancelTrialInteractor(BaseInteractor):
+ def validate(self, owner: Owner | None):
+ if not owner:
+ raise ValidationError("Cannot find owner record in the database")
+ if not current_user_part_of_org(self.current_owner, owner):
+ raise Unauthorized()
+
+ def _cancel_trial(self, owner: Owner):
+ plan_service = PlanService(current_org=owner)
+ plan_service.cancel_trial()
+ return
+
+ @sync_to_async
+ def execute(self, org_username: str) -> None:
+ owner = Owner.objects.filter(
+ username=org_username, service=self.service
+ ).first()
+ self.validate(owner=owner)
+ self._cancel_trial(owner=owner)
+ return
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/create_api_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_api_token.py
new file mode 100644
index 0000000000..e1692d36e0
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_api_token.py
@@ -0,0 +1,22 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import Session
+
+
+class CreateApiTokenInteractor(BaseInteractor):
+ def validate(self, name):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if len(name) == 0:
+ raise ValidationError("name cant be empty")
+
+ def create_token(self, name):
+ type = Session.SessionType.API
+ return Session.objects.create(name=name, owner=self.current_owner, type=type)
+
+ @sync_to_async
+ def execute(self, name):
+ self.validate(name)
+ return self.create_token(name)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py
new file mode 100644
index 0000000000..f7624c8cfa
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py
@@ -0,0 +1,42 @@
+import logging
+
+import stripe
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+from services.billing import BillingService
+
+log = logging.getLogger(__name__)
+
+
+class CreateStripeSetupIntentInteractor(BaseInteractor):
+ def validate(self, owner_obj: Owner) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if not owner_obj:
+ raise ValidationError("Owner not found")
+ if not current_user_part_of_org(self.current_owner, owner_obj):
+ raise Unauthorized()
+
+ def create_setup_intent(self, owner_obj: Owner) -> stripe.SetupIntent:
+ try:
+ billing = BillingService(requesting_user=self.current_owner)
+ return billing.create_setup_intent(owner_obj)
+ except Exception as e:
+ log.error(
+ "Error getting setup intent",
+ extra={
+ "ownerid": owner_obj.ownerid,
+ "error": str(e),
+ },
+ )
+ raise ValidationError("Unable to create setup intent")
+
+ @sync_to_async
+ def execute(self, owner: str) -> stripe.SetupIntent:
+ owner_obj = Owner.objects.filter(username=owner, service=self.service).first()
+ self.validate(owner_obj)
+ return self.create_setup_intent(owner_obj)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/create_user_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_user_token.py
new file mode 100644
index 0000000000..8d60818785
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/create_user_token.py
@@ -0,0 +1,30 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import UserToken
+
+
+class CreateUserTokenInteractor(BaseInteractor):
+ def validate(self, name: str, token_type: str):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if len(name) == 0:
+ raise ValidationError("name cant be empty")
+ if token_type not in UserToken.TokenType.values:
+ raise ValidationError(f"invalid token type: {token_type}")
+
+ def create_token(self, name: str, token_type: str) -> UserToken:
+ return UserToken.objects.create(
+ name=name,
+ owner=self.current_owner,
+ token_type=token_type,
+ )
+
+ @sync_to_async
+ def execute(self, name: str, token_type: str = None) -> UserToken:
+ if token_type is None:
+ token_type = UserToken.TokenType.API.value
+
+ self.validate(name, token_type)
+ return self.create_token(name, token_type)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/delete_session.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/delete_session.py
new file mode 100644
index 0000000000..dd1aae6b05
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/delete_session.py
@@ -0,0 +1,30 @@
+from asgiref.sync import sync_to_async
+from django.contrib.sessions.models import Session as DjangoSession
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated
+from codecov_auth.models import Session
+
+
+class DeleteSessionInteractor(BaseInteractor):
+ def validate(self) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ @sync_to_async
+ def execute(self, sessionid: int) -> None:
+ self.validate()
+
+ try:
+ session_to_delete = Session.objects.get(sessionid=sessionid)
+ django_session_to_delete = DjangoSession.objects.get(
+ session_key=session_to_delete.login_session_id
+ )
+ user_id_to_delete = int(
+ django_session_to_delete.get_decoded().get("_auth_user_id", "0")
+ )
+
+ if user_id_to_delete == self.current_user.id:
+ django_session_to_delete.delete()
+ except (Session.DoesNotExist, DjangoSession.DoesNotExist):
+ pass
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/fetch_owner.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/fetch_owner.py
new file mode 100644
index 0000000000..bb32216fd4
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/fetch_owner.py
@@ -0,0 +1,10 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov_auth.models import Owner
+
+
+class FetchOwnerInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, username):
+ return Owner.objects.filter(username=username, service=self.service).first()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py
new file mode 100644
index 0000000000..46906de909
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py
@@ -0,0 +1,54 @@
+from asgiref.sync import async_to_sync, sync_to_async
+from django.conf import settings
+
+import services.self_hosted as self_hosted
+from codecov.commands.base import BaseInteractor
+from services.decorators import torngit_safe
+from services.repo_providers import get_generic_adapter_params, get_provider
+
+
+@torngit_safe
+@sync_to_async
+def _is_admin_on_provider(owner, current_user):
+ torngit_provider_adapter = get_provider(
+ owner.service,
+ {
+ **get_generic_adapter_params(current_user, owner.service),
+ **{
+ "owner": {
+ "username": owner.username,
+ "service_id": owner.service_id,
+ }
+ },
+ },
+ )
+
+ isAdmin = async_to_sync(torngit_provider_adapter.get_is_admin)(
+ user={"username": current_user.username, "service_id": current_user.service_id}
+ )
+ return isAdmin
+
+
+class GetIsCurrentUserAnAdminInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, owner, current_owner):
+ if settings.IS_ENTERPRISE:
+ return self_hosted.is_admin_owner(current_owner)
+ else:
+ if not current_owner:
+ return False
+ admins = owner.admins
+ if not hasattr(current_owner, "ownerid"):
+ return False
+ if owner.ownerid == current_owner.ownerid:
+ return True
+ else:
+ try:
+ isAdmin = async_to_sync(_is_admin_on_provider)(owner, current_owner)
+ if isAdmin:
+ # save admin provider in admins list
+ owner.add_admin(current_owner)
+ return isAdmin or (current_owner.ownerid in admins)
+ except Exception as error:
+ print("Error Calling Admin Provider " + repr(error)) # noqa: T201
+ return False
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/get_org_upload_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_org_upload_token.py
new file mode 100644
index 0000000000..7a07c7d141
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_org_upload_token.py
@@ -0,0 +1,23 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import OrganizationLevelToken
+
+
+class GetOrgUploadToken(BaseInteractor):
+ def validate(self, owner):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ if not current_user_part_of_org(self.current_owner, owner):
+ raise Unauthorized()
+
+ @sync_to_async
+ def execute(self, owner):
+ self.validate(owner)
+
+ org_token = OrganizationLevelToken.objects.filter(owner=owner).first()
+ if org_token:
+ return org_token.token
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/get_uploads_number_per_user.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_uploads_number_per_user.py
new file mode 100644
index 0000000000..3c5e42bc3f
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/get_uploads_number_per_user.py
@@ -0,0 +1,20 @@
+from typing import Optional
+
+from asgiref.sync import sync_to_async
+from shared.helpers.redis import get_redis_connection
+from shared.plan.service import PlanService
+from shared.upload.utils import query_monthly_coverage_measurements
+
+from codecov.commands.base import BaseInteractor
+from codecov_auth.models import Owner
+
+redis = get_redis_connection()
+
+
+class GetUploadsNumberPerUserInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, owner: Owner) -> Optional[int]:
+ plan_service = PlanService(current_org=owner)
+ monthly_limit = plan_service.monthly_uploads_limit
+ if monthly_limit is not None:
+ return query_monthly_coverage_measurements(plan_service=plan_service)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/is_syncing.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/is_syncing.py
new file mode 100644
index 0000000000..94971cb526
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/is_syncing.py
@@ -0,0 +1,10 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from services.refresh import RefreshService
+
+
+class IsSyncingInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self):
+ return RefreshService().is_refreshing(self.current_owner.ownerid)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/onboard_user.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/onboard_user.py
new file mode 100644
index 0000000000..1d72cbacfe
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/onboard_user.py
@@ -0,0 +1,49 @@
+from asgiref.sync import sync_to_async
+from django import forms
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.models import OwnerProfile
+
+
+class OnboardForm(forms.Form):
+ email = forms.EmailField(required=False)
+ business_email = forms.EmailField(required=False)
+ other_goal = forms.CharField(required=False)
+ type_projects = forms.MultipleChoiceField(choices=OwnerProfile.ProjectType.choices)
+ goals = forms.MultipleChoiceField(choices=OwnerProfile.Goal.choices)
+
+
+class OnboardUserInteractor(BaseInteractor):
+ def validate(self, params):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if not self.current_owner or self.current_owner.onboarding_completed:
+ raise Unauthorized()
+ form = OnboardForm(params)
+ if not form.is_valid():
+ raise ValidationError(form.errors.as_json())
+
+ def create_profile(self, params):
+ self.current_owner.onboarding_completed = True
+ self.current_owner.business_email = params.get("business_email")
+ self.current_owner.email = params.get("email")
+ self.current_owner.save()
+
+ OwnerProfile.objects.update_or_create(
+ owner=self.current_owner,
+ defaults={
+ "type_projects": params.get("type_projects", []),
+ "goals": params.get("goals", []),
+ "other_goal": params.get("other_goal"),
+ },
+ )
+
+ # refresh in case the profile was already preloaded
+ self.current_owner.profile.refresh_from_db()
+
+ @sync_to_async
+ def execute(self, params):
+ self.validate(params)
+ self.create_profile(params)
+ return self.current_owner
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/regenerate_org_upload_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/regenerate_org_upload_token.py
new file mode 100644
index 0000000000..5eb94c566d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/regenerate_org_upload_token.py
@@ -0,0 +1,33 @@
+import uuid
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import OrganizationLevelToken, Owner
+
+
+class RegenerateOrgUploadTokenInteractor(BaseInteractor):
+ def validate(self, owner_obj):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if not owner_obj:
+ raise ValidationError("Owner not found")
+ if not current_user_part_of_org(self.current_owner, owner_obj):
+ raise Unauthorized()
+
+ @sync_to_async
+ def execute(self, owner):
+ owner_obj = Owner.objects.filter(username=owner, service=self.service).first()
+
+ self.validate(owner_obj)
+
+ upload_token, created = OrganizationLevelToken.objects.get_or_create(
+ owner=owner_obj
+ )
+ if not created:
+ upload_token.token = uuid.uuid4()
+ upload_token.save()
+
+ return upload_token.token
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/revoke_user_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/revoke_user_token.py
new file mode 100644
index 0000000000..904f338a65
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/revoke_user_token.py
@@ -0,0 +1,16 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated
+from codecov_auth.models import UserToken
+
+
+class RevokeUserTokenInteractor(BaseInteractor):
+ def validate(self):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ @sync_to_async
+ def execute(self, tokenid):
+ self.validate()
+ UserToken.objects.filter(external_id=tokenid, owner=self.current_owner).delete()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/save_okta_config.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/save_okta_config.py
new file mode 100644
index 0000000000..46c9c84904
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/save_okta_config.py
@@ -0,0 +1,92 @@
+from dataclasses import dataclass
+
+from asgiref.sync import sync_to_async
+from shared.django_apps.codecov_auth.models import AccountsUsers, User
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.models import Account, OktaSettings, Owner
+
+
+@dataclass
+class SaveOktaConfigInput:
+ enabled: bool | None
+ enforced: bool | None
+ client_id: str | None = None
+ client_secret: str | None = None
+ url: str | None = None
+ org_username: str | None = None
+
+
+class SaveOktaConfigInteractor(BaseInteractor):
+ def validate(self, owner: Owner) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if not owner:
+ raise ValidationError("Cannot find owner record in the database")
+ if not owner.is_admin(self.current_owner):
+ raise Unauthorized()
+
+ @sync_to_async
+ def execute(self, input: dict) -> None:
+ typed_input = SaveOktaConfigInput(
+ client_id=input.get("client_id"),
+ client_secret=input.get("client_secret"),
+ url=input.get("url"),
+ enabled=input.get("enabled"),
+ enforced=input.get("enforced"),
+ org_username=input.get("org_username"),
+ )
+
+ owner = Owner.objects.filter(
+ username=typed_input.org_username, service=self.service
+ ).first()
+ self.validate(owner=owner)
+
+ account = owner.account
+ if not account:
+ account = Account.objects.create(
+ name=owner.username,
+ plan=owner.plan,
+ plan_seat_count=owner.plan_user_count,
+ free_seat_count=owner.free,
+ plan_auto_activate=owner.plan_auto_activate,
+ )
+ owner.account = account
+ owner.save()
+
+ # Update the activated users to be added to the account
+ plan_activated_user_owners: list[int] = owner.plan_activated_users
+ activated_connections: list[AccountsUsers] = []
+ for activated_user_owner in plan_activated_user_owners:
+ user_owner: Owner = Owner.objects.select_related("user").get(
+ pk=activated_user_owner
+ )
+ user = user_owner.user
+ if user is None:
+ user = User(name=user_owner.name, email=user_owner.email)
+ user_owner.user = user
+ user.save()
+ user_owner.save()
+
+ activated_connections.append(AccountsUsers(account=account, user=user))
+
+ # Batch the user creation in batches of 50 users
+ if len(activated_connections) > 50:
+ AccountsUsers.objects.bulk_create(activated_connections)
+ activated_connections = []
+
+ if activated_connections:
+ AccountsUsers.objects.bulk_create(activated_connections)
+
+ okta_config, created = OktaSettings.objects.get_or_create(account=account)
+
+ for field in ["client_id", "client_secret", "url", "enabled", "enforced"]:
+ value = getattr(typed_input, field)
+ if value is not None:
+ # Strip the URL of any trailing spaces and slashes before saving it
+ if field == "url":
+ value = value.strip("/ ")
+ setattr(okta_config, field, value)
+
+ okta_config.save()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/save_terms_agreement.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/save_terms_agreement.py
new file mode 100644
index 0000000000..acef9c5324
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/save_terms_agreement.py
@@ -0,0 +1,92 @@
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from asgiref.sync import sync_to_async
+from django.utils import timezone
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from services.analytics import AnalyticsService
+
+
+@dataclass
+class TermsAgreementInput:
+ business_email: Optional[str] = None
+ name: Optional[str] = None
+ terms_agreement: bool = False
+ marketing_consent: bool = False
+ customer_intent: Optional[str] = None
+
+
+class SaveTermsAgreementInteractor(BaseInteractor):
+ requires_service = False
+
+ def validate_deprecated(self, input: TermsAgreementInput) -> None:
+ valid_customer_intents = ["Business", "BUSINESS", "Personal", "PERSONAL"]
+ if (
+ input.customer_intent
+ and input.customer_intent not in valid_customer_intents
+ ):
+ raise ValidationError("Invalid customer intent provided")
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ def validate(self, input: TermsAgreementInput) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ def update_terms_agreement_deprecated(self, input: TermsAgreementInput) -> None:
+ self.current_user.terms_agreement = input.terms_agreement
+ self.current_user.terms_agreement_at = timezone.now()
+ self.current_user.customer_intent = input.customer_intent
+ self.current_user.email_opt_in = input.marketing_consent
+ self.current_user.save()
+
+ if input.business_email and input.business_email != "":
+ self.current_user.email = input.business_email
+ self.current_user.save()
+
+ if input.marketing_consent:
+ self.send_data_to_marketo()
+
+ def update_terms_agreement(self, input: TermsAgreementInput) -> None:
+ self.current_user.terms_agreement = input.terms_agreement
+ self.current_user.terms_agreement_at = timezone.now()
+ self.current_user.name = input.name
+ self.current_user.email_opt_in = input.marketing_consent
+ self.current_user.save()
+
+ if input.business_email and input.business_email != "":
+ self.current_user.email = input.business_email
+ self.current_user.save()
+
+ if input.marketing_consent:
+ self.send_data_to_marketo()
+
+ def send_data_to_marketo(self) -> None:
+ event_data = {
+ "email": self.current_user.email,
+ }
+ AnalyticsService().opt_in_email(self.current_user.id, event_data)
+
+ @sync_to_async
+ def execute(self, input: Any) -> None:
+ if input.get("name"):
+ typed_input = TermsAgreementInput(
+ business_email=input.get("business_email"),
+ terms_agreement=input.get("terms_agreement"),
+ marketing_consent=input.get("marketing_consent"),
+ name=input.get("name"),
+ )
+ self.validate(typed_input)
+ self.update_terms_agreement(typed_input)
+ # this handles the deprecated inputs
+ else:
+ typed_input = TermsAgreementInput(
+ business_email=input.get("business_email"),
+ terms_agreement=input.get("terms_agreement"),
+ marketing_consent=input.get("marketing_consent"),
+ customer_intent=input.get("customer_intent"),
+ )
+ self.validate_deprecated(typed_input)
+ self.update_terms_agreement_deprecated(typed_input)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/set_upload_token_required.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/set_upload_token_required.py
new file mode 100644
index 0000000000..10ba28164b
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/set_upload_token_required.py
@@ -0,0 +1,48 @@
+from dataclasses import dataclass
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+
+
+@dataclass
+class SetUploadTokenRequiredInput:
+ upload_token_required: bool
+ org_username: str
+
+
+class SetUploadTokenRequiredInteractor(BaseInteractor):
+ def validate(self, owner_obj, upload_token_required):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ if not owner_obj:
+ raise ValidationError("Owner not found")
+ if not current_user_part_of_org(self.current_owner, owner_obj):
+ raise Unauthorized()
+ if not owner_obj.is_admin(self.current_owner):
+ raise Unauthorized("Admin authorization required")
+ if upload_token_required is None:
+ raise ValidationError("upload_token_required must be either True or False")
+
+ @sync_to_async
+ def execute(self, input: dict[str, bool]):
+ typed_input = SetUploadTokenRequiredInput(
+ upload_token_required=input.get("upload_token_required"),
+ org_username=input.get("org_username"),
+ )
+
+ owner_obj = Owner.objects.filter(
+ username=typed_input.org_username, service=self.service
+ ).first()
+
+ self.validate(owner_obj, typed_input.upload_token_required)
+
+ owner_obj.upload_token_required_for_public_repos = bool(
+ typed_input.upload_token_required
+ )
+ owner_obj.save()
+
+ return
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/set_yaml_on_owner.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/set_yaml_on_owner.py
new file mode 100644
index 0000000000..e177a564b2
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/set_yaml_on_owner.py
@@ -0,0 +1,98 @@
+import html
+from typing import Optional
+
+import yaml
+from asgiref.sync import sync_to_async
+from shared.django_apps.core.models import Repository
+from shared.django_apps.utils.model_utils import get_ownerid_if_member
+from shared.validation.exceptions import InvalidYamlException
+from shared.yaml.validation import validate_yaml
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+from codecov_auth.constants import OWNER_YAML_TO_STRING_KEY
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+
+
+class SetYamlOnOwnerInteractor(BaseInteractor):
+ def validate(self) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ def authorize(self) -> None:
+ if not current_user_part_of_org(self.current_owner, self.owner):
+ raise Unauthorized()
+
+ def get_owner(self, username: str) -> Owner:
+ try:
+ return Owner.objects.get(username=username, service=self.service)
+ except Owner.DoesNotExist:
+ raise NotFound()
+
+ def convert_yaml_to_dict(self, yaml_input: str) -> Optional[dict]:
+ yaml_safe = html.escape(yaml_input, quote=False)
+ try:
+ yaml_dict = yaml.safe_load(yaml_safe)
+ except yaml.scanner.ScannerError as e:
+ line = e.problem_mark.line
+ column = e.problem_mark.column
+ message = (
+ f"Syntax error at line {line + 1}, column {column + 1}: {e.problem}"
+ )
+ raise ValidationError(message)
+ if not yaml_dict:
+ return None
+ try:
+ return validate_yaml(yaml_dict, show_secrets_for=None)
+ except InvalidYamlException as e:
+ message = f"Error at {str(e.error_location)}: {e.error_message}"
+ raise ValidationError(message)
+
+ def yaml_side_effects(self, old_yaml: dict | None, new_yaml: dict | None) -> None:
+ old_yaml_branch = old_yaml and old_yaml.get("codecov", {}).get("branch")
+ new_yaml_branch = new_yaml and new_yaml.get("codecov", {}).get("branch")
+
+ # Update all repositories from owner if branch is updated in yaml
+ if new_yaml_branch != old_yaml_branch:
+ repos = Repository.objects.filter(author_id=self.owner.ownerid)
+ repos.update(
+ branch=new_yaml_branch or old_yaml_branch
+ ) # Keeps old_branch if new_branch is None
+
+ old_yaml_bot = old_yaml and old_yaml.get("codecov", {}).get("bot")
+ new_yaml_bot = new_yaml and new_yaml.get("codecov", {}).get("bot")
+
+ # Update owner's bot column if bot is updated in yaml or if bot is not configured.
+ if new_yaml_bot != old_yaml_bot or self.owner.bot is None:
+ new_bot_id = (
+ get_ownerid_if_member(
+ service=self.owner.service,
+ owner_username=new_yaml_bot,
+ owner_id=self.owner.ownerid,
+ )
+ or old_yaml_bot
+ or None
+ )
+ self.owner.bot_id = new_bot_id
+ self.owner.save()
+
+ @sync_to_async
+ def execute(self, username: str, yaml_input: str) -> Owner:
+ self.validate()
+ self.owner = self.get_owner(username)
+ self.authorize()
+ old_yaml = self.owner.yaml
+ self.owner.yaml = self.convert_yaml_to_dict(yaml_input)
+ if self.owner.yaml:
+ self.owner.yaml[OWNER_YAML_TO_STRING_KEY] = yaml_input
+ self.owner.save()
+
+ # side effects
+ self.yaml_side_effects(old_yaml=old_yaml, new_yaml=self.owner.yaml)
+ return self.owner
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/start_trial.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/start_trial.py
new file mode 100644
index 0000000000..5abd36a8a2
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/start_trial.py
@@ -0,0 +1,29 @@
+from asgiref.sync import sync_to_async
+from shared.plan.service import PlanService
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+
+
+class StartTrialInteractor(BaseInteractor):
+ def validate(self, current_org: Owner | None):
+ if not current_org:
+ raise ValidationError("Cannot find owner record in the database")
+ if not current_user_part_of_org(self.current_owner, current_org):
+ raise Unauthorized()
+
+ def _start_trial(self, current_org: Owner) -> None:
+ plan_service = PlanService(current_org=current_org)
+ plan_service.start_trial(current_owner=self.current_owner)
+ return
+
+ @sync_to_async
+ def execute(self, org_username: str) -> None:
+ current_org = Owner.objects.filter(
+ username=org_username, service=self.service
+ ).first()
+ self.validate(current_org=current_org)
+ self._start_trial(current_org=current_org)
+ return
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/store_codecov_metric.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/store_codecov_metric.py
new file mode 100644
index 0000000000..c52cad8596
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/store_codecov_metric.py
@@ -0,0 +1,31 @@
+import json
+
+from asgiref.sync import sync_to_async
+from shared.django_apps.codecov_metrics.service.codecov_metrics import (
+ UserOnboardingMetricsService,
+)
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import ValidationError
+from codecov_auth.models import Owner
+
+
+class StoreCodecovMetricInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, org_username: str, event: str, json_string: str) -> None:
+ current_org = Owner.objects.filter(
+ username=org_username, service=self.service
+ ).first()
+ if not current_org:
+ raise ValidationError("Cannot find owner record in the database")
+
+ try:
+ payload = json.loads(json_string)
+ except json.JSONDecodeError:
+ raise ValidationError("Invalid JSON string")
+
+ UserOnboardingMetricsService.create_user_onboarding_metric(
+ org_id=current_org.pk,
+ event=event,
+ payload=payload,
+ )
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/__init__.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py
new file mode 100644
index 0000000000..fde8a2380d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py
@@ -0,0 +1,109 @@
+from datetime import datetime, timedelta
+
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.django_apps.codecov.commands.exceptions import ValidationError
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, TierName, TrialStatus
+
+from codecov.commands.exceptions import Unauthorized
+from codecov.commands.exceptions import ValidationError as CodecovValidationError
+from codecov_auth.models import Owner
+
+from ..cancel_trial import CancelTrialInteractor
+
+
+class CancelTrialInteractorTest(TestCase):
+ def setUp(self):
+ self.tier = TierFactory(tier_name=DEFAULT_FREE_PLAN)
+ self.plan = PlanFactory(tier=self.tier)
+
+ @async_to_sync
+ def execute(self, current_user, org_username=None):
+ current_user = current_user
+ return CancelTrialInteractor(current_user, "github").execute(
+ org_username=org_username,
+ )
+
+ def test_cancel_trial_raises_exception_when_owner_is_not_in_db(self):
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ plan=self.plan.name,
+ )
+ with pytest.raises(CodecovValidationError):
+ self.execute(current_user=current_user, org_username="some-other-username")
+
+ def test_cancel_trial_raises_exception_when_current_user_not_part_of_org(self):
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ plan=self.plan.name,
+ )
+ OwnerFactory(
+ username="random-user-456",
+ service="github",
+ plan=self.plan.name,
+ )
+ with pytest.raises(Unauthorized):
+ self.execute(current_user=current_user, org_username="random-user-456")
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_cancel_trial_raises_exception_when_owners_trial_status_is_not_started(
+ self,
+ ):
+ trial_start_date = None
+ trial_end_date = None
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ plan=self.plan.name,
+ )
+ with pytest.raises(ValidationError):
+ self.execute(current_user=current_user, org_username=current_user.username)
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_cancel_trial_raises_exception_when_owners_trial_status_is_expired(self):
+ now = datetime.now()
+ trial_start_date = now + timedelta(days=-10)
+ trial_end_date = now + timedelta(days=-4)
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ plan=self.plan.name,
+ )
+ with pytest.raises(ValidationError):
+ self.execute(current_user=current_user, org_username=current_user.username)
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_cancel_trial_starts_trial_for_org_that_has_trial_ongoing(self):
+ now = datetime.now()
+ trial_start_date = now
+ trial_end_date = now + timedelta(days=3)
+ trial_tier = TierFactory(tier_name=TierName.TRIAL.value)
+ trial_plan = PlanFactory(tier=trial_tier, name=PlanName.TRIAL_PLAN_NAME.value)
+ current_user: Owner = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ trial_status=TrialStatus.ONGOING.value,
+ plan=trial_plan.name,
+ )
+ self.execute(current_user=current_user, org_username=current_user.username)
+ current_user.refresh_from_db()
+
+ now = datetime.now()
+ assert current_user.trial_end_date == now
+ assert current_user.trial_status == TrialStatus.EXPIRED.value
+ assert current_user.plan == DEFAULT_FREE_PLAN
+ assert current_user.plan_activated_users is None
+ assert current_user.plan_user_count == 1
+ assert current_user.stripe_subscription_id is None
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_api_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_api_token.py
new file mode 100644
index 0000000000..f8c3487b60
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_api_token.py
@@ -0,0 +1,25 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+from ..create_api_token import CreateApiTokenInteractor
+
+
+class CreateApiTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await CreateApiTokenInteractor(None, "github").execute("name")
+
+ async def test_when_no_name_raise(self):
+ with pytest.raises(ValidationError):
+ await CreateApiTokenInteractor(self.owner, "github").execute("")
+
+ async def test_create_token(self):
+ session = await CreateApiTokenInteractor(self.owner, "github").execute("name")
+ assert session is not None
+ assert session.owner is self.owner
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_user_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_user_token.py
new file mode 100644
index 0000000000..ea606a7d16
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_create_user_token.py
@@ -0,0 +1,35 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import UserToken
+
+from ..create_user_token import CreateUserTokenInteractor
+
+
+class CreateUserTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ async def test_unauthenticated(self):
+ with pytest.raises(Unauthenticated):
+ await CreateUserTokenInteractor(None, "github").execute("name")
+
+ async def test_empty_name(self):
+ with pytest.raises(ValidationError):
+ await CreateUserTokenInteractor(self.owner, "github").execute("")
+
+ async def test_invalid_type(self):
+ with pytest.raises(ValidationError):
+ await CreateUserTokenInteractor(self.owner, "github").execute(
+ "name", "wrong"
+ )
+
+ async def test_create_token(self):
+ user_token = await CreateUserTokenInteractor(self.owner, "github").execute(
+ "name"
+ )
+ assert user_token is not None
+ assert user_token.owner is self.owner
+ assert user_token.token_type == UserToken.TokenType.API
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_delete_session.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_delete_session.py
new file mode 100644
index 0000000000..d5c500045f
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_delete_session.py
@@ -0,0 +1,42 @@
+import pytest
+from asgiref.sync import sync_to_async
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, SessionFactory
+
+from codecov.commands.exceptions import Unauthenticated
+from codecov_auth.models import DjangoSession, Session
+from codecov_auth.tests.factories import DjangoSessionFactory
+
+from ..delete_session import DeleteSessionInteractor
+
+
+@sync_to_async
+def get_session(id):
+ return Session.objects.get(sessionid=id)
+
+
+class DeleteSessionInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.django_session = DjangoSessionFactory()
+ self.session = SessionFactory(
+ owner=self.owner, login_session=self.django_session
+ )
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await DeleteSessionInteractor(None, "github").execute(12)
+
+ async def test_delete_session(self):
+ await DeleteSessionInteractor(self.owner, "github").execute(
+ self.session.sessionid
+ )
+
+ @sync_to_async
+ def assert_sessions():
+ return (
+ len(DjangoSession.objects.all()) == 0
+ and len(Session.objects.all()) == 0
+ )
+
+ assert assert_sessions
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_is_current_user_an_admin.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_is_current_user_an_admin.py
new file mode 100644
index 0000000000..be65f3cb33
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_is_current_user_an_admin.py
@@ -0,0 +1,152 @@
+from unittest.mock import patch
+
+from asgiref.sync import async_to_sync
+from django.test import TestCase, override_settings
+from shared.django_apps.codecov_auth.tests.factories import (
+ GetAdminProviderAdapter,
+ OwnerFactory,
+)
+
+from ..get_is_current_user_an_admin import (
+ GetIsCurrentUserAnAdminInteractor,
+ _is_admin_on_provider,
+)
+
+
+class GetIsCurrentUserAnAdminInteractorTest(TestCase):
+ def setUp(self):
+ self.owner_has_admins = OwnerFactory(ownerid=0, admins=[2])
+ self.owner_has_no_admins = OwnerFactory(ownerid=1, admins=[])
+ self.owner_null_admins = OwnerFactory(ownerid=9, admins=None)
+
+ def test_user_admin_in_personal_org(self):
+ current_user = self.owner_has_admins
+ owner = self.owner_has_admins
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert isAdmin == True
+
+ def test_user_not_admin_in_org(self):
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_admins
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert isAdmin == False
+
+ def test_user_not_a_provider_admin(self):
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_no_admins
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert isAdmin == False
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_admin_on_provider_invokes_torngit_adapter(self, mocked_get_adapter):
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_no_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter()
+ async_to_sync(_is_admin_on_provider)(owner, current_user)
+ assert mocked_get_adapter.return_value.last_call_args == {
+ "username": current_user.username,
+ "service_id": current_user.service_id,
+ }
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_admin_in_org_not_on_provider(self, mocked_get_adapter):
+ current_user = OwnerFactory(ownerid=2)
+ owner = self.owner_has_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter(result=False)
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert isAdmin == True
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_admin_on_provider(self, mocked_get_adapter):
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_no_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter(result=True)
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert current_user.ownerid in owner.admins
+ assert isAdmin == True
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_admin_on_provider_only_once(self, mocked_get_adapter):
+ # Ensure duplicate ownerids won't be saved in admins upon multiple calls
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_no_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter(result=True)
+ async_to_sync(GetIsCurrentUserAnAdminInteractor(owner, current_user).execute)(
+ owner, current_user
+ )
+ async_to_sync(GetIsCurrentUserAnAdminInteractor(owner, current_user).execute)(
+ owner, current_user
+ )
+ assert owner.admins == [3]
+ assert current_user.ownerid in owner.admins
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_admin_on_provider_initially_is_null(self, mocked_get_adapter):
+ # Owner model defaults admins to null, check can handle first update
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_null_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter(result=True)
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert owner.admins == [3]
+ assert current_user.ownerid in owner.admins
+ assert isAdmin == True
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_admin_not_in_org_or_on_provider(self, mocked_get_adapter):
+ current_user = OwnerFactory(ownerid=3)
+ owner = self.owner_has_no_admins
+ mocked_get_adapter.return_value = GetAdminProviderAdapter(result=False)
+ isAdmin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert current_user.ownerid not in owner.admins
+ assert isAdmin == False
+
+ @patch("services.self_hosted.is_admin_owner")
+ @override_settings(IS_ENTERPRISE=True)
+ def test_is_admin_self_hosted(self, is_admin_owner):
+ current_user = OwnerFactory(ownerid=3)
+ owner = OwnerFactory(ownerid=4)
+
+ is_admin_owner.return_value = False
+ is_admin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert is_admin == False
+
+ is_admin_owner.return_value = True
+ is_admin = async_to_sync(
+ GetIsCurrentUserAnAdminInteractor(owner, current_user).execute
+ )(owner, current_user)
+ assert is_admin == True
+
+ def test_is_admin_no_current_owner(self):
+ owner = OwnerFactory(ownerid=5)
+ res = async_to_sync(GetIsCurrentUserAnAdminInteractor(owner, "gitlab").execute)(
+ owner, None
+ )
+ assert res == False
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_org_upload_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_org_upload_token.py
new file mode 100644
index 0000000000..193484648c
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_org_upload_token.py
@@ -0,0 +1,46 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+ OwnerFactory,
+)
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized
+
+from ..get_org_upload_token import GetOrgUploadToken
+
+
+class GetOrgUploadTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.owner_with_no_upload_token = OwnerFactory()
+ self.owner_with_upload_token = OwnerFactory(plan="users-enterprisem")
+ OrganizationLevelTokenFactory(owner=self.owner_with_upload_token)
+
+ async def test_owner_with_no_org_upload_token(self):
+ token = await GetOrgUploadToken(
+ self.owner_with_no_upload_token, "github"
+ ).execute(self.owner_with_no_upload_token)
+ assert token is None
+
+ async def test_owner_with_org_upload_token(self):
+ token = await GetOrgUploadToken(self.owner_with_upload_token, "github").execute(
+ self.owner_with_upload_token
+ )
+ assert token
+ assert len(str(token)) == 36 # default uuid
+
+ async def test_owner_with_org_upload_token_and_anonymous_user(self):
+ with pytest.raises(Unauthenticated):
+ token = await GetOrgUploadToken(None, "github").execute(
+ self.owner_with_upload_token
+ )
+
+ assert token is None
+
+ async def test_owner_with_org_upload_token_and_unauthorized_user(self):
+ with pytest.raises(Unauthorized):
+ token = await GetOrgUploadToken(
+ self.owner_with_upload_token, "github"
+ ).execute(self.owner_with_no_upload_token)
+
+ assert token is None
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py
new file mode 100644
index 0000000000..4812a831d3
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py
@@ -0,0 +1,122 @@
+from datetime import datetime, timedelta
+
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.django_apps.reports.models import ReportType
+from shared.plan.constants import PlanName, TierName, TrialStatus
+from shared.upload.utils import UploaderType, insert_coverage_measurement
+
+from reports.tests.factories import CommitReportFactory, UploadFactory
+
+from ..get_uploads_number_per_user import GetUploadsNumberPerUserInteractor
+
+
+class GetUploadsNumberPerUserInteractorTest(TestCase):
+ def setUp(self):
+ self.tier = TierFactory(tier_name=TierName.BASIC.value)
+ self.plan = PlanFactory(tier=self.tier, monthly_uploads_limit=250)
+ self.user_with_no_uploads = OwnerFactory(plan=self.plan.name)
+ self.user_with_uploads = OwnerFactory(plan=self.plan.name)
+ repo = RepositoryFactory.create(author=self.user_with_uploads, private=True)
+ commit = CommitFactory.create(repository=repo)
+ report = CommitReportFactory.create(
+ commit=commit, report_type=ReportType.COVERAGE.value
+ )
+
+ # Reports all created today/within the last 30 days
+ for i in range(2):
+ # Explicit add insert_coverage_measurement as we'll do this every time that we make an upload
+ upload = UploadFactory.create(report=report)
+ insert_coverage_measurement(
+ owner_id=self.user_with_uploads.ownerid,
+ repo_id=repo.repoid,
+ commit_id=commit.id,
+ upload_id=upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repo.private,
+ report_type=report.report_type,
+ )
+
+ report_within_40_days = UploadFactory.create(report=report)
+ report_within_40_days.created_at += timedelta(days=-40)
+ report_within_40_days.save()
+
+ # Trial Data
+ trial_tier = TierFactory(tier_name=TierName.TRIAL.value)
+ trial_plan = PlanFactory(
+ tier=trial_tier,
+ name=PlanName.TRIAL_PLAN_NAME.value,
+ monthly_uploads_limit=250,
+ )
+ self.trial_owner = OwnerFactory(
+ trial_status=TrialStatus.EXPIRED.value,
+ trial_start_date=datetime.now() + timedelta(days=-10),
+ trial_end_date=datetime.now() + timedelta(days=-2),
+ plan=trial_plan.name,
+ )
+ trial_repo = RepositoryFactory.create(author=self.trial_owner, private=True)
+ trial_commit = CommitFactory.create(repository=trial_repo)
+ trial_report = CommitReportFactory.create(
+ commit=trial_commit, report_type=ReportType.COVERAGE.value
+ )
+
+ report_before_trial = UploadFactory.create(report=trial_report)
+ report_before_trial.created_at += timedelta(days=-12)
+ report_before_trial.save()
+ upload_before_trial = insert_coverage_measurement(
+ owner_id=self.trial_owner.ownerid,
+ repo_id=repo.repoid,
+ commit_id=commit.id,
+ upload_id=report_before_trial.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repo.private,
+ report_type=report.report_type,
+ )
+ upload_before_trial.created_at += timedelta(days=-12)
+ upload_before_trial.save()
+
+ report_during_trial = UploadFactory.create(report=trial_report)
+ report_during_trial.created_at += timedelta(days=-5)
+ report_during_trial.save()
+ upload_during_trial = insert_coverage_measurement(
+ owner_id=self.trial_owner.ownerid,
+ repo_id=repo.repoid,
+ commit_id=commit.id,
+ upload_id=report_during_trial.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repo.private,
+ report_type=report.report_type,
+ )
+ upload_during_trial.created_at += timedelta(days=-5)
+ upload_during_trial.save()
+
+ report_after_trial = UploadFactory.create(report=trial_report)
+ insert_coverage_measurement(
+ owner_id=self.trial_owner.ownerid,
+ repo_id=repo.repoid,
+ commit_id=commit.id,
+ upload_id=report_after_trial.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repo.private,
+ report_type=report.report_type,
+ )
+
+ async def test_with_no_uploads(self):
+ owner = self.user_with_no_uploads
+ uploads = await GetUploadsNumberPerUserInteractor(None, owner).execute(owner)
+ assert uploads == 0
+
+ async def test_with_number_of_uploads(self):
+ owner = self.user_with_uploads
+ uploads = await GetUploadsNumberPerUserInteractor(None, owner).execute(owner)
+ assert uploads == 2
+
+ async def test_number_of_uploads_with_expired_trial(self):
+ owner = self.trial_owner
+ uploads = await GetUploadsNumberPerUserInteractor(None, owner).execute(owner)
+ assert uploads == 2
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_is_syncing.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_is_syncing.py
new file mode 100644
index 0000000000..4c37daac67
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_is_syncing.py
@@ -0,0 +1,20 @@
+from unittest.mock import patch
+
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from ..is_syncing import IsSyncingInteractor
+
+
+class IsSyncingInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ @patch("services.refresh.RefreshService.is_refreshing")
+ @async_to_sync
+ async def test_call_is_refreshing(self, mock_is_refreshing):
+ mock_is_refreshing.return_value = True
+ res = await IsSyncingInteractor(self.owner, "github").execute()
+ assert res is True
+ mock_is_refreshing.assert_called()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_onboard_user.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_onboard_user.py
new file mode 100644
index 0000000000..ead455d755
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_onboard_user.py
@@ -0,0 +1,49 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.commands.owner.interactors.onboard_user import OnboardUserInteractor
+from codecov_auth.models import OwnerProfile
+
+
+class OnboardUserInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.already_onboarded_owner = OwnerFactory(
+ username="codecov-user", onboarding_completed=True
+ )
+ self.good_params = {
+ "email": "dev@dev.com",
+ "business_email": "dev@codecov.io",
+ "type_projects": [OwnerProfile.ProjectType.PERSONAL],
+ "goals": [OwnerProfile.Goal.STARTING_WITH_TESTS, OwnerProfile.Goal.OTHER],
+ "other_goal": "feel confident in my code",
+ }
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await OnboardUserInteractor(None, "github").execute(self.good_params)
+
+ async def test_when_user_already_completed_onboarding(self):
+ with pytest.raises(Unauthorized):
+ await OnboardUserInteractor(
+ self.already_onboarded_owner,
+ "github",
+ ).execute(self.good_params)
+
+ async def test_when_params_arent_good(self):
+ with pytest.raises(ValidationError):
+ await OnboardUserInteractor(self.owner, "github").execute(
+ {**self.good_params, "email": "notgood"}
+ )
+
+ async def test_when_everything_is_good(self):
+ user = await OnboardUserInteractor(self.owner, "github").execute(
+ self.good_params
+ )
+ assert user.email == self.good_params["email"]
+ assert user.business_email == self.good_params["business_email"]
+ assert user.profile.type_projects == self.good_params["type_projects"]
+ assert user.profile.goals == self.good_params["goals"]
+ assert user.profile.other_goal == self.good_params["other_goal"]
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_regenerate_org_upload_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_regenerate_org_upload_token.py
new file mode 100644
index 0000000000..388b4a9d97
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_regenerate_org_upload_token.py
@@ -0,0 +1,39 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+
+from ..regenerate_org_upload_token import RegenerateOrgUploadTokenInteractor
+
+
+class RegenerateOrgUploadTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.random_user = OwnerFactory()
+ self.owner = OwnerFactory(username="codecovv", plan="users-enterprisem")
+ self.user_not_part_of_org = OwnerFactory(
+ username="random", plan="users-enterprisem"
+ )
+
+ def execute(self, owner, org_owner=None):
+ return RegenerateOrgUploadTokenInteractor(owner, "github").execute(
+ owner=org_owner
+ )
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await self.execute(owner="")
+
+ async def test_when_validation_no_owner_found(self):
+ with pytest.raises(ValidationError):
+ await self.execute(owner=self.random_user)
+
+ async def test_regenerate_org_upload_token_user_not_part_of_org(self):
+ with pytest.raises(Unauthorized):
+ await self.execute(
+ owner=self.user_not_part_of_org, org_owner=self.owner.username
+ )
+
+ async def test_regenerate_org_upload_token(self):
+ token = await self.execute(owner=self.owner, org_owner=self.owner.username)
+ assert token is not None
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_revoke_user_token.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_revoke_user_token.py
new file mode 100644
index 0000000000..8e6e645a08
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_revoke_user_token.py
@@ -0,0 +1,34 @@
+import pytest
+from asgiref.sync import sync_to_async
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ UserTokenFactory,
+)
+
+from codecov.commands.exceptions import Unauthenticated
+from codecov_auth.models import UserToken
+
+from ..revoke_user_token import RevokeUserTokenInteractor
+
+
+@sync_to_async
+def get_user_token(external_id):
+ return UserToken.objects.get(external_id=external_id)
+
+
+class RevokeUserTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.user_token = UserTokenFactory(owner=self.owner)
+
+ async def test_unauthenticated(self):
+ with pytest.raises(Unauthenticated):
+ await RevokeUserTokenInteractor(None, "github").execute(123)
+
+ async def test_revoke_user_token(self):
+ await RevokeUserTokenInteractor(self.owner, "github").execute(
+ self.user_token.external_id
+ )
+ with pytest.raises(UserToken.DoesNotExist):
+ await get_user_token(self.user_token.external_id)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_okta_config.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_okta_config.py
new file mode 100644
index 0000000000..8d00cce30e
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_okta_config.py
@@ -0,0 +1,249 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.contrib.auth.models import AnonymousUser
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ OktaSettingsFactory,
+ OwnerFactory,
+)
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.models import OktaSettings
+
+from ..save_okta_config import SaveOktaConfigInteractor
+
+
+class SaveOktaConfigInteractorTest(TestCase):
+ def setUp(self):
+ self.current_user = OwnerFactory(username="codecov-user")
+ self.service = "github"
+ user1 = OwnerFactory()
+ user2 = OwnerFactory()
+ self.owner = OwnerFactory(
+ username=self.current_user.username,
+ service=self.service,
+ account=AccountFactory(),
+ )
+
+ self.owner_with_admins = OwnerFactory(
+ username=self.current_user.username,
+ service=self.service,
+ admins=[self.current_user.ownerid],
+ plan_activated_users=[user1.ownerid, user2.ownerid],
+ account=None,
+ )
+
+ self.interactor = SaveOktaConfigInteractor(
+ current_owner=self.owner,
+ service=self.service,
+ current_user=self.current_user,
+ )
+
+ @async_to_sync
+ def execute(
+ self,
+ interactor: SaveOktaConfigInteractor | None = None,
+ input: dict | None = None,
+ ):
+ if not interactor and self.interactor:
+ interactor = self.interactor
+
+ if not interactor:
+ return
+ return interactor.execute(input)
+
+ def test_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(
+ interactor=SaveOktaConfigInteractor(
+ current_owner=None,
+ service=self.service,
+ current_user=AnonymousUser(),
+ ),
+ input={
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner.username,
+ },
+ )
+
+ def test_validation_error_when_owner_not_found(self):
+ with pytest.raises(ValidationError):
+ self.execute(
+ input={
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": "non-existent-user",
+ },
+ )
+
+ def test_unauthorized_error_when_user_is_not_admin(self):
+ with pytest.raises(Unauthorized):
+ self.execute(
+ input={
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner.username,
+ },
+ )
+
+ def test_create_okta_settings_when_account_does_not_exist(self):
+ plan_activated_users = []
+ for _ in range(100):
+ user_owner = OwnerFactory(user=None)
+ plan_activated_users.append(user_owner.ownerid)
+
+ org_with_lots_of_users = OwnerFactory(
+ service=self.service,
+ admins=[self.current_user.ownerid],
+ plan_activated_users=plan_activated_users,
+ )
+
+ input_data = {
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": org_with_lots_of_users.username,
+ }
+
+ interactor = SaveOktaConfigInteractor(
+ current_owner=self.current_user, service=self.service
+ )
+ self.execute(interactor=interactor, input=input_data)
+
+ org_with_lots_of_users.refresh_from_db()
+ account = org_with_lots_of_users.account
+
+ assert account.name == org_with_lots_of_users.username
+ assert account.plan == org_with_lots_of_users.plan
+ assert account.plan_seat_count == org_with_lots_of_users.plan_user_count
+ assert account.free_seat_count == org_with_lots_of_users.free
+
+ assert account.users.count() == 100
+ assert account.users.count() == len(org_with_lots_of_users.plan_activated_users)
+
+ okta_config = OktaSettings.objects.get(account=org_with_lots_of_users.account)
+
+ assert okta_config.client_id == input_data["client_id"]
+ assert okta_config.client_secret == input_data["client_secret"]
+ assert okta_config.url == input_data["url"]
+ assert okta_config.enabled == input_data["enabled"]
+ assert okta_config.enforced == input_data["enforced"]
+
+ def test_update_okta_settings_when_account_exists(self):
+ input_data = {
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner_with_admins.username,
+ }
+
+ account = AccountFactory()
+ self.owner_with_admins.account = account
+ self.owner_with_admins.save()
+
+ interactor = SaveOktaConfigInteractor(
+ current_owner=self.current_user, service=self.service
+ )
+ self.execute(interactor=interactor, input=input_data)
+
+ okta_config = OktaSettings.objects.get(account=self.owner_with_admins.account)
+
+ assert okta_config.client_id == input_data["client_id"]
+ assert okta_config.client_secret == input_data["client_secret"]
+ assert okta_config.url == input_data["url"]
+ assert okta_config.enabled == input_data["enabled"]
+ assert okta_config.enforced == input_data["enforced"]
+
+ def test_update_okta_settings_url_remove_trailing_slashes(self):
+ input_data = {
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com/",
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner_with_admins.username,
+ }
+
+ account = AccountFactory()
+ self.owner_with_admins.account = account
+ self.owner_with_admins.save()
+
+ interactor = SaveOktaConfigInteractor(
+ current_owner=self.current_user, service=self.service
+ )
+ self.execute(interactor=interactor, input=input_data)
+
+ okta_config = OktaSettings.objects.get(account=self.owner_with_admins.account)
+
+ assert okta_config.url == "https://okta.example.com"
+
+ def test_update_okta_settings_when_okta_settings_exists(self):
+ input_data = {
+ "client_id": "some-client-id",
+ "client_secret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner_with_admins.username,
+ }
+
+ account = AccountFactory()
+ OktaSettingsFactory(account=account)
+ self.owner_with_admins.account = account
+ self.owner_with_admins.save()
+
+ interactor = SaveOktaConfigInteractor(
+ current_owner=self.current_user, service=self.service
+ )
+ self.execute(interactor=interactor, input=input_data)
+
+ okta_config = OktaSettings.objects.get(account=self.owner_with_admins.account)
+
+ assert okta_config.client_id == input_data["client_id"]
+ assert okta_config.client_secret == input_data["client_secret"]
+ assert okta_config.url == input_data["url"]
+ assert okta_config.enabled == input_data["enabled"]
+ assert okta_config.enforced == input_data["enforced"]
+
+ def test_update_okta_settings_when_some_fields_are_none(self):
+ input_data = {
+ "client_id": "some-client-id",
+ "client_secret": None,
+ "url": None,
+ "enabled": True,
+ "enforced": True,
+ "org_username": self.owner_with_admins.username,
+ }
+
+ account = AccountFactory()
+ OktaSettingsFactory(account=account)
+ self.owner_with_admins.account = account
+ self.owner_with_admins.save()
+
+ interactor = SaveOktaConfigInteractor(
+ current_owner=self.current_user, service=self.service
+ )
+ self.execute(interactor=interactor, input=input_data)
+
+ okta_config = OktaSettings.objects.get(account=self.owner_with_admins.account)
+
+ assert okta_config.client_id == input_data["client_id"]
+ assert okta_config.client_secret is not None
+ assert okta_config.url is not None
+ assert okta_config.enabled == input_data["enabled"]
+ assert okta_config.enforced == input_data["enforced"]
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py
new file mode 100644
index 0000000000..07803acc4e
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py
@@ -0,0 +1,256 @@
+from unittest.mock import patch
+
+import pytest
+from asgiref.sync import async_to_sync
+from django.contrib.auth.models import AnonymousUser
+from django.test import TestCase
+from django.utils import timezone
+from freezegun import freeze_time
+from freezegun.api import FakeDatetime
+from shared.django_apps.codecov_auth.tests.factories import UserFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+from ..save_terms_agreement import SaveTermsAgreementInteractor
+
+
+class UpdateSaveTermsAgreementInteractorTest(TestCase):
+ def setUp(self):
+ self.current_user = UserFactory(name="codecov-user")
+ self.updated_at = FakeDatetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+
+ @async_to_sync
+ def execute(self, current_user, input=None):
+ real_input = {
+ "business_email": None,
+ "terms_agreement": False,
+ "marketing_consent": False,
+ }
+ if input:
+ real_input.update(input)
+ return SaveTermsAgreementInteractor(None, "github", current_user).execute(
+ input=real_input,
+ )
+
+ #### Start of older tests
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_deprecated_update_user_when_agreement_is_false(self):
+ self.execute(
+ current_user=self.current_user,
+ input={"terms_agreement": False, "customer_intent": "Business"},
+ )
+ before_refresh_business_email = self.current_user.email
+
+ assert self.current_user.terms_agreement == False
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == before_refresh_business_email
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_deprecated_update_user_when_agreement_is_true(self):
+ self.execute(
+ current_user=self.current_user,
+ input={"terms_agreement": True, "customer_intent": "Business"},
+ )
+ before_refresh_business_email = self.current_user.email
+
+ assert self.current_user.terms_agreement == True
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == before_refresh_business_email
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_deprecated_update_owner_and_user_when_email_is_not_empty(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "terms_agreement": True,
+ "customer_intent": "Business",
+ },
+ )
+
+ assert self.current_user.terms_agreement == True
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == "something@email.com"
+
+ def test_deprecated_validation_error_when_customer_intent_invalid(self):
+ with pytest.raises(ValidationError):
+ self.execute(
+ current_user=self.current_user,
+ input={"terms_agreement": None, "customer_intent": "invalid"},
+ )
+
+ def test_deprecated_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(
+ current_user=AnonymousUser(),
+ input={
+ "business_email": "something@email.com",
+ "terms_agreement": True,
+ "customer_intent": "Business",
+ },
+ )
+
+ def test_deprecated_email_opt_in_saved_in_db(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "terms_agreement": True,
+ "marketing_consent": True,
+ "customer_intent": "Business",
+ },
+ )
+ self.current_user.refresh_from_db()
+ assert self.current_user.email_opt_in == True
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo"
+ )
+ def test_deprecated_marketo_called_only_with_consent(
+ self, mock_send_data_to_marketo
+ ):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "terms_agreement": True,
+ "marketing_consent": True,
+ "customer_intent": "Business",
+ },
+ )
+
+ mock_send_data_to_marketo.assert_called_once()
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo"
+ )
+ def test_deprecated_marketo_not_called_without_consent(
+ self, mock_send_data_to_marketo
+ ):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "terms_agreement": True,
+ "marketing_consent": False,
+ "customer_intent": "Business",
+ },
+ )
+
+ mock_send_data_to_marketo.assert_not_called()
+
+ #### End of older tests
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_update_user_when_agreement_is_false(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": False,
+ },
+ )
+ before_refresh_business_email = self.current_user.email
+
+ assert self.current_user.terms_agreement == False
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == before_refresh_business_email
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_update_user_when_agreement_is_true(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ },
+ )
+ before_refresh_business_email = self.current_user.email
+
+ assert self.current_user.terms_agreement == True
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == before_refresh_business_email
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_update_owner_and_user_when_email_and_name_are_not_empty(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ },
+ )
+
+ assert self.current_user.terms_agreement == True
+ assert self.current_user.terms_agreement_at == self.updated_at
+
+ self.current_user.refresh_from_db()
+ assert self.current_user.email == "something@email.com"
+ assert self.current_user.name == "codecov-user"
+
+ def test_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(
+ current_user=AnonymousUser(),
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ },
+ )
+
+ def test_email_opt_in_saved_in_db(self):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ "marketing_consent": True,
+ },
+ )
+ self.current_user.refresh_from_db()
+ assert self.current_user.email_opt_in == True
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo"
+ )
+ def test_marketo_called_only_with_consent(self, mock_send_data_to_marketo):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ "marketing_consent": True,
+ },
+ )
+
+ mock_send_data_to_marketo.assert_called_once()
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo"
+ )
+ def test_marketo_not_called_without_consent(self, mock_send_data_to_marketo):
+ self.execute(
+ current_user=self.current_user,
+ input={
+ "business_email": "something@email.com",
+ "name": "codecov-user",
+ "terms_agreement": True,
+ "marketing_consent": False,
+ },
+ )
+
+ mock_send_data_to_marketo.assert_not_called()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py
new file mode 100644
index 0000000000..205b12af1d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py
@@ -0,0 +1,92 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+
+from ..set_upload_token_required import SetUploadTokenRequiredInteractor
+
+
+class SetUploadTokenRequiredInteractorTest(TestCase):
+ def setUp(self):
+ self.service = "github"
+ self.current_user = OwnerFactory(username="codecov-user")
+ self.owner = OwnerFactory(
+ username="codecov-owner",
+ service=self.service,
+ )
+ self.owner_with_admins = OwnerFactory(
+ username="codecov-admin-owner",
+ service=self.service,
+ admins=[self.current_user.ownerid],
+ )
+
+ @async_to_sync
+ async def execute(
+ self, current_user, org_username=None, upload_token_required=True
+ ):
+ interactor = SetUploadTokenRequiredInteractor(current_user, self.service)
+ return await interactor.execute(
+ {
+ "upload_token_required": upload_token_required,
+ "org_username": org_username,
+ }
+ )
+
+ def test_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(
+ current_user=None,
+ org_username=self.owner.username,
+ )
+
+ def test_validation_error_when_owner_not_found(self):
+ with pytest.raises(ValidationError):
+ self.execute(
+ current_user=self.current_user,
+ org_username="non-existent-user",
+ )
+
+ def test_unauthorized_error_when_user_is_not_admin(self):
+ with pytest.raises(Unauthorized):
+ self.execute(
+ current_user=self.current_user,
+ org_username=self.owner.username,
+ )
+
+ def test_set_upload_token_required_when_user_is_admin(self):
+ self.current_user.organizations = [self.owner_with_admins.ownerid]
+ self.current_user.save()
+
+ self.execute(
+ current_user=self.current_user,
+ org_username=self.owner_with_admins.username,
+ )
+
+ self.owner_with_admins.refresh_from_db()
+ assert self.owner_with_admins.upload_token_required_for_public_repos == True
+
+ def test_set_upload_token_required_to_false(self):
+ self.current_user.organizations = [self.owner_with_admins.ownerid]
+ self.current_user.save()
+
+ self.execute(
+ current_user=self.current_user,
+ org_username=self.owner_with_admins.username,
+ upload_token_required=False,
+ )
+
+ self.owner_with_admins.refresh_from_db()
+ assert self.owner_with_admins.upload_token_required_for_public_repos == False
+
+ def test_set_upload_token_required_to_null_raises_validation_error(self):
+ self.current_user.organizations = [self.owner_with_admins.ownerid]
+ self.current_user.save()
+
+ with pytest.raises(ValidationError):
+ self.execute(
+ current_user=self.current_user,
+ org_username=self.owner_with_admins.username,
+ upload_token_required=None,
+ )
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_yaml_on_owner.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_yaml_on_owner.py
new file mode 100644
index 0000000000..b82ad35232
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_set_yaml_on_owner.py
@@ -0,0 +1,182 @@
+import pytest
+from asgiref.sync import sync_to_async
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+
+from ..set_yaml_on_owner import SetYamlOnOwnerInteractor
+
+good_yaml = """
+codecov:
+ require_ci_to_pass: yes
+"""
+
+good_yaml_with_quotes = """
+codecov:
+ bot: 'codecov'
+"""
+
+good_yaml_with_bot_and_branch = """
+codecov:
+ branch: 'test-1'
+ bot: 'codecov'
+"""
+
+yaml_with_changed_branch_and_bot = """
+codecov:
+ branch: 'test-2'
+ bot: 'codecov-2'
+"""
+
+bad_yaml_not_dict = """
+hey
+"""
+
+bad_yaml_wrong_keys = """
+toto:
+ tata: titi
+"""
+
+bad_yaml_syntax_error = """
+codecov:
+ bot: foo: bar
+"""
+
+good_yaml_with_comments = """
+# comment 1
+codecov: # comment 2
+
+
+ bot: 'codecov'
+# comment 3
+ #comment 4
+"""
+
+
+class SetYamlOnOwnerInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.current_owner = OwnerFactory(
+ organizations=[self.org.ownerid], service=self.org.service
+ )
+ self.random_owner = OwnerFactory(service=self.org.service)
+ self.codecov_bot = OwnerFactory(
+ username="codecov",
+ organizations=[self.org.ownerid],
+ private_access=True,
+ )
+ self.codecov2_bot = OwnerFactory(
+ username="codecov-2",
+ organizations=[self.org.ownerid],
+ private_access=True,
+ )
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return SetYamlOnOwnerInteractor(owner, service).execute(*args)
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await self.execute(None, self.org.username, good_yaml)
+
+ async def test_when_not_path_of_org_raise(self):
+ with pytest.raises(Unauthorized):
+ await self.execute(self.random_owner, self.org.username, good_yaml)
+
+ async def test_when_owner_not_found_raise(self):
+ with pytest.raises(NotFound):
+ await self.execute(
+ self.current_owner, "thing that should not exist", good_yaml
+ )
+
+ async def test_user_is_part_of_org_and_yaml_is_good(self):
+ owner_updated = await self.execute(
+ self.current_owner, self.org.username, good_yaml
+ )
+ # check the interactor returns the right owner
+ assert owner_updated.ownerid == self.org.ownerid
+ assert owner_updated.yaml == {
+ "codecov": {
+ "require_ci_to_pass": True,
+ },
+ "to_string": "\ncodecov:\n require_ci_to_pass: yes\n",
+ }
+
+ async def test_user_is_part_of_org_and_yaml_has_quotes(self):
+ owner_updated = await self.execute(
+ self.current_owner, self.org.username, good_yaml_with_quotes
+ )
+ # check the interactor returns the right owner
+ assert owner_updated.ownerid == self.org.ownerid
+ assert owner_updated.yaml == {
+ "codecov": {
+ "bot": "codecov",
+ },
+ "to_string": "\ncodecov:\n bot: 'codecov'\n",
+ }
+
+ async def test_user_is_part_of_org_and_yaml_is_empty(self):
+ owner_updated = await self.execute(self.current_owner, self.org.username, "")
+ assert owner_updated.yaml is None
+
+ async def test_user_is_part_of_org_and_yaml_is_not_dict(self):
+ with pytest.raises(ValidationError) as e:
+ await self.execute(self.current_owner, self.org.username, bad_yaml_not_dict)
+ assert str(e.value) == "Error at []: Yaml needs to be a dict"
+
+ async def test_user_is_part_of_org_and_yaml_is_not_codecov_valid(self):
+ with pytest.raises(ValidationError):
+ await self.execute(
+ self.current_owner, self.org.username, bad_yaml_wrong_keys
+ )
+
+ async def test_yaml_syntax_error(self):
+ with pytest.raises(ValidationError) as e:
+ await self.execute(
+ self.current_owner, self.org.username, bad_yaml_syntax_error
+ )
+ assert (
+ str(e.value)
+ == "Syntax error at line 3, column 13: mapping values are not allowed here"
+ )
+
+ async def test_yaml_has_comments(self):
+ owner_updated = await self.execute(
+ self.current_owner, self.org.username, good_yaml_with_comments
+ )
+ # check the interactor returns the right owner
+ assert owner_updated.ownerid == self.org.ownerid
+ assert owner_updated.yaml == {
+ "codecov": {
+ "bot": "codecov",
+ },
+ "to_string": "\n"
+ "# comment 1\n"
+ "codecov: # comment 2\n"
+ "\n"
+ "\n"
+ " bot: 'codecov'\n"
+ "# comment 3\n"
+ " #comment 4\n",
+ }
+
+ async def test_user_changes_yaml_bot_and_branch(self):
+ await sync_to_async(RepositoryFactory)(author=self.org, branch="fake-branch")
+ assert self.current_owner.bot_id is None
+ owner_updated = await self.execute(
+ self.current_owner, self.org.username, yaml_with_changed_branch_and_bot
+ )
+ # check the interactor returns the right owner
+ assert owner_updated.ownerid == self.org.ownerid
+ assert owner_updated.yaml == {
+ "codecov": {"branch": "test-2", "bot": "codecov-2"},
+ "to_string": "\ncodecov:\n branch: 'test-2'\n bot: 'codecov-2'\n",
+ }
+ assert owner_updated.bot_id == self.codecov2_bot.ownerid
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_start_trial.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_start_trial.py
new file mode 100644
index 0000000000..fe00a38d90
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_start_trial.py
@@ -0,0 +1,133 @@
+from datetime import datetime, timedelta
+
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.django_apps.codecov.commands.exceptions import ValidationError
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import (
+ TRIAL_PLAN_SEATS,
+ PlanName,
+ TierName,
+ TrialDaysAmount,
+ TrialStatus,
+)
+
+from codecov.commands.exceptions import Unauthorized
+from codecov.commands.exceptions import ValidationError as CodecovValidationError
+from codecov_auth.models import Owner
+
+from ..start_trial import StartTrialInteractor
+
+
+class StartTrialInteractorTest(TestCase):
+ def setUp(self):
+ self.tier = TierFactory(tier_name=TierName.BASIC.value)
+ self.plan = PlanFactory(tier=self.tier, is_active=True)
+
+ @async_to_sync
+ def execute(self, current_user, org_username=None):
+ current_user = current_user
+ return StartTrialInteractor(current_user, "github").execute(
+ org_username=org_username,
+ )
+
+ def test_start_trial_raises_exception_when_owner_is_not_in_db(self):
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ plan=self.plan.name,
+ )
+ with pytest.raises(CodecovValidationError):
+ self.execute(current_user=current_user, org_username="some-other-username")
+
+ def test_cancel_trial_raises_exception_when_current_user_not_part_of_org(self):
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ plan=self.plan.name,
+ )
+ OwnerFactory(
+ username="random-user-456",
+ service="github",
+ plan=self.plan.name,
+ )
+ with pytest.raises(Unauthorized):
+ self.execute(current_user=current_user, org_username="random-user-456")
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_start_trial_raises_exception_when_owners_trial_status_is_ongoing(self):
+ now = datetime.now()
+ trial_start_date = now
+ trial_end_date = now + timedelta(days=3)
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ trial_status=TrialStatus.ONGOING.value,
+ plan=self.plan.name,
+ )
+ with pytest.raises(ValidationError):
+ self.execute(current_user=current_user, org_username=current_user.username)
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_start_trial_raises_exception_when_owners_trial_status_is_expired(self):
+ now = datetime.now()
+ trial_start_date = now + timedelta(days=-10)
+ trial_end_date = now + timedelta(days=-4)
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ trial_status=TrialStatus.EXPIRED.value,
+ plan=self.plan.name,
+ )
+ with pytest.raises(ValidationError):
+ self.execute(current_user=current_user, org_username=current_user.username)
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_start_trial_raises_exception_when_owners_trial_status_cannot_trial(
+ self,
+ ):
+ now = datetime.now()
+ trial_start_date = now
+ trial_end_date = now
+ current_user = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=trial_start_date,
+ trial_end_date=trial_end_date,
+ trial_status=TrialStatus.CANNOT_TRIAL.value,
+ plan=self.plan.name,
+ )
+ with pytest.raises(ValidationError):
+ self.execute(current_user=current_user, org_username=current_user.username)
+
+ @freeze_time("2022-01-01T00:00:00")
+ def test_start_trial_starts_trial_for_org_that_has_not_started_trial_before_and_calls_segment(
+ self,
+ ):
+ current_user: Owner = OwnerFactory(
+ username="random-user-123",
+ service="github",
+ trial_start_date=None,
+ trial_end_date=None,
+ trial_status=TrialStatus.NOT_STARTED.value,
+ plan=self.plan.name,
+ )
+ self.execute(current_user=current_user, org_username=current_user.username)
+ current_user.refresh_from_db()
+
+ now = datetime.now()
+ assert current_user.trial_start_date == now
+ assert current_user.trial_end_date == now + timedelta(
+ days=TrialDaysAmount.CODECOV_SENTRY.value
+ )
+ assert current_user.trial_status == TrialStatus.ONGOING.value
+ assert current_user.plan == PlanName.TRIAL_PLAN_NAME.value
+ assert current_user.plan_user_count == TRIAL_PLAN_SEATS
+ assert current_user.plan_auto_activate == True
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_trigger_sync.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_trigger_sync.py
new file mode 100644
index 0000000000..8004aa6016
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_trigger_sync.py
@@ -0,0 +1,28 @@
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated
+
+from ..trigger_sync import TriggerSyncInteractor
+
+
+class IsSyncingInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await TriggerSyncInteractor(None, "github").execute()
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ async def test_call_is_refreshing(self, mock_trigger_refresh):
+ await TriggerSyncInteractor(self.owner, "github").execute()
+ mock_trigger_refresh.assert_called_once_with(
+ self.owner.ownerid,
+ self.owner.username,
+ using_integration=False,
+ manual_trigger=True,
+ )
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_default_organization.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_default_organization.py
new file mode 100644
index 0000000000..e0c310e382
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_default_organization.py
@@ -0,0 +1,81 @@
+from unittest.mock import patch
+
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import OwnerProfile
+
+from ..update_default_organization import UpdateDefaultOrganizationInteractor
+
+
+class UpdateDefaultOrganizationInteractorTest(TestCase):
+ def setUp(self):
+ self.default_organization_username = "sample-default-org-username"
+ self.default_organization = OwnerFactory(
+ username=self.default_organization_username, service="github"
+ )
+ self.owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ organizations=[self.default_organization.ownerid],
+ )
+
+ self.org_not_in_users_organizations = OwnerFactory(
+ username="imposter", service="github"
+ )
+
+ @async_to_sync
+ def execute(self, owner, username=None):
+ return UpdateDefaultOrganizationInteractor(owner, "github").execute(
+ default_org_username=username,
+ )
+
+ def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(owner=None, username="random-name")
+
+ def test_update_org_not_belonging_to_users_organizations(self):
+ with pytest.raises(ValidationError):
+ self.execute(owner=self.owner, username="imposter")
+
+ def test_update_org_when_default_org_username_is_none(self):
+ self.execute(owner=self.owner, username=None)
+
+ owner_profile: OwnerProfile = OwnerProfile.objects.filter(
+ owner_id=self.owner.ownerid
+ ).first()
+ assert owner_profile.default_org is None
+
+ def test_update_owners_default_org(self):
+ username = self.execute(
+ owner=self.owner, username=self.default_organization_username
+ )
+
+ owner_profile: OwnerProfile = OwnerProfile.objects.filter(
+ owner_id=self.owner.ownerid
+ ).first()
+ assert owner_profile.default_org == self.default_organization
+ assert username == self.default_organization.username
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.update_default_organization.try_auto_activate"
+ )
+ def test_attempts_to_auto_activate_user_for_default_org(self, try_auto_activate):
+ self.execute(owner=self.owner, username=self.default_organization_username)
+
+ try_auto_activate.assert_called_once_with(
+ self.default_organization,
+ self.owner,
+ )
+
+ def test_update_owners_default_org_when_current_user_is_selected(self):
+ username = self.execute(owner=self.owner, username=self.owner.username)
+
+ owner_profile: OwnerProfile = OwnerProfile.objects.filter(
+ owner_id=self.owner.ownerid
+ ).first()
+ assert owner_profile.default_org == self.owner
+ assert username == self.owner.username
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_profile.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_profile.py
new file mode 100644
index 0000000000..95393f9db4
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_profile.py
@@ -0,0 +1,40 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+from ..update_profile import UpdateProfileInteractor
+
+
+class UpdateProfileInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await UpdateProfileInteractor(None, "github").execute(name="hello")
+
+ async def test_when_email_wrong(self):
+ with pytest.raises(ValidationError):
+ await UpdateProfileInteractor(self.owner, "github").execute(
+ email="not-right"
+ )
+
+ async def test_update_name(self):
+ user = await UpdateProfileInteractor(self.owner, "github").execute(name="hello")
+ assert user.name == "hello"
+
+ async def test_update_email(self):
+ user = await UpdateProfileInteractor(self.owner, "github").execute(
+ email="hello@codecov.io"
+ )
+ assert user.email == "hello@codecov.io"
+
+ async def test_update_email_and_name(self):
+ user = await UpdateProfileInteractor(self.owner, "github").execute(
+ name="codecov brother", email="brother@codecov.io"
+ )
+ assert user == self.owner
+ assert user.email == "brother@codecov.io"
+ assert user.name == "codecov brother"
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_self_hosted_settings.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_self_hosted_settings.py
new file mode 100644
index 0000000000..cb16ec6691
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/tests/test_update_self_hosted_settings.py
@@ -0,0 +1,59 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.contrib.auth.models import AnonymousUser
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.commands.owner.interactors.update_self_hosted_settings import (
+ UpdateSelfHostedSettingsInteractor,
+)
+
+
+class UpdateSelfHostedSettingsInteractorTest(TestCase):
+ @async_to_sync
+ def execute(
+ self,
+ current_user,
+ input={
+ "should_auto_activate": None,
+ },
+ ):
+ return UpdateSelfHostedSettingsInteractor(None, "github", current_user).execute(
+ input=input,
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_update_self_hosted_settings_when_auto_activate_is_true(self):
+ owner = OwnerFactory(plan_auto_activate=False)
+ self.execute(current_user=owner, input={"should_auto_activate": True})
+ owner.refresh_from_db()
+ assert owner.plan_auto_activate == True
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_update_self_hosted_settings_when_auto_activate_is_false(self):
+ owner = OwnerFactory(plan_auto_activate=True)
+ self.execute(current_user=owner, input={"should_auto_activate": False})
+ owner.refresh_from_db()
+ assert owner.plan_auto_activate == False
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_validation_error_when_not_self_hosted_instance(self):
+ owner = OwnerFactory(plan_auto_activate=True)
+ with pytest.raises(ValidationError):
+ self.execute(
+ current_user=owner,
+ input={
+ "should_auto_activate": False,
+ },
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(
+ current_user=AnonymousUser(),
+ input={
+ "should_auto_activate": False,
+ },
+ )
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/trigger_sync.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/trigger_sync.py
new file mode 100644
index 0000000000..32f6028431
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/trigger_sync.py
@@ -0,0 +1,21 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated
+from services.refresh import RefreshService
+
+
+class TriggerSyncInteractor(BaseInteractor):
+ def validate(self) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ @sync_to_async
+ def execute(self) -> None:
+ self.validate()
+ RefreshService().trigger_refresh(
+ self.current_owner.ownerid,
+ self.current_owner.username,
+ using_integration=False,
+ manual_trigger=True,
+ )
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/update_default_organization.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_default_organization.py
new file mode 100644
index 0000000000..023e7583b2
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_default_organization.py
@@ -0,0 +1,49 @@
+from typing import Optional
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import Owner, OwnerProfile
+from services.activation import try_auto_activate
+
+
+class UpdateDefaultOrganizationInteractor(BaseInteractor):
+ def validate(
+ self,
+ default_org: Optional[Owner],
+ ) -> Optional[Owner]:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ if default_org is None:
+ return
+
+ if (
+ default_org.ownerid not in self.current_owner.organizations
+ and default_org.ownerid != self.current_owner.ownerid
+ ):
+ raise ValidationError(
+ "Organization does not belong in current user's organization list"
+ )
+
+ def update_default_organization(self, default_org: Optional[Owner]):
+ owner_profile, _ = OwnerProfile.objects.get_or_create(
+ owner_id=self.current_owner.ownerid
+ )
+ owner_profile.default_org = default_org
+ saved_owner_profile = owner_profile.save()
+ if default_org:
+ try_auto_activate(default_org, owner_profile.owner)
+ return saved_owner_profile
+
+ @sync_to_async
+ def execute(self, default_org_username: str):
+ default_org = None
+ if default_org_username is not None:
+ default_org = Owner.objects.filter(
+ username=default_org_username, service=self.service
+ ).first()
+ self.validate(default_org)
+ self.update_default_organization(default_org)
+ return default_org_username
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/update_profile.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_profile.py
new file mode 100644
index 0000000000..6ff5666550
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_profile.py
@@ -0,0 +1,34 @@
+from asgiref.sync import sync_to_async
+from django import forms
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+
+class UpdateProfileForm(forms.Form):
+ name = forms.CharField(required=False)
+ email = forms.EmailField(required=False)
+
+
+class UpdateProfileInteractor(BaseInteractor):
+ def validate(self, **kwargs):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+ form = UpdateProfileForm(kwargs)
+ if not form.is_valid():
+ # temporary solution to expose form errors until a better abstraction
+ raise ValidationError(form.errors.as_json())
+
+ def update_field(self, field_name, **kwargs):
+ field = kwargs.get(field_name)
+ if not field:
+ return
+ setattr(self.current_owner, field_name, field)
+
+ @sync_to_async
+ def execute(self, **kwargs):
+ self.validate(**kwargs)
+ self.update_field("email", **kwargs)
+ self.update_field("name", **kwargs)
+ self.current_owner.save()
+ return self.current_owner
diff --git a/apps/codecov-api/codecov_auth/commands/owner/interactors/update_self_hosted_settings.py b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_self_hosted_settings.py
new file mode 100644
index 0000000000..5464a323c7
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/interactors/update_self_hosted_settings.py
@@ -0,0 +1,37 @@
+from dataclasses import dataclass
+
+from asgiref.sync import sync_to_async
+from django.conf import settings
+
+import services.self_hosted as self_hosted
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+
+@dataclass
+class UpdateSelfHostedSettingsInput:
+ auto_activate_members: bool = False
+
+
+class UpdateSelfHostedSettingsInteractor(BaseInteractor):
+ def validate(self) -> None:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ if not settings.IS_ENTERPRISE:
+ raise ValidationError(
+ "enable_autoactivation and disable_autoactivation are only available in self-hosted environments"
+ )
+
+ @sync_to_async
+ def execute(self, input: UpdateSelfHostedSettingsInput) -> None:
+ self.validate()
+ typed_input = UpdateSelfHostedSettingsInput(
+ auto_activate_members=input.get("should_auto_activate"),
+ )
+
+ should_auto_activate = typed_input.auto_activate_members
+ if should_auto_activate:
+ self_hosted.enable_autoactivation()
+ else:
+ self_hosted.disable_autoactivation()
diff --git a/apps/codecov-api/codecov_auth/commands/owner/owner.py b/apps/codecov-api/codecov_auth/commands/owner/owner.py
new file mode 100644
index 0000000000..53b21a9577
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/owner.py
@@ -0,0 +1,108 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.cancel_trial import CancelTrialInteractor
+from .interactors.create_api_token import CreateApiTokenInteractor
+from .interactors.create_stripe_setup_intent import CreateStripeSetupIntentInteractor
+from .interactors.create_user_token import CreateUserTokenInteractor
+from .interactors.delete_session import DeleteSessionInteractor
+from .interactors.fetch_owner import FetchOwnerInteractor
+from .interactors.get_is_current_user_an_admin import GetIsCurrentUserAnAdminInteractor
+from .interactors.get_org_upload_token import GetOrgUploadToken
+from .interactors.get_uploads_number_per_user import GetUploadsNumberPerUserInteractor
+from .interactors.is_syncing import IsSyncingInteractor
+from .interactors.onboard_user import OnboardUserInteractor
+from .interactors.regenerate_org_upload_token import RegenerateOrgUploadTokenInteractor
+from .interactors.revoke_user_token import RevokeUserTokenInteractor
+from .interactors.save_okta_config import SaveOktaConfigInteractor
+from .interactors.save_terms_agreement import SaveTermsAgreementInteractor
+from .interactors.set_upload_token_required import SetUploadTokenRequiredInteractor
+from .interactors.set_yaml_on_owner import SetYamlOnOwnerInteractor
+from .interactors.start_trial import StartTrialInteractor
+from .interactors.store_codecov_metric import StoreCodecovMetricInteractor
+from .interactors.trigger_sync import TriggerSyncInteractor
+from .interactors.update_default_organization import UpdateDefaultOrganizationInteractor
+from .interactors.update_profile import UpdateProfileInteractor
+from .interactors.update_self_hosted_settings import UpdateSelfHostedSettingsInteractor
+
+
+class OwnerCommands(BaseCommand):
+ def create_api_token(self, name):
+ return self.get_interactor(CreateApiTokenInteractor).execute(name)
+
+ def create_stripe_setup_intent(self, owner):
+ return self.get_interactor(CreateStripeSetupIntentInteractor).execute(owner)
+
+ def delete_session(self, sessionid: int):
+ return self.get_interactor(DeleteSessionInteractor).execute(sessionid)
+
+ def create_user_token(self, name, token_type=None):
+ return self.get_interactor(CreateUserTokenInteractor).execute(name, token_type)
+
+ def revoke_user_token(self, tokenid):
+ return self.get_interactor(RevokeUserTokenInteractor).execute(tokenid)
+
+ def set_yaml_on_owner(self, username, yaml):
+ return self.get_interactor(SetYamlOnOwnerInteractor).execute(username, yaml)
+
+ def update_profile(self, **kwargs):
+ return self.get_interactor(UpdateProfileInteractor).execute(**kwargs)
+
+ def save_terms_agreement(self, input):
+ return self.get_interactor(SaveTermsAgreementInteractor).execute(input)
+
+ def update_default_organization(self, **kwargs):
+ return self.get_interactor(UpdateDefaultOrganizationInteractor).execute(
+ **kwargs
+ )
+
+ def fetch_owner(self, username):
+ return self.get_interactor(FetchOwnerInteractor).execute(username)
+
+ def trigger_sync(self):
+ return self.get_interactor(TriggerSyncInteractor).execute()
+
+ def is_syncing(self):
+ return self.get_interactor(IsSyncingInteractor).execute()
+
+ def onboard_user(self, params):
+ return self.get_interactor(OnboardUserInteractor).execute(params)
+
+ def get_uploads_number_per_user(self, owner):
+ return self.get_interactor(GetUploadsNumberPerUserInteractor).execute(owner)
+
+ def get_is_current_user_an_admin(self, owner, current_user):
+ return self.get_interactor(GetIsCurrentUserAnAdminInteractor).execute(
+ owner, current_user
+ )
+
+ def get_org_upload_token(self, owner):
+ return self.get_interactor(GetOrgUploadToken).execute(owner)
+
+ def regenerate_org_upload_token(self, owner):
+ return self.get_interactor(RegenerateOrgUploadTokenInteractor).execute(owner)
+
+ def start_trial(self, org_username: str) -> None:
+ return self.get_interactor(StartTrialInteractor).execute(
+ org_username=org_username
+ )
+
+ def cancel_trial(self, org_username: str) -> None:
+ return self.get_interactor(CancelTrialInteractor).execute(
+ org_username=org_username
+ )
+
+ def update_self_hosted_settings(self, input) -> None:
+ return self.get_interactor(UpdateSelfHostedSettingsInteractor).execute(input)
+
+ def store_codecov_metric(
+ self, org_username: str, event: str, json_string: str
+ ) -> None:
+ return self.get_interactor(StoreCodecovMetricInteractor).execute(
+ org_username, event, json_string
+ )
+
+ def save_okta_config(self, input) -> None:
+ return self.get_interactor(SaveOktaConfigInteractor).execute(input)
+
+ def set_upload_token_required(self, input) -> None:
+ return self.get_interactor(SetUploadTokenRequiredInteractor).execute(input)
diff --git a/apps/codecov-api/codecov_auth/commands/owner/tests/__init__.py b/apps/codecov-api/codecov_auth/commands/owner/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/commands/owner/tests/test_owner.py b/apps/codecov-api/codecov_auth/commands/owner/tests/test_owner.py
new file mode 100644
index 0000000000..cd8456e3f6
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/commands/owner/tests/test_owner.py
@@ -0,0 +1,144 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from ..owner import OwnerCommands
+
+
+class OwnerCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.command = OwnerCommands(self.owner, "github")
+
+ @patch("codecov_auth.commands.owner.owner.CreateApiTokenInteractor.execute")
+ def test_create_api_token_delegate_to_interactor(self, interactor_mock):
+ name = "new api token"
+ self.command.create_api_token(name)
+ interactor_mock.assert_called_once_with(name)
+
+ @patch("codecov_auth.commands.owner.owner.DeleteSessionInteractor.execute")
+ def test_delete_session_delegate_to_interactor(self, interactor_mock):
+ sessionid = 12
+ self.command.delete_session(sessionid)
+ interactor_mock.assert_called_once_with(sessionid)
+
+ @patch("codecov_auth.commands.owner.owner.CreateUserTokenInteractor.execute")
+ def test_create_user_token_delegate_to_interactor(self, interactor_mock):
+ name = "new api token"
+ self.command.create_user_token(name)
+ interactor_mock.assert_called_once_with(name, None)
+
+ @patch("codecov_auth.commands.owner.owner.RevokeUserTokenInteractor.execute")
+ def test_revoke_user_token_delegate_to_interactor(self, interactor_mock):
+ tokenid = 123
+ self.command.revoke_user_token(tokenid)
+ interactor_mock.assert_called_once_with(tokenid)
+
+ @patch("codecov_auth.commands.owner.owner.SetYamlOnOwnerInteractor.execute")
+ def test_set_yaml_on_owner_delegate_to_interactor(self, interactor_mock):
+ username = "codecov"
+ yaml = "codecov: something"
+ self.command.set_yaml_on_owner(username, yaml)
+ interactor_mock.assert_called_once_with(username, yaml)
+
+ @patch("codecov_auth.commands.owner.owner.UpdateProfileInteractor.execute")
+ def test_update_profile_delegate_to_interactor(self, interactor_mock):
+ name = "codecov name"
+ self.command.update_profile(name=name)
+ interactor_mock.assert_called_once_with(name=name)
+
+ @patch("codecov_auth.commands.owner.owner.SaveTermsAgreementInteractor.execute")
+ def test_save_terms_agreement_delegate_to_interactor(self, interactor_mock):
+ input_dict = {"email": "a@a.com", "termsAgreement": False}
+ self.command.save_terms_agreement(input_dict)
+ interactor_mock.assert_called_once_with(input_dict)
+
+ @patch("codecov_auth.commands.owner.owner.StartTrialInteractor.execute")
+ def test_start_trial_delegate_to_interactor(self, interactor_mock):
+ org_username = "random_org"
+ self.command.start_trial(org_username=org_username)
+ interactor_mock.assert_called_once_with(org_username=org_username)
+
+ @patch("codecov_auth.commands.owner.owner.CancelTrialInteractor.execute")
+ def test_cancel_trial_delegate_to_interactor(self, interactor_mock):
+ org_username = "random_org"
+ self.command.cancel_trial(org_username=org_username)
+ interactor_mock.assert_called_once_with(org_username=org_username)
+
+ @patch(
+ "codecov_auth.commands.owner.owner.UpdateDefaultOrganizationInteractor.execute"
+ )
+ def test_update_default_organization_delegate_to_interactor(self, interactor_mock):
+ username = "codecov-user"
+ self.command.update_default_organization(default_org_username=username)
+ interactor_mock.assert_called_once_with(default_org_username=username)
+
+ @patch("codecov_auth.commands.owner.owner.TriggerSyncInteractor.execute")
+ def test_trigger_sync_delegate_to_interactor(self, interactor_mock):
+ self.command.trigger_sync()
+ interactor_mock.assert_called_once()
+
+ @patch("codecov_auth.commands.owner.owner.IsSyncingInteractor.execute")
+ def test_is_syncing_delegate_to_interactor(self, interactor_mock):
+ self.command.is_syncing()
+ interactor_mock.assert_called_once()
+
+ @patch("codecov_auth.commands.owner.owner.OnboardUserInteractor.execute")
+ def test_onboard_user_delegate_to_interactor(self, interactor_mock):
+ params = {}
+ self.command.onboard_user(params)
+ interactor_mock.assert_called_once_with(params)
+
+ @patch(
+ "codecov_auth.commands.owner.owner.GetUploadsNumberPerUserInteractor.execute"
+ )
+ def test_get_uploads_number_per_user_delegate_to_interactor(self, interactor_mock):
+ owner = {}
+ self.command.get_uploads_number_per_user(owner)
+ interactor_mock.assert_called_once_with(owner)
+
+ @patch(
+ "codecov_auth.commands.owner.owner.GetIsCurrentUserAnAdminInteractor.execute"
+ )
+ def test_get_is_current_user_an_admin_delegate_to_interactor(self, interactor_mock):
+ owner = {}
+ current_user = {}
+ self.command.get_is_current_user_an_admin(owner, current_user)
+ interactor_mock.assert_called_once_with(owner, current_user)
+
+ @patch("codecov_auth.commands.owner.owner.GetOrgUploadToken.execute")
+ def test_get_org_upload_token_delegate_to_interactor(self, interactor_mock):
+ owner = {}
+ self.command.get_org_upload_token(owner)
+ interactor_mock.assert_called_once_with(owner)
+
+ @patch(
+ "codecov_auth.commands.owner.owner.RegenerateOrgUploadTokenInteractor.execute"
+ )
+ def test_regenerate_org_upload_token_delegate_to_interactor(self, interactor_mock):
+ owner = {}
+ self.command.regenerate_org_upload_token(owner)
+ interactor_mock.assert_called_once_with(owner)
+
+ @patch("codecov_auth.commands.owner.owner.SaveOktaConfigInteractor.execute")
+ def test_save_okta_config_delegate_to_interactor(self, interactor_mock):
+ input_dict = {
+ "client_id": "123",
+ "client_secret": "123",
+ "url": "http://example.com",
+ "enabled": True,
+ "enforced": False,
+ "org_username": "codecov-user",
+ }
+ self.command.save_okta_config(input_dict)
+ interactor_mock.assert_called_once_with(input_dict)
+
+ @patch("codecov_auth.commands.owner.owner.SetUploadTokenRequiredInteractor.execute")
+ def test_set_upload_token_required_delegate_to_interactor(self, interactor_mock):
+ input_dict = {
+ "upload_token_required": True,
+ "org_username": "codecov-user",
+ }
+ self.command.set_upload_token_required(input_dict)
+ interactor_mock.assert_called_once_with(input_dict)
diff --git a/apps/codecov-api/codecov_auth/constants.py b/apps/codecov-api/codecov_auth/constants.py
new file mode 100644
index 0000000000..a818f2a64e
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/constants.py
@@ -0,0 +1,6 @@
+AVATAR_GITHUB_BASE_URL = "https://avatars0.githubusercontent.com"
+BITBUCKET_BASE_URL = "https://bitbucket.org"
+GITLAB_BASE_URL = "https://gitlab.com"
+GRAVATAR_BASE_URL = "https://www.gravatar.com"
+AVATARIO_BASE_URL = "https://avatars.io"
+OWNER_YAML_TO_STRING_KEY = "to_string"
diff --git a/apps/codecov-api/codecov_auth/helpers.py b/apps/codecov-api/codecov_auth/helpers.py
new file mode 100644
index 0000000000..3998d7529f
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/helpers.py
@@ -0,0 +1,71 @@
+from traceback import format_stack
+
+import requests
+from django.contrib.admin.models import CHANGE, LogEntry
+from django.contrib.contenttypes.models import ContentType
+
+from codecov_auth.constants import GITLAB_BASE_URL
+
+GITLAB_PAYLOAD_AVATAR_URL_KEY = "avatar_url"
+
+
+def get_gitlab_url(email, size):
+ res = requests.get(
+ "{}/api/v4/avatar?email={}&size={}".format(GITLAB_BASE_URL, email, size)
+ )
+ url = ""
+ if res.status_code == 200:
+ data = res.json()
+ try:
+ url = data[GITLAB_PAYLOAD_AVATAR_URL_KEY]
+ except KeyError:
+ pass
+
+ return url
+
+
+def current_user_part_of_org(owner, org):
+ if owner is None:
+ return False
+ if owner == org:
+ return True
+ # owner is a direct member of the org
+ orgs_of_user = owner.organizations or []
+ return org.ownerid in orgs_of_user
+
+
+# https://stackoverflow.com/questions/7905106/adding-a-log-entry-for-an-action-by-a-user-in-a-django-ap
+
+
+class History:
+ @staticmethod
+ def log(objects, message, user, action_flag=None, add_traceback=False):
+ """
+ Log an action in the admin log
+ :param objects: Objects being operated on
+ :param message: Message to log
+ :param user: User performing action
+ :param action_flag: Type of action being performed
+ :param add_traceback: Add the stack trace to the message
+ """
+ if action_flag is None:
+ action_flag = CHANGE
+
+ if not isinstance(objects, list):
+ objects = [objects]
+
+ if add_traceback:
+ message = f"{message}: {format_stack()}"
+
+ for obj in objects:
+ if not obj:
+ continue
+
+ LogEntry.objects.log_action(
+ user_id=user.pk,
+ content_type_id=ContentType.objects.get_for_model(obj).pk,
+ object_repr=str(obj),
+ object_id=obj.ownerid,
+ change_message=message,
+ action_flag=action_flag,
+ )
diff --git a/apps/codecov-api/codecov_auth/managers.py b/apps/codecov-api/codecov_auth/managers.py
new file mode 100644
index 0000000000..c081d62cb6
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/managers.py
@@ -0,0 +1,83 @@
+from django.db.models import Exists, Func, Manager, OuterRef, Q, QuerySet, Subquery
+from django.db.models.functions import Coalesce
+
+from core.models import Pull
+
+
+class OwnerQuerySet(QuerySet):
+ def users_of(self, owner):
+ """
+ Returns users of "owner", which is defined as Owner objects
+ whose "organizations" field contains the "owner"s ownerid
+ or is one of the "owner"s "plan_activated_users".
+ """
+ filters = Q(organizations__contains=[owner.ownerid])
+ if owner.plan_activated_users:
+ filters = filters | Q(ownerid__in=owner.plan_activated_users)
+
+ return self.filter(filters)
+
+ def annotate_activated_in(self, owner):
+ """
+ Annotates queryset with "activated" field, which is True
+ if a given user is activated in organization "owner", false
+ otherwise.
+ """
+ from codecov_auth.models import Owner
+
+ return self.annotate(
+ activated=Coalesce(
+ Exists(
+ Owner.objects.filter(
+ ownerid=owner.ownerid,
+ plan_activated_users__contains=Func(
+ OuterRef("ownerid"),
+ function="ARRAY",
+ template="%(function)s[%(expressions)s]",
+ ),
+ )
+ ),
+ False,
+ )
+ )
+
+ def annotate_is_admin_in(self, owner):
+ """
+ Annotates queryset with "is_admin" field, which is True
+ if a given user is an admin in organization "owner", and
+ false otherwise.
+ """
+ from codecov_auth.models import Owner
+
+ return self.annotate(
+ is_admin=Coalesce(
+ Exists(
+ Owner.objects.filter(
+ ownerid=owner.ownerid,
+ admins__contains=Func(
+ OuterRef("ownerid"),
+ function="ARRAY",
+ template="%(function)s[%(expressions)s]",
+ ),
+ )
+ ),
+ False,
+ )
+ )
+
+ def annotate_last_pull_timestamp(self):
+ pulls = Pull.objects.filter(author=OuterRef("pk")).order_by("-updatestamp")
+ return self.annotate(
+ last_pull_timestamp=Subquery(pulls.values("updatestamp")[:1]),
+ )
+
+
+# We cannot use `QuerySet.as_manager()` since it relies on the `inspect` module and will
+# not play nicely with Cython (which we use for self-hosted):
+# https://cython.readthedocs.io/en/latest/src/userguide/limitations.html#inspect-support
+class OwnerManager(Manager):
+ def get_queryset(self):
+ return OwnerQuerySet(self.model, using=self._db)
+
+ def users_of(self, *args, **kwargs):
+ return self.get_queryset().users_of(*args, **kwargs)
diff --git a/apps/codecov-api/codecov_auth/middleware.py b/apps/codecov-api/codecov_auth/middleware.py
new file mode 100644
index 0000000000..6664c600bd
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/middleware.py
@@ -0,0 +1,165 @@
+import logging
+from typing import Optional
+from urllib.parse import urlparse
+
+from corsheaders.conf import conf as corsconf
+from corsheaders.middleware import (
+ ACCESS_CONTROL_ALLOW_CREDENTIALS,
+ ACCESS_CONTROL_ALLOW_ORIGIN,
+)
+from corsheaders.middleware import CorsMiddleware as BaseCorsMiddleware
+from django.http import HttpRequest, HttpResponse
+from django.urls import resolve
+from django.utils.deprecation import MiddlewareMixin
+from rest_framework import exceptions
+
+from codecov_auth.models import Owner, Service
+from utils.services import get_long_service_name
+
+log = logging.getLogger(__name__)
+
+
+def get_service(request: HttpRequest) -> Optional[str]:
+ resolver_match = resolve(request.path_info)
+ service = resolver_match.kwargs.get("service")
+ if service is not None:
+ service = get_long_service_name(service.lower())
+ try:
+ Service(service)
+ return service
+ except ValueError:
+ # not a valid service
+ return None
+
+
+class CurrentOwnerMiddleware(MiddlewareMixin):
+ """
+ The authenticated `User` may have multiple linked `Owners` and we need a way
+ to load the "currently active" `Owner` for use in this request.
+
+ If there's a `current_owner_id` value in the session then we use that.
+ If the current owner does not match the request's `service` then we just pick the first
+ of the user's owners with the matching service.
+
+ This middleware is preferrable to accessing the session directly in views since
+ we can load the `Owner` once and reuse it anywhere needed (without having to perform
+ additional database queries).
+ """
+
+ def process_request(self, request: HttpRequest) -> None:
+ if not request.user or request.user.is_anonymous:
+ request.current_owner = None
+ return
+
+ current_user = request.user
+ current_owner = None
+
+ current_owner_id = request.session.get("current_owner_id")
+ if current_owner_id is not None:
+ current_owner = current_user.owners.filter(pk=current_owner_id).first()
+
+ service = get_service(request)
+ if service and (current_owner is None or service != current_owner.service):
+ # FIXME: this is OK (for now) since we're only allowing a single owner of a given
+ # service to be linked to any 1 user
+ current_owner = current_user.owners.filter(service=service).first()
+
+ request.current_owner = current_owner
+
+
+class ImpersonationMiddleware(MiddlewareMixin):
+ """
+ Allows staff users to impersonate other users for debugging.
+ """
+
+ def process_request(self, request: HttpRequest) -> None:
+ """Log and ensure that the impersonating user is authenticated.
+ The `current user` is the staff user that is impersonating the
+ user owner at `impersonating_ownerid`.
+ """
+ current_user = request.user
+
+ if current_user and not current_user.is_anonymous:
+ impersonating_ownerid = request.COOKIES.get("staff_user")
+ if impersonating_ownerid is None:
+ request.impersonation = False
+ return
+
+ log.info(
+ "Impersonation attempted",
+ extra=dict(
+ current_user_id=current_user.pk,
+ current_user_email=current_user.email,
+ impersonating_ownerid=impersonating_ownerid,
+ ),
+ )
+ if not current_user.is_staff:
+ log.warning(
+ "Impersonation unsuccessful",
+ extra=dict(
+ reason="must be a staff user",
+ current_user_id=current_user.pk,
+ current_user_email=current_user.email,
+ impersonating_ownerid=impersonating_ownerid,
+ ),
+ )
+ raise exceptions.PermissionDenied()
+
+ request.current_owner = (
+ Owner.objects.filter(pk=impersonating_ownerid)
+ .prefetch_related("user")
+ .first()
+ )
+ if request.current_owner is None:
+ log.warning(
+ "Impersonation unsuccessful",
+ extra=dict(
+ reason="no such owner",
+ current_user_id=current_user.pk,
+ current_user_email=current_user.email,
+ impersonating_ownerid=impersonating_ownerid,
+ ),
+ )
+ raise exceptions.AuthenticationFailed()
+
+ log.info(
+ "Impersonation successful",
+ extra=dict(
+ current_user_id=current_user.pk,
+ current_user_email=current_user.email,
+ impersonating_ownerid=impersonating_ownerid,
+ ),
+ )
+ request.impersonation = True
+ else:
+ request.impersonation = False
+
+
+class CorsMiddleware(BaseCorsMiddleware):
+ def process_response(
+ self, request: HttpRequest, response: HttpResponse
+ ) -> HttpResponse:
+ response = super().process_response(request, response)
+ if not self.is_enabled(request):
+ return response
+
+ origin = request.META.get("HTTP_ORIGIN")
+ if not origin:
+ return response
+
+ # we only allow credentials with CORS requests if the request
+ # is coming from one of our explicitly whitelisted domains
+ # (other domains will only be able to access public resources)
+ allow_credentials = False
+ if corsconf.CORS_ALLOW_CREDENTIALS:
+ url = urlparse(origin)
+ if self.origin_found_in_white_lists(origin, url):
+ allow_credentials = True
+
+ response.headers[ACCESS_CONTROL_ALLOW_ORIGIN] = origin
+ if allow_credentials:
+ response.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] = "true"
+ else:
+ del response.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS]
+
+ return response
diff --git a/apps/codecov-api/codecov_auth/models.py b/apps/codecov-api/codecov_auth/models.py
new file mode 100644
index 0000000000..b30e6f5e49
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/models.py
@@ -0,0 +1 @@
+from shared.django_apps.codecov_auth.models import *
diff --git a/apps/codecov-api/codecov_auth/permissions.py b/apps/codecov-api/codecov_auth/permissions.py
new file mode 100644
index 0000000000..c8e43437e7
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/permissions.py
@@ -0,0 +1,8 @@
+from rest_framework.permissions import BasePermission
+
+
+class SpecificScopePermission(BasePermission):
+ def has_permission(self, request, view):
+ return request.auth is not None and all(
+ scope in request.auth.get_scopes() for scope in view.required_scopes
+ )
diff --git a/apps/codecov-api/codecov_auth/services/org_level_token_service.py b/apps/codecov-api/codecov_auth/services/org_level_token_service.py
new file mode 100644
index 0000000000..bb20bfb36d
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/services/org_level_token_service.py
@@ -0,0 +1,70 @@
+import logging
+import uuid
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.forms import ValidationError
+
+from codecov_auth.models import OrganizationLevelToken, Owner, Plan
+
+log = logging.getLogger(__name__)
+
+
+class OrgLevelTokenService(object):
+ """
+ Groups some basic CRUD functionality to create and delete OrganizationLevelToken.
+ Restrictions:
+ -- only 1 token per Owner
+ """
+
+ @classmethod
+ def org_can_have_upload_token(cls, org: Owner):
+ return org.plan in Plan.objects.values_list("name", flat=True)
+
+ @classmethod
+ def get_or_create_org_token(cls, org: Owner):
+ if not cls.org_can_have_upload_token(org):
+ raise ValidationError(
+ "Organization-wide upload tokens are not available for your organization."
+ )
+ token, created = OrganizationLevelToken.objects.get_or_create(owner=org)
+ if created:
+ log.info(
+ "New OrgLevelToken created",
+ extra=dict(
+ ownerid=org.ownerid,
+ valid_until=token.valid_until,
+ token_type=token.token_type,
+ ),
+ )
+ return token
+
+ @classmethod
+ def refresh_token(cls, tokenid: int):
+ try:
+ token = OrganizationLevelToken.objects.get(id=tokenid)
+ token.token = uuid.uuid4()
+ token.save()
+ except OrganizationLevelToken.DoesNotExist:
+ raise ValidationError(
+ "Token to refresh was not found", params=dict(tokenid=tokenid)
+ )
+
+ @classmethod
+ def delete_org_token_if_exists(cls, org: Owner):
+ try:
+ org_token = OrganizationLevelToken.objects.get(owner=org)
+ org_token.delete()
+ except OrganizationLevelToken.DoesNotExist:
+ pass
+
+
+@receiver(post_save, sender=Owner)
+def manage_org_tokens_if_owner_plan_changed(sender, instance: Owner, **kwargs):
+ """
+ Gets executed after saving a Owner instance to DB.
+ Manages OrganizationLevelToken according to Owner plan, either creating or deleting them as necessary
+ """
+ owner_can_have_org_token = OrgLevelTokenService.org_can_have_upload_token(instance)
+ if not owner_can_have_org_token:
+ OrgLevelTokenService.delete_org_token_if_exists(instance)
diff --git a/apps/codecov-api/codecov_auth/signals.py b/apps/codecov-api/codecov_auth/signals.py
new file mode 100644
index 0000000000..5e60d26eb5
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/signals.py
@@ -0,0 +1,53 @@
+from typing import Any, Dict, Optional, Type, cast
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from codecov_auth.models import OrganizationLevelToken, Owner, OwnerProfile
+from utils.shelter import ShelterPubsub
+
+
+@receiver(post_save, sender=Owner)
+def create_owner_profile_when_owner_is_created(
+ sender: Type[Owner], instance: Owner, created: bool, **kwargs: Dict[str, Any]
+) -> Optional[OwnerProfile]:
+ if created:
+ return OwnerProfile.objects.create(owner_id=instance.ownerid)
+
+
+@receiver(
+ post_save, sender=OrganizationLevelToken, dispatch_uid="shelter_sync_org_token"
+)
+def update_org_token(
+ sender: Type[OrganizationLevelToken],
+ instance: OrganizationLevelToken,
+ **kwargs: Dict[str, Any],
+) -> None:
+ data = {
+ "type": "org_token",
+ "sync": "one",
+ "id": instance.id,
+ }
+ ShelterPubsub.get_instance().publish(data)
+
+
+@receiver(post_save, sender=Owner, dispatch_uid="shelter_sync_owner")
+def update_owner(
+ sender: Type[Owner], instance: Owner, **kwargs: Dict[str, Any]
+) -> None:
+ """
+ Shelter tracks a limited set of Owner fields - only update if those fields have changed.
+ """
+ created: bool = cast(bool, kwargs["created"])
+ tracked_fields = [
+ "upload_token_required_for_public_repos",
+ "username",
+ "service",
+ ]
+ if created or any(instance.tracker.has_changed(field) for field in tracked_fields):
+ data = {
+ "type": "owner",
+ "sync": "one",
+ "id": instance.ownerid,
+ }
+ ShelterPubsub.get_instance().publish(data)
diff --git a/apps/codecov-api/codecov_auth/templates/admin/extend_trial_form.html b/apps/codecov-api/codecov_auth/templates/admin/extend_trial_form.html
new file mode 100644
index 0000000000..24c6bba2bd
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/templates/admin/extend_trial_form.html
@@ -0,0 +1,28 @@
+{% extends "admin/base_site.html" %}
+
+{% block content %}
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/codecov-api/codecov_auth/tests/__init__.py b/apps/codecov-api/codecov_auth/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/tests/factories.py b/apps/codecov-api/codecov_auth/tests/factories.py
new file mode 100644
index 0000000000..5e4f6efb03
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/factories.py
@@ -0,0 +1,13 @@
+import factory
+from django.utils import timezone
+from factory.django import DjangoModelFactory
+
+from codecov_auth.models import DjangoSession
+
+
+class DjangoSessionFactory(DjangoModelFactory):
+ class Meta:
+ model = DjangoSession
+
+ expire_date = timezone.now()
+ session_key = factory.Faker("uuid4")
diff --git a/apps/codecov-api/codecov_auth/tests/test_admin.py b/apps/codecov-api/codecov_auth/tests/test_admin.py
new file mode 100644
index 0000000000..6b36c50027
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/test_admin.py
@@ -0,0 +1,1051 @@
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+import pytest
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.contrib.admin.sites import AdminSite
+from django.test import RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+from shared.django_apps.codecov_auth.models import (
+ Account,
+ AccountsUsers,
+ InvoiceBilling,
+ StripeBilling,
+)
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ InvoiceBillingFactory,
+ OrganizationLevelTokenFactory,
+ OwnerFactory,
+ PlanFactory,
+ SentryUserFactory,
+ SessionFactory,
+ StripeBillingFactory,
+ TierFactory,
+ UserFactory,
+)
+from shared.django_apps.core.tests.factories import PullFactory, RepositoryFactory
+from shared.plan.constants import (
+ DEFAULT_FREE_PLAN,
+ PlanName,
+)
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov.commands.exceptions import ValidationError
+from codecov_auth.admin import (
+ AccountAdmin,
+ InvoiceBillingAdmin,
+ OrgUploadTokenInline,
+ OwnerAdmin,
+ StripeBillingAdmin,
+ UserAdmin,
+ find_and_remove_stale_users,
+)
+from codecov_auth.models import (
+ OrganizationLevelToken,
+ Owner,
+ Plan,
+ SentryUser,
+ Tier,
+ User,
+)
+from core.models import Pull
+
+
+class OwnerAdminTest(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(OrganizationLevelToken)
+ self.owner_admin = OwnerAdmin(Owner, admin_site)
+
+ def test_owner_admin_detail_page(self):
+ owner = OwnerFactory()
+ response = self.client.get(
+ reverse("admin:codecov_auth_owner_change", args=[owner.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_owner_admin_impersonate_owner(self):
+ owner_to_impersonate = OwnerFactory(service="bitbucket", plan=DEFAULT_FREE_PLAN)
+ other_owner = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+
+ with self.subTest("more than one user selected"):
+ response = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "impersonate_owner",
+ ACTION_CHECKBOX_NAME: [
+ owner_to_impersonate.pk,
+ other_owner.pk,
+ ],
+ },
+ follow=True,
+ )
+ self.assertIn(
+ "You must impersonate exactly one Owner.", str(response.content)
+ )
+
+ with self.subTest("one user selected"):
+ response = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "impersonate_owner",
+ ACTION_CHECKBOX_NAME: [owner_to_impersonate.pk],
+ },
+ )
+ self.assertIn("/bb/", response.url)
+ self.assertEqual(
+ response.cookies.get("staff_user").value,
+ str(owner_to_impersonate.pk),
+ )
+
+ @patch("codecov_auth.admin.TaskService.delete_owner")
+ def test_delete_queryset(self, delete_mock):
+ user_to_delete = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ ownerid = user_to_delete.ownerid
+ queryset = MagicMock()
+ queryset.__iter__.return_value = [user_to_delete]
+
+ self.owner_admin.delete_queryset(MagicMock(), queryset)
+
+ delete_mock.assert_called_once_with(ownerid=ownerid)
+
+ @patch("codecov_auth.admin.TaskService.delete_owner")
+ def test_delete_model(self, delete_mock):
+ user_to_delete = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ ownerid = user_to_delete.ownerid
+ self.owner_admin.delete_model(MagicMock(), user_to_delete)
+ delete_mock.assert_called_once_with(ownerid=ownerid)
+
+ @patch("codecov_auth.admin.admin.ModelAdmin.get_deleted_objects")
+ def test_confirmation_deleted_objects(self, mocked_deleted_objs):
+ user_to_delete = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ deleted_objs = [
+ 'Owner: {};'.format(
+ user_to_delete.ownerid, user_to_delete
+ )
+ ]
+ mocked_deleted_objs.return_value = deleted_objs, {"owners": 1}, set(), []
+
+ (
+ deleted_objects,
+ model_count,
+ perms_needed,
+ protected,
+ ) = self.owner_admin.get_deleted_objects([user_to_delete], MagicMock())
+
+ mocked_deleted_objs.assert_called_once()
+ assert deleted_objects == ()
+
+ @patch("codecov_auth.admin.admin.ModelAdmin.log_change")
+ def test_prev_and_new_values_in_log_entry(self, mocked_super_log_change):
+ owner = OwnerFactory(staff=True, plan=DEFAULT_FREE_PLAN)
+ owner.save()
+ owner.staff = False
+ form = MagicMock()
+ form.changed_data = ["staff"]
+ self.owner_admin.save_model(
+ request=MagicMock, new_obj=owner, form=form, change=True
+ )
+ assert owner.changed_fields["staff"] == "prev value: True, new value: False"
+
+ message = []
+ message.append({"changed": {"fields": ["staff"]}})
+ self.owner_admin.log_change(MagicMock, owner, message)
+ mocked_super_log_change.assert_called_once()
+ assert message == [
+ {"changed": {"fields": ["staff"]}},
+ {"staff": "prev value: True, new value: False"},
+ ]
+
+ def test_inline_orgwide_tokens_display(self):
+ owner = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ request_url = reverse("admin:codecov_auth_owner_change", args=[owner.ownerid])
+ request = RequestFactory().get(request_url)
+ request.user = self.staff_user
+ inlines = self.owner_admin.get_inline_instances(request, owner)
+ # Orgs in enterprise cloud have a token created automagically
+ assert isinstance(inlines[0], OrgUploadTokenInline)
+
+ def test_inline_orgwide_permissions(self):
+ owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value)
+ org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan)
+ owner_in_cloud_plan.save()
+ org_token.save()
+ request_url = reverse(
+ "admin:codecov_auth_owner_change", args=[owner_in_cloud_plan.ownerid]
+ )
+ request = RequestFactory().get(request_url)
+ request.user = self.staff_user
+ inlines = self.owner_admin.get_inline_instances(request, owner_in_cloud_plan)
+ inline_instance = inlines[0]
+ assert (
+ inline_instance.has_add_permission(request, owner_in_cloud_plan) == False
+ ) # Should be false because it already has a token
+ assert (
+ inline_instance.has_delete_permission(request, owner_in_cloud_plan) == True
+ )
+ assert (
+ inline_instance.has_change_permission(request, owner_in_cloud_plan) == False
+ )
+
+ def test_inline_orgwide_add_token_permission_no_token_and_user_in_enterprise_cloud_plan(
+ self,
+ ):
+ owner = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ assert OrganizationLevelToken.objects.filter(owner=owner).count() == 0
+ request_url = reverse("admin:codecov_auth_owner_change", args=[owner.ownerid])
+ request = RequestFactory().get(request_url)
+ request.user = self.staff_user
+ inlines = self.owner_admin.get_inline_instances(request, owner)
+ inline_instance = inlines[0]
+ assert inline_instance.has_add_permission(request, owner) == True
+
+ def test_inline_orgwide_add_token_permission_no_token_user_not_in_enterprise_cloud_plan(
+ self,
+ ):
+ owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value)
+ assert (
+ OrganizationLevelToken.objects.filter(owner=owner_in_cloud_plan).count()
+ == 0
+ )
+ request_url = reverse(
+ "admin:codecov_auth_owner_change", args=[owner_in_cloud_plan.ownerid]
+ )
+ request = RequestFactory().get(request_url)
+ request.user = self.staff_user
+ inlines = self.owner_admin.get_inline_instances(request, owner_in_cloud_plan)
+ inline_instance = inlines[0]
+ assert inline_instance.has_add_permission(request, owner_in_cloud_plan) == True
+
+ @patch(
+ "codecov_auth.services.org_level_token_service.OrgLevelTokenService.refresh_token"
+ )
+ def test_org_token_refresh_request_calls_service_to_refresh_token(
+ self, mock_refresh
+ ):
+ owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value)
+ org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan)
+ owner_in_cloud_plan.save()
+ org_token.save()
+ request_url = reverse(
+ "admin:codecov_auth_owner_change", args=[owner_in_cloud_plan.ownerid]
+ )
+ fake_data = {
+ "staff": ["true"],
+ "plan": ["users-enterprisem"],
+ "plan_provider": [""],
+ "plan_user_count": ["5"],
+ "plan_activated_users": [""],
+ "integration_id": [""],
+ "bot": [""],
+ "stripe_customer_id": [""],
+ "stripe_subscription_id": [""],
+ "organizations": [""],
+ "organization_tokens-TOTAL_FORMS": ["1"],
+ "organization_tokens-INITIAL_FORMS": ["0"],
+ "organization_tokens-MIN_NUM_FORMS": ["0"],
+ "organization_tokens-MAX_NUM_FORMS": ["1"],
+ "organization_tokens-0-id": [str(org_token.id)],
+ "organization_tokens-0-owner": [owner_in_cloud_plan.ownerid],
+ "organization_tokens-0-valid_until_0": ["2023-08-08"],
+ "organization_tokens-0-valid_until_1": ["17:01:14"],
+ "organization_tokens-0-token_type": ["upload"],
+ "organization_tokens-0-REFRESH": "on",
+ "_continue": ["Save and continue editing"],
+ }
+ self.client.post(request_url, data=fake_data)
+ mock_refresh.assert_called_with(str(org_token.id))
+
+ @patch(
+ "codecov_auth.services.org_level_token_service.OrgLevelTokenService.refresh_token"
+ )
+ def test_org_token_request_doesnt_call_service_to_refresh_token(self, mock_refresh):
+ owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value)
+ org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan)
+ owner_in_cloud_plan.save()
+ org_token.save()
+ request_url = reverse(
+ "admin:codecov_auth_owner_change", args=[owner_in_cloud_plan.ownerid]
+ )
+ fake_data = {
+ "staff": ["true"],
+ "plan": ["users-enterprisem"],
+ "plan_provider": [""],
+ "plan_user_count": ["5"],
+ "plan_activated_users": [""],
+ "integration_id": [""],
+ "bot": [""],
+ "stripe_customer_id": [""],
+ "stripe_subscription_id": [""],
+ "organizations": [""],
+ "organization_tokens-TOTAL_FORMS": ["1"],
+ "organization_tokens-INITIAL_FORMS": ["0"],
+ "organization_tokens-MIN_NUM_FORMS": ["0"],
+ "organization_tokens-MAX_NUM_FORMS": ["1"],
+ "organization_tokens-0-id": [str(org_token.id)],
+ "organization_tokens-0-owner": [owner_in_cloud_plan.ownerid],
+ "organization_tokens-0-valid_until_0": ["2023-08-08"],
+ "organization_tokens-0-valid_until_1": ["17:01:14"],
+ "organization_tokens-0-token_type": ["upload"],
+ "_continue": ["Save and continue editing"],
+ }
+ self.client.post(request_url, data=fake_data)
+ mock_refresh.assert_not_called()
+
+ def test_start_trial_ui_display(self):
+ owner = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "extend_trial",
+ ACTION_CHECKBOX_NAME: [owner.pk],
+ },
+ )
+ assert res.status_code == 200
+ assert "Extending trial for:" in str(res.content)
+
+ @patch("shared.plan.service.PlanService.start_trial_manually")
+ def test_start_trial_action(self, mock_start_trial_service):
+ mock_start_trial_service.return_value = None
+ org_to_be_trialed = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "extend_trial",
+ ACTION_CHECKBOX_NAME: [org_to_be_trialed.pk],
+ "end_date": "2024-01-01 01:02:03",
+ "extend_trial": True,
+ },
+ )
+ assert res.status_code == 302
+ assert mock_start_trial_service.called
+
+ @patch("shared.plan.service.PlanService._start_trial_helper")
+ def test_extend_trial_action(self, mock_start_trial_service):
+ mock_start_trial_service.return_value = None
+ org_to_be_trialed = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+ org_to_be_trialed.plan = PlanName.TRIAL_PLAN_NAME.value
+ org_to_be_trialed.save()
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "extend_trial",
+ ACTION_CHECKBOX_NAME: [org_to_be_trialed.pk],
+ "end_date": "2024-01-01 01:02:03",
+ "extend_trial": True,
+ },
+ )
+ assert res.status_code == 302
+ assert mock_start_trial_service.called
+ assert mock_start_trial_service.call_args.kwargs == {"is_extension": True}
+
+ @patch("shared.plan.service.PlanService.start_trial_manually")
+ def test_start_trial_paid_plan(self, mock_start_trial_service):
+ mock_start_trial_service.side_effect = ValidationError(
+ "Cannot trial from a paid plan"
+ )
+
+ org_to_be_trialed = OwnerFactory(plan=DEFAULT_FREE_PLAN)
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_owner_changelist"),
+ {
+ "action": "extend_trial",
+ ACTION_CHECKBOX_NAME: [org_to_be_trialed.pk],
+ "end_date": "2024-01-01 01:02:03",
+ "extend_trial": True,
+ },
+ )
+ assert res.status_code == 302
+ assert mock_start_trial_service.called
+
+ def test_account_widget(self):
+ owner = OwnerFactory(
+ user=UserFactory(), plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ rf = RequestFactory()
+ get_request = rf.get(f"/admin/codecov_auth/owner/{owner.ownerid}/change/")
+ get_request.user = self.staff_user
+ sample_input = {
+ "change": True,
+ "fields": ["account", "plan", "uses_invoice", "staff"],
+ }
+ form = self.owner_admin.get_form(request=get_request, obj=owner, **sample_input)
+ # admin user cannot create, edit, or delete Account objects from the OwnerAdmin
+ self.assertFalse(form.base_fields["account"].widget.can_add_related)
+ self.assertFalse(form.base_fields["account"].widget.can_change_related)
+ self.assertFalse(form.base_fields["account"].widget.can_delete_related)
+
+
+class UserAdminTest(TestCase):
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(User)
+ self.owner_admin = UserAdmin(User, admin_site)
+
+ def test_user_admin_list_page(self):
+ user = UserFactory()
+ res = self.client.get(reverse("admin:codecov_auth_user_changelist"))
+ assert res.status_code == 200
+ assert user.name in res.content.decode("utf-8")
+ assert user.email in res.content.decode("utf-8")
+
+ def test_user_admin_detail_page(self):
+ user = UserFactory()
+ res = self.client.get(reverse("admin:codecov_auth_user_change", args=[user.pk]))
+ assert res.status_code == 200
+ assert user.name in res.content.decode("utf-8")
+ assert user.email in res.content.decode("utf-8")
+ assert str(user.external_id) in res.content.decode("utf-8")
+
+
+class SentryUserAdminTest(TestCase):
+ def setUp(self) -> None:
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(User)
+ admin_site.register(SentryUser)
+ self.owner_admin = UserAdmin(User, admin_site)
+
+ def test_user_admin_list_page(self):
+ sentry_user = SentryUserFactory()
+ res = self.client.get(reverse("admin:codecov_auth_sentryuser_changelist"))
+ assert res.status_code == 200
+ res.content.decode("utf-8")
+ assert sentry_user.name in res.content.decode("utf-8")
+ assert sentry_user.email in res.content.decode("utf-8")
+
+ def test_user_admin_detail_page(self):
+ sentry_user = SentryUserFactory()
+ res = self.client.get(
+ reverse("admin:codecov_auth_sentryuser_change", args=[sentry_user.pk])
+ )
+ assert res.status_code == 200
+ assert sentry_user.name in res.content.decode("utf-8")
+ assert sentry_user.email in res.content.decode("utf-8")
+ assert sentry_user.access_token not in res.content.decode("utf-8")
+ assert sentry_user.refresh_token not in res.content.decode("utf-8")
+
+
+def create_stale_users(
+ account: Account | None = None,
+) -> tuple[list[Owner], list[Owner]]:
+ org_1 = OwnerFactory(account=account)
+ org_2 = OwnerFactory(account=account)
+
+ now = timezone.now()
+
+ user_1 = OwnerFactory(user=UserFactory()) # stale, neither session nor PR
+
+ user_2 = OwnerFactory(user=UserFactory()) # semi-stale, semi-old session
+ SessionFactory(owner=user_2, lastseen=now - timedelta(days=45))
+
+ user_3 = OwnerFactory(user=UserFactory()) # stale, old session
+ SessionFactory(owner=user_3, lastseen=now - timedelta(days=120))
+
+ user_4 = OwnerFactory(user=UserFactory()) # semi-stale, semi-old PR
+ pull = PullFactory(
+ repository=RepositoryFactory(),
+ author=user_4,
+ )
+ pull.updatestamp = now - timedelta(days=45)
+ super(Pull, pull).save() # `Pull` overrides the `updatestamp` on each save
+
+ user_5 = OwnerFactory(user=UserFactory()) # stale, old PR
+ pull = PullFactory(
+ repository=RepositoryFactory(),
+ author=user_5,
+ )
+ pull.updatestamp = now - timedelta(days=120)
+ super(Pull, pull).save() # `Pull` overrides the `updatestamp` on each save
+
+ org_1.plan_activated_users = [
+ user_1.ownerid,
+ user_2.ownerid,
+ ]
+ org_1.save()
+
+ org_2.plan_activated_users = [
+ user_3.ownerid,
+ user_4.ownerid,
+ user_5.ownerid,
+ ]
+ org_2.save()
+
+ return ([org_1, org_2], [user_1, user_2, user_3, user_4, user_5])
+
+
+@pytest.mark.django_db()
+def test_stale_user_cleanup():
+ orgs, users = create_stale_users()
+
+ # remove stale users with default > 90 days
+ removed_users, affected_orgs = find_and_remove_stale_users(orgs)
+ assert removed_users == {users[0].ownerid, users[2].ownerid, users[4].ownerid}
+ assert affected_orgs == {orgs[0].ownerid, orgs[1].ownerid}
+
+ orgs = list(
+ Owner.objects.filter(ownerid__in=[org.ownerid for org in orgs])
+ ) # re-fetch orgs
+ # all good, nothing to do
+ removed_users, affected_orgs = find_and_remove_stale_users(orgs)
+ assert removed_users == set()
+ assert affected_orgs == set()
+
+ # remove even more stale users
+ removed_users, affected_orgs = find_and_remove_stale_users(orgs, timedelta(days=30))
+ assert removed_users == {users[1].ownerid, users[3].ownerid}
+ assert affected_orgs == {orgs[0].ownerid, orgs[1].ownerid}
+
+ orgs = list(
+ Owner.objects.filter(ownerid__in=[org.ownerid for org in orgs])
+ ) # re-fetch orgs
+ # all the users have been deactivated by now
+ for org in orgs:
+ assert len(org.plan_activated_users) == 0
+
+
+class AccountAdminTest(TestCase):
+ def setUp(self):
+ staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=staff_user)
+ admin_site = AdminSite()
+ admin_site.register(Account)
+ admin_site.register(StripeBilling)
+ admin_site.register(InvoiceBilling)
+ admin_site.register(AccountsUsers)
+ self.account_admin = AccountAdmin(Account, admin_site)
+
+ self.account = AccountFactory(plan_seat_count=4, free_seat_count=2)
+ self.org_1 = OwnerFactory(account=self.account)
+ self.org_2 = OwnerFactory(account=self.account)
+ self.owner_with_user_1 = OwnerFactory(user=UserFactory())
+ self.owner_with_user_2 = OwnerFactory(user=UserFactory())
+ self.owner_with_user_3 = OwnerFactory(user=UserFactory())
+ self.owner_without_user_1 = OwnerFactory(user=None)
+ self.owner_without_user_2 = OwnerFactory(user=None)
+ self.student = OwnerFactory(user=UserFactory(), student=True)
+ self.org_1.plan_activated_users = [
+ self.owner_with_user_2.ownerid,
+ self.owner_with_user_3.ownerid,
+ self.owner_without_user_1.ownerid,
+ self.student.ownerid,
+ self.owner_without_user_2.ownerid,
+ ]
+ self.org_2.plan_activated_users = [
+ self.owner_with_user_2.ownerid,
+ self.owner_with_user_3.ownerid,
+ self.owner_without_user_1.ownerid,
+ self.student.ownerid,
+ self.owner_with_user_1.ownerid,
+ ]
+ self.org_1.save()
+ self.org_2.save()
+
+ def test_list_page(self):
+ res = self.client.get(reverse("admin:codecov_auth_account_changelist"))
+ self.assertEqual(res.status_code, 200)
+ decoded_res = res.content.decode("utf-8")
+ self.assertIn("column-name", decoded_res)
+ self.assertIn("column-is_active", decoded_res)
+ self.assertIn(
+ '', decoded_res
+ )
+ self.assertIn(
+ '',
+ decoded_res,
+ )
+
+ def test_detail_page(self):
+ res = self.client.get(
+ reverse("admin:codecov_auth_account_change", args=[self.account.pk])
+ )
+ self.assertEqual(res.status_code, 200)
+ decoded_res = res.content.decode("utf-8")
+ self.assertIn(
+ '',
+ decoded_res,
+ )
+ self.assertIn("Organizations (read only)", decoded_res)
+ self.assertIn("Stripe Billing (click save to commit changes)", decoded_res)
+ self.assertIn("Invoice Billing (click save to commit changes)", decoded_res)
+
+ def test_link_users_to_account(self):
+ self.assertEqual(AccountsUsers.objects.all().count(), 0)
+ self.assertEqual(self.account.accountsusers_set.all().count(), 0)
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "link_users_to_account",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(messages[0].message, "Created a User for 2 Owners")
+ self.assertEqual(
+ messages[1].message, "Created 6 AccountsUsers, removed 0 AccountsUsers"
+ )
+
+ self.assertEqual(AccountsUsers.objects.all().count(), 6)
+ self.assertEqual(
+ AccountsUsers.objects.filter(account_id=self.account.id).count(), 6
+ )
+
+ for org in [self.org_1, self.org_2]:
+ for active_owner_id in org.plan_activated_users:
+ owner_obj = Owner.objects.get(pk=active_owner_id)
+ self.assertTrue(
+ AccountsUsers.objects.filter(
+ account=self.account, user_id=owner_obj.user_id
+ ).exists()
+ )
+
+ # another user joins
+ another_owner_with_user = OwnerFactory(user=UserFactory())
+ self.org_1.plan_activated_users.append(another_owner_with_user.ownerid)
+ self.org_1.save()
+ # rerun action to re-sync
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "link_users_to_account",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(messages[2].message, "Created a User for 0 Owners")
+ self.assertEqual(
+ messages[3].message, "Created 1 AccountsUsers, removed 0 AccountsUsers"
+ )
+
+ self.assertEqual(AccountsUsers.objects.all().count(), 7)
+ self.assertEqual(
+ AccountsUsers.objects.filter(account_id=self.account.id).count(), 7
+ )
+ self.assertIn(
+ another_owner_with_user.user_id,
+ self.account.accountsusers_set.all().values_list("user_id", flat=True),
+ )
+
+ def test_link_users_to_account_not_enough_seats(self):
+ self.assertEqual(AccountsUsers.objects.all().count(), 0)
+ self.account.plan_seat_count = 1
+ self.account.save()
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "link_users_to_account",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(
+ messages[0].message,
+ "Request failed: Account plan does not have enough seats; current plan activated users (non-students): 5, total seats for account: 3",
+ )
+ self.assertEqual(AccountsUsers.objects.all().count(), 0)
+
+ def test_seat_check(self):
+ # edge case: User has multiple Owners, one of which is a Student, but should still count as 1 seat on this Account
+ user = self.owner_with_user_1.user
+ OwnerFactory(
+ service="gitlab", user=user, student=False
+ ) # another owner on this user
+ OwnerFactory(
+ service="bitbucket", user=user, student=True
+ ) # student owner on this user
+
+ self.assertEqual(AccountsUsers.objects.all().count(), 0)
+ self.account.plan_seat_count = 1
+ self.account.save()
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "seat_check",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(
+ messages[0].message,
+ "Request failed: Account plan does not have enough seats; current plan activated users (non-students): 5, total seats for account: 3",
+ )
+
+ self.account.plan_seat_count = 10
+ self.account.save()
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "seat_check",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(
+ messages[1].message,
+ "Request succeeded: Account plan has enough seats! current plan activated users (non-students): 5, total seats for account: 12",
+ )
+ self.assertEqual(AccountsUsers.objects.all().count(), 0)
+
+ def test_link_users_to_account_remove_unneeded_account_users(self):
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "link_users_to_account",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(messages[0].message, "Created a User for 2 Owners")
+ self.assertEqual(
+ messages[1].message, "Created 6 AccountsUsers, removed 0 AccountsUsers"
+ )
+
+ self.assertEqual(AccountsUsers.objects.all().count(), 6)
+ self.assertEqual(
+ AccountsUsers.objects.filter(account_id=self.account.id).count(), 6
+ )
+
+ for org in [self.org_1, self.org_2]:
+ for active_owner_id in org.plan_activated_users:
+ owner_obj = Owner.objects.get(pk=active_owner_id)
+ self.assertTrue(
+ AccountsUsers.objects.filter(
+ account=self.account, user_id=owner_obj.user_id
+ ).exists()
+ )
+
+ # disconnect one of the orgs
+ self.org_2.account = None
+ self.org_2.save()
+
+ # re-sync to remove Account users from org 2 that are not connected to other account orgs (just owner_with_user_1)
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "link_users_to_account",
+ ACTION_CHECKBOX_NAME: [self.account.pk],
+ },
+ )
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(res.url, "/admin/codecov_auth/account/")
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(messages[2].message, "Created a User for 0 Owners")
+ self.assertEqual(
+ messages[3].message, "Created 0 AccountsUsers, removed 1 AccountsUsers"
+ )
+
+ self.assertEqual(AccountsUsers.objects.all().count(), 5)
+ self.assertEqual(
+ AccountsUsers.objects.filter(account_id=self.account.id).count(), 5
+ )
+ still_connected = [
+ self.owner_with_user_2,
+ self.owner_with_user_3,
+ self.owner_without_user_1,
+ self.owner_without_user_2,
+ self.student,
+ ]
+ for owner in still_connected:
+ owner.refresh_from_db()
+ self.assertTrue(
+ AccountsUsers.objects.filter(
+ account=self.account, user_id=owner.user_id
+ ).exists()
+ )
+
+ self.owner_with_user_1.refresh_from_db() # removed user
+ # no longer connected to account
+ self.assertFalse(
+ AccountsUsers.objects.filter(
+ account=self.account, user_id=self.owner_with_user_1.user_id
+ ).exists()
+ )
+ # still connected to org
+ self.assertIn(
+ self.owner_with_user_1.ownerid,
+ Owner.objects.get(pk=self.org_2.pk).plan_activated_users,
+ )
+ # user object still exists, with no account connections
+ self.assertIsNotNone(self.owner_with_user_1.user_id)
+ self.assertFalse(
+ AccountsUsers.objects.filter(user=self.owner_with_user_1.user).exists()
+ )
+
+ def test_deactivate_stale_users(self):
+ account = AccountFactory()
+ orgs, users = create_stale_users(account)
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "deactivate_stale_users",
+ ACTION_CHECKBOX_NAME: [account.pk],
+ },
+ )
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(
+ messages[-1].message, "Removed 3 stale users from 2 affected organizations."
+ )
+
+ res = self.client.post(
+ reverse("admin:codecov_auth_account_changelist"),
+ {
+ "action": "deactivate_stale_users",
+ ACTION_CHECKBOX_NAME: [account.pk],
+ },
+ )
+ messages = list(res.wsgi_request._messages)
+ self.assertEqual(
+ messages[-1].message,
+ "No stale users found in selected accounts / organizations.",
+ )
+
+
+class StripeBillingAdminTest(TestCase):
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(StripeBilling)
+ self.stripe_admin = StripeBillingAdmin(StripeBilling, admin_site)
+ self.account = AccountFactory()
+ self.obj = StripeBillingFactory(account=self.account)
+
+ def test_account_widget(self):
+ rf = RequestFactory()
+ get_request = rf.get(f"/admin/codecov_auth/stripebilling/{self.obj.id}/change/")
+ sample_input = {
+ "change": True,
+ "fields": [
+ "id",
+ "created_at",
+ "updated_at",
+ "account",
+ "customer_id",
+ "subscription_id",
+ "is_active",
+ ],
+ }
+ form = self.stripe_admin.get_form(
+ request=get_request, obj=self.obj, **sample_input
+ )
+ # admin user cannot create, edit, or delete Account objects from the StripeBillingAdmin
+ self.assertFalse(form.base_fields["account"].widget.can_add_related)
+ self.assertFalse(form.base_fields["account"].widget.can_change_related)
+ self.assertFalse(form.base_fields["account"].widget.can_delete_related)
+
+
+class InvoiceBillingAdminTest(TestCase):
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(InvoiceBilling)
+ self.invoice_admin = InvoiceBillingAdmin(InvoiceBilling, admin_site)
+ self.account = AccountFactory()
+ self.obj = InvoiceBillingFactory(account=self.account)
+
+ def test_account_widget(self):
+ rf = RequestFactory()
+ get_request = rf.get(
+ f"/admin/codecov_auth/invoicebilling/{self.obj.id}/change/"
+ )
+ sample_input = {
+ "change": True,
+ "fields": [
+ "id",
+ "created_at",
+ "updated_at",
+ "account",
+ "account_manager",
+ "invoice_notes",
+ "is_active",
+ ],
+ }
+ form = self.invoice_admin.get_form(
+ request=get_request, obj=self.obj, **sample_input
+ )
+ # admin user cannot create, edit, or delete Account objects from the InvoiceBillingAdmin
+ self.assertFalse(form.base_fields["account"].widget.can_add_related)
+ self.assertFalse(form.base_fields["account"].widget.can_change_related)
+ self.assertFalse(form.base_fields["account"].widget.can_delete_related)
+
+
+class PlanAdminTest(TestCase):
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(Plan)
+
+ self.tier = TierFactory()
+ self.plan = PlanFactory(name=DEFAULT_FREE_PLAN, tier=self.tier)
+
+ def test_plan_admin_modal_display(self):
+ response = self.client.get(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.plan.name)
+
+ def test_plan_modal_tiers_display(self):
+ response = self.client.get(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.tier.tier_name)
+
+ def test_add_plans_modal_action(self):
+ data = {
+ "action": "add_plans",
+ ACTION_CHECKBOX_NAME: [self.plan.pk],
+ "tier_id": self.tier.pk,
+ }
+ response = self.client.post(
+ reverse("admin:codecov_auth_plan_changelist"), data=data
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/codecov_auth/plan/")
+
+ def test_plan_change_form(self):
+ response = self.client.get(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+ for field in [
+ "tier",
+ "name",
+ "marketing_name",
+ "base_unit_price",
+ "benefits",
+ "billing_rate",
+ "is_active",
+ "max_seats",
+ "monthly_uploads_limit",
+ "paid_plan",
+ ]:
+ self.assertContains(response, f"id_{field}")
+
+ def test_plan_change_form_validation(self):
+ self.plan.base_unit_price = -10
+ self.plan.save()
+
+ response = self.client.post(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]),
+ {
+ "tier": self.tier.pk,
+ "name": self.plan.name,
+ "marketing_name": self.plan.marketing_name,
+ "base_unit_price": -10,
+ "benefits": self.plan.benefits,
+ "is_active": self.plan.is_active,
+ "paid_plan": self.plan.paid_plan,
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Base unit price cannot be negative.")
+
+ response = self.client.post(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]),
+ {
+ "tier": self.tier.pk,
+ "name": self.plan.name,
+ "marketing_name": self.plan.marketing_name,
+ "base_unit_price": self.plan.base_unit_price,
+ "benefits": self.plan.benefits,
+ "is_active": self.plan.is_active,
+ "max_seats": -5,
+ "paid_plan": self.plan.paid_plan,
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Max seats cannot be negative.")
+
+ response = self.client.post(
+ reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]),
+ {
+ "tier": self.tier.pk,
+ "name": self.plan.name,
+ "marketing_name": self.plan.marketing_name,
+ "benefits": self.plan.benefits,
+ "is_active": self.plan.is_active,
+ "monthly_uploads_limit": -5,
+ "paid_plan": self.plan.paid_plan,
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Monthly uploads limit cannot be negative.")
+
+
+class TierAdminTest(TestCase):
+ def setUp(self):
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+ admin_site = AdminSite()
+ admin_site.register(Tier)
+
+ self.tier = TierFactory()
+ self.plan = PlanFactory(name=DEFAULT_FREE_PLAN, tier=self.tier)
+
+ def test_tier_modal_plans_display(self):
+ response = self.client.get(
+ reverse("admin:codecov_auth_tier_change", args=[self.tier.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.tier.tier_name)
+
+ def test_add_plans_modal_action(self):
+ data = {
+ "action": "add_plans",
+ ACTION_CHECKBOX_NAME: [self.plan.pk],
+ "tier_id": self.tier.pk,
+ }
+ response = self.client.post(
+ reverse("admin:codecov_auth_tier_changelist"), data=data
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/codecov_auth/tier/")
+
+ def test_tier_change_form(self):
+ response = self.client.get(
+ reverse("admin:codecov_auth_tier_change", args=[self.tier.pk])
+ )
+ self.assertEqual(response.status_code, 200)
+ for field in [
+ "tier_name",
+ "bundle_analysis",
+ "test_analytics",
+ "flaky_test_detection",
+ "project_coverage",
+ "private_repo_support",
+ ]:
+ self.assertContains(response, f"id_{field}")
diff --git a/apps/codecov-api/codecov_auth/tests/test_migrations.py b/apps/codecov-api/codecov_auth/tests/test_migrations.py
new file mode 100644
index 0000000000..adeebbaf2a
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/test_migrations.py
@@ -0,0 +1,77 @@
+import pytest
+
+from utils.test_utils import TestMigrations
+
+
+class Migration0046Test(TestMigrations):
+ migrate_from = "0045_remove_ownerprofile_terms_agreement"
+ migrate_to = "0046_dedupe_owner_admin_values"
+
+ def setUpBeforeMigration(self, apps):
+ owners = apps.get_model("codecov_auth", "Owner")
+
+ self.owner_no_dupes = owners.objects.create(
+ ownerid=1,
+ service_id=1,
+ service="github",
+ admins=[1, 2, 3],
+ )
+
+ self.owner_null_admins = owners.objects.create(
+ ownerid=2,
+ service_id=2,
+ service="github",
+ admins=None,
+ )
+
+ self.owner_no_admins = owners.objects.create(
+ ownerid=3,
+ service_id=3,
+ service="github",
+ admins=[],
+ )
+
+ self.owner_one_dupe = owners.objects.create(
+ ownerid=4,
+ service_id=4,
+ service="github",
+ admins=[1, 1],
+ )
+
+ self.owner_multi_dupe = owners.objects.create(
+ ownerid=5,
+ service_id=5,
+ service="github",
+ admins=[1, 1, 2, 3, 3, 3, 4],
+ )
+
+ self.owner_multi_dupe_ordering = owners.objects.create(
+ ownerid=6,
+ service_id=6,
+ service="github",
+ admins=[3, 2, 1, 2, 3],
+ )
+
+ @pytest.mark.skip(
+ reason="move to shared"
+ ) # TODO move this test to live with auth models in shared
+ def test_admins_deduped(self):
+ owners = self.apps.get_model("codecov_auth", "Owner")
+
+ owner = owners.objects.get(ownerid=self.owner_no_dupes.ownerid)
+ assert owner.admins == [1, 2, 3]
+
+ owner = owners.objects.get(ownerid=self.owner_null_admins.ownerid)
+ assert owner.admins == []
+
+ owner = owners.objects.get(ownerid=self.owner_no_admins.ownerid)
+ assert owner.admins == []
+
+ owner = owners.objects.get(ownerid=self.owner_one_dupe.ownerid)
+ assert owner.admins == [1]
+
+ owner = owners.objects.get(ownerid=self.owner_multi_dupe.ownerid)
+ assert owner.admins == [1, 2, 3, 4]
+
+ owner = owners.objects.get(ownerid=self.owner_multi_dupe_ordering.ownerid)
+ assert owner.admins == [3, 2, 1]
diff --git a/apps/codecov-api/codecov_auth/tests/test_signals.py b/apps/codecov-api/codecov_auth/tests/test_signals.py
new file mode 100644
index 0000000000..639978b453
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/test_signals.py
@@ -0,0 +1,121 @@
+from unittest import mock
+from unittest.mock import call
+
+import pytest
+from django.test import TestCase
+from shared.django_apps.codecov_auth.models import Service
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+ OwnerFactory,
+)
+
+
+@pytest.mark.django_db
+def test_shelter_org_token_sync(mocker):
+ publish = mocker.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+
+ # this triggers the publish via Django signals
+ OrganizationLevelTokenFactory(id=91728376, owner=OwnerFactory(ownerid=111))
+
+ publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 111}',
+ ),
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "org_token", "sync": "one", "id": 91728376}',
+ ),
+ ]
+ )
+
+
+@mock.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+class TestCodecovAuthSignals(TestCase):
+ def test_sync_on_create(self, mock_publish):
+ OwnerFactory(ownerid=12345)
+ mock_publish.assert_called_once_with(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ )
+
+ def test_sync_on_update_upload_token_required_for_public_repos(self, mock_publish):
+ owner = OwnerFactory(ownerid=12345, upload_token_required_for_public_repos=True)
+ owner.upload_token_required_for_public_repos = False
+ owner.save()
+ mock_publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ ]
+ )
+
+ def test_sync_on_update_username(self, mock_publish):
+ owner = OwnerFactory(ownerid=12345, username="hello")
+ owner.username = "world"
+ owner.save()
+ mock_publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ ]
+ )
+
+ def test_sync_on_update_service(self, mock_publish):
+ owner = OwnerFactory(ownerid=12345, service=Service.GITHUB.value)
+ owner.service = Service.BITBUCKET.value
+ owner.save()
+ mock_publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ ),
+ ]
+ )
+
+ def test_no_sync_on_update_other_fields(self, mock_publish):
+ owner = OwnerFactory(ownerid=12345, name="hello")
+ owner.name = "world"
+ owner.save()
+ mock_publish.assert_called_once_with(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ )
+
+ @mock.patch("logging.Logger.warning")
+ def test_sync_error(self, mock_log, mock_publish):
+ mock_publish.side_effect = Exception("publish error")
+
+ OwnerFactory(ownerid=12345)
+
+ # publish is still called, raises an Exception
+ mock_publish.assert_called_once_with(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 12345}',
+ )
+
+ mock_log.assert_called_once_with(
+ "Failed to publish a message",
+ extra=dict(
+ data_to_publish={"type": "owner", "sync": "one", "id": 12345},
+ error=mock_publish.side_effect,
+ ),
+ )
diff --git a/apps/codecov-api/codecov_auth/tests/unit/__init__.py b/apps/codecov-api/codecov_auth/tests/unit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/tests/unit/services/test_org_level_token_service.py b/apps/codecov-api/codecov_auth/tests/unit/services/test_org_level_token_service.py
new file mode 100644
index 0000000000..5c13134f8a
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/services/test_org_level_token_service.py
@@ -0,0 +1,95 @@
+import uuid
+from unittest.mock import patch
+
+import pytest
+from django.forms import ValidationError
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+ OwnerFactory,
+ PlanFactory,
+ TierFactory,
+)
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, TierName
+
+from codecov_auth.models import OrganizationLevelToken
+from codecov_auth.services.org_level_token_service import OrgLevelTokenService
+
+
+@patch(
+ "codecov_auth.services.org_level_token_service.OrgLevelTokenService.org_can_have_upload_token"
+)
+def test_token_is_deleted_when_changing_user_plan(mocked_org_can_have_upload_token, db):
+ # This should happen because of the signal consumer we have defined in
+ # codecov_auth/services/org_upload_token_service.py > manage_org_tokens_if_owner_plan_changed
+ mocked_org_can_have_upload_token.return_value = False
+ enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value)
+ enterprise_plan = PlanFactory(
+ tier=enterprise_tier, name=PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ owner = OwnerFactory(plan=enterprise_plan.name)
+ org_token = OrganizationLevelTokenFactory(owner=owner)
+ owner.save()
+ org_token.save()
+ assert OrganizationLevelToken.objects.filter(owner=owner).count() == 1
+ owner.plan = "users-basic"
+ owner.save()
+ assert OrganizationLevelToken.objects.filter(owner=owner).count() == 0
+
+
+class TestOrgWideUploadTokenService(TestCase):
+ def setUp(self):
+ self.enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value)
+ self.enterprise_plan = PlanFactory(
+ tier=self.enterprise_tier,
+ name=PlanName.ENTERPRISE_CLOUD_YEARLY.value,
+ )
+ self.basic_tier = TierFactory(tier_name=TierName.BASIC.value)
+ self.basic_plan = PlanFactory(
+ tier=self.basic_tier,
+ name=DEFAULT_FREE_PLAN,
+ )
+ self.owner = OwnerFactory(plan=self.enterprise_plan.name)
+
+ def test_get_org_token(self):
+ # Check that if you try to create a token for an org that already has one you get the same token
+ org_token = OrganizationLevelTokenFactory(owner=self.owner)
+ self.owner.save()
+ org_token.save()
+ assert org_token == OrgLevelTokenService.get_or_create_org_token(self.owner)
+
+ def test_create_org_token(self):
+ user_in_enterprise_plan = OwnerFactory(plan=self.enterprise_plan.name)
+ token = OrgLevelTokenService.get_or_create_org_token(user_in_enterprise_plan)
+ assert isinstance(token.token, uuid.UUID)
+ assert token.owner == user_in_enterprise_plan
+ # Check that users not in enterprise plan can create org tokens
+ user_not_in_enterprise_plan = OwnerFactory(plan=self.basic_plan.name)
+ token = OrgLevelTokenService.get_or_create_org_token(
+ user_not_in_enterprise_plan
+ )
+ assert isinstance(token.token, uuid.UUID)
+ assert token.owner == user_not_in_enterprise_plan
+
+ def test_delete_token(self):
+ owner = OwnerFactory(plan=self.enterprise_plan.name)
+ OrgLevelTokenService.delete_org_token_if_exists(owner)
+ with pytest.raises(OrganizationLevelToken.DoesNotExist):
+ OrganizationLevelToken.objects.get(owner=owner)
+
+ def test_refresh_token(self):
+ owner = OwnerFactory(plan=self.enterprise_plan.name)
+ org_token = OrganizationLevelTokenFactory(owner=owner)
+ owner.save()
+ org_token.save()
+ previous_token_obj = OrganizationLevelToken.objects.get(owner=owner)
+ previous_token = previous_token_obj.token
+ OrgLevelTokenService.refresh_token(previous_token_obj.id)
+ refreshed_token_obj = OrganizationLevelToken.objects.get(owner=owner)
+ assert previous_token_obj.id == refreshed_token_obj.id
+ assert previous_token != refreshed_token_obj.token
+
+ def test_refresh_token_error(self):
+ with pytest.raises(ValidationError):
+ # Token that doesn't exist
+ OrgLevelTokenService.refresh_token(1000)
diff --git a/apps/codecov-api/codecov_auth/tests/unit/test_authentication.py b/apps/codecov-api/codecov_auth/tests/unit/test_authentication.py
new file mode 100644
index 0000000000..138238d720
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/test_authentication.py
@@ -0,0 +1,256 @@
+from datetime import datetime, timedelta
+from http.cookies import SimpleCookie
+from unittest.mock import AsyncMock, call, patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.urls import ResolverMatch
+from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
+from rest_framework.test import APIRequestFactory
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ UserFactory,
+ UserTokenFactory,
+)
+from shared.django_apps.core.tests.factories import RepositoryFactory
+
+from codecov_auth.authentication import (
+ InternalTokenAuthentication,
+ SuperTokenAuthentication,
+ UserTokenAuthentication,
+)
+from codecov_auth.authentication.types import (
+ InternalToken,
+ InternalUser,
+ SuperToken,
+ SuperUser,
+)
+
+# Using the standard RequestFactory API to create a form POST request
+
+
+def set_resolver_match(request, kwargs={}):
+ match = ResolverMatch(func=lambda: None, args=(), kwargs=kwargs)
+ request.resolver_match = match
+
+
+class UserTokenAuthenticationTests(TestCase):
+ def test_bearer_token_auth(self):
+ user_token = UserTokenFactory()
+
+ request_factory = APIRequestFactory()
+ request = request_factory.get(
+ "", HTTP_AUTHORIZATION=f"Bearer {user_token.token}"
+ )
+
+ authenticator = UserTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert result == (user_token.owner, user_token)
+
+ def test_bearer_token_auth_invalid_token(self):
+ request_factory = APIRequestFactory()
+ request = request_factory.get(
+ "", HTTP_AUTHORIZATION="Bearer 8f9bc6cb-fd14-43bc-bbb5-be1e7c948f34"
+ )
+
+ authenticator = UserTokenAuthentication()
+ with pytest.raises(AuthenticationFailed):
+ authenticator.authenticate(request)
+
+ def test_token_not_uuid(self):
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION="Bearer hello_world")
+ authenticator = UserTokenAuthentication()
+ with pytest.raises(AuthenticationFailed):
+ authenticator.authenticate(request)
+
+ def test_bearer_token_auth_expired_token(self):
+ user_token = UserTokenFactory(valid_until=datetime.now() - timedelta(seconds=1))
+
+ request_factory = APIRequestFactory()
+ request = request_factory.get(
+ "", HTTP_AUTHORIZATION=f"Bearer {user_token.token}"
+ )
+
+ authenticator = UserTokenAuthentication()
+ with pytest.raises(AuthenticationFailed):
+ authenticator.authenticate(request)
+
+ def test_bearer_token_auth_malformed_header(self):
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION="wrong")
+
+ authenticator = UserTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert result is None
+
+ def test_bearer_token_auth_no_authorization_header(self):
+ request_factory = APIRequestFactory()
+ request = request_factory.get("")
+
+ authenticator = UserTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert result is None
+
+
+class SuperTokenAuthenticationTests(TestCase):
+ @override_settings(SUPER_API_TOKEN="17603a9e-0463-45e1-883e-d649fccf4ae8")
+ def test_bearer_token_auth_if_token_is_super_token(self):
+ super_token = "17603a9e-0463-45e1-883e-d649fccf4ae8"
+
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {super_token}")
+
+ authenticator = SuperTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert isinstance(result[0], SuperUser)
+ assert isinstance(result[1], SuperToken)
+ assert result[1].token == super_token
+
+ @override_settings(SUPER_API_TOKEN="17603a9e-0463-45e1-883e-d649fccf4ae8")
+ def test_bearer_token_auth_invalid_super_token(self):
+ super_token = "0ae68e58-79f8-4341-9531-55aada05a251"
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {super_token}")
+
+ authenticator = SuperTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert result is None
+
+ def test_bearer_token_default_token_envar(self):
+ super_token = "0ae68e58-79f8-4341-9531-55aada05a251"
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {super_token}")
+ authenticator = SuperTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert result is None
+
+ def test_bearer_token_default_token_envar_and_same_string_as_header(self):
+ super_token = settings.SUPER_API_TOKEN
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {super_token}")
+ authenticator = SuperTokenAuthentication()
+ with pytest.raises(
+ AuthenticationFailed,
+ match="Invalid token header. Token string should not contain spaces.",
+ ):
+ authenticator.authenticate(request)
+
+
+class InternalTokenAuthenticationTests(TestCase):
+ @override_settings(CODECOV_INTERNAL_TOKEN="17603a9e-0463-45e1-883e-d649fccf4ae8")
+ def test_bearer_token_auth_if_token_is_internal_token(self):
+ internal_token = "17603a9e-0463-45e1-883e-d649fccf4ae8"
+
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {internal_token}")
+
+ authenticator = InternalTokenAuthentication()
+ result = authenticator.authenticate(request)
+ assert isinstance(result[0], InternalUser)
+ assert isinstance(result[1], InternalToken)
+ assert result[1].token == internal_token
+
+ @override_settings(CODECOV_INTERNAL_TOKEN="17603a9e-0463-45e1-883e-d649fccf4ae8")
+ def test_bearer_token_auth_if_token_is_not_internal_token(self):
+ internal_token = "random_token"
+
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {internal_token}")
+
+ authenticator = InternalTokenAuthentication()
+ with pytest.raises(
+ AuthenticationFailed,
+ match="Invalid token",
+ ):
+ authenticator.authenticate(request)
+
+ def test_bearer_token_default_token_envar_and_same_string_as_header(self):
+ internal_token = settings.CODECOV_INTERNAL_TOKEN
+ request_factory = APIRequestFactory()
+ request = request_factory.get("", HTTP_AUTHORIZATION=f"Bearer {internal_token}")
+ authenticator = InternalTokenAuthentication()
+ with pytest.raises(
+ AuthenticationFailed,
+ match="Invalid token header. Token string should not contain spaces.",
+ ):
+ authenticator.authenticate(request)
+
+
+class ImpersonationTests(TestCase):
+ def setUp(self):
+ self.owner_to_impersonate = OwnerFactory(
+ username="impersonateme", service="github", user=UserFactory(is_staff=False)
+ )
+ self.staff_user = UserFactory(is_staff=True)
+ self.non_staff_user = UserFactory(is_staff=False)
+
+ self.client.cookies = SimpleCookie({"staff_user": self.owner_to_impersonate.pk})
+
+ def test_impersonation(self):
+ self.client.force_login(user=self.staff_user)
+ res = self.client.post(
+ "/graphql/gh",
+ {"query": "{ me { user { username } } }"},
+ content_type="application/json",
+ )
+ assert res.json()["data"]["me"] == {"user": {"username": "impersonateme"}}
+
+ @patch("core.commands.repository.repository.RepositoryCommands.fetch_repository")
+ def test_impersonation_with_okta(
+ self, mock_call_to_fetch_repository, new_callable=AsyncMock
+ ):
+ repo = RepositoryFactory(author=self.owner_to_impersonate, private=True)
+ query_repositories = """{ owner(username: "%s") { repository(name: "%s") { ... on Repository { name } } } }"""
+ query = query_repositories % (repo.author.username, repo.name)
+
+ # not impersonating
+ del self.client.cookies["staff_user"]
+ self.client.force_login(user=self.owner_to_impersonate.user)
+ self.client.post(
+ "/graphql/gh",
+ {"query": query},
+ content_type="application/json",
+ )
+
+ # impersonating, same query
+ self.client.cookies = SimpleCookie({"staff_user": self.owner_to_impersonate.pk})
+ self.client.force_login(user=self.staff_user)
+ self.client.post(
+ "/graphql/gh",
+ {"query": query},
+ content_type="application/json",
+ )
+
+ mock_call_to_fetch_repository.assert_has_calls(
+ [
+ call(
+ self.owner_to_impersonate,
+ repo.name,
+ [],
+ exclude_okta_enforced_repos=True,
+ needs_coverage=False,
+ needs_commits=False,
+ ),
+ call(
+ self.owner_to_impersonate,
+ repo.name,
+ [],
+ exclude_okta_enforced_repos=False,
+ needs_coverage=False,
+ needs_commits=False,
+ ),
+ ]
+ )
+
+ def test_impersonation_non_staff(self):
+ self.client.force_login(user=self.non_staff_user)
+ with pytest.raises(PermissionDenied):
+ self.client.get("/")
+
+ def test_impersonation_invalid_user(self):
+ self.client.cookies = SimpleCookie({"staff_user": 9999})
+ self.client.force_login(user=self.staff_user)
+ with pytest.raises(AuthenticationFailed):
+ self.client.get("/")
diff --git a/apps/codecov-api/codecov_auth/tests/unit/test_helpers.py b/apps/codecov-api/codecov_auth/tests/unit/test_helpers.py
new file mode 100644
index 0000000000..117fd31340
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/test_helpers.py
@@ -0,0 +1,69 @@
+from unittest.mock import patch
+
+import pytest
+from django.contrib.admin.models import LogEntry
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+
+from codecov_auth.helpers import History, current_user_part_of_org
+
+
+@pytest.mark.django_db
+def test_current_user_part_of_org_when_user_not_authenticated():
+ org = OwnerFactory()
+ assert current_user_part_of_org(None, org) is False
+
+
+@pytest.mark.django_db
+def test_current_user_part_of_org_when_user_is_owner():
+ current_user = OwnerFactory()
+ assert current_user_part_of_org(current_user, current_user) is True
+
+
+@pytest.mark.django_db
+def test_current_user_part_of_org_when_user_doesnt_have_org():
+ org = OwnerFactory()
+ current_user = OwnerFactory(organizations=None)
+ current_user.save()
+ assert current_user_part_of_org(current_user, org) is False
+
+
+@pytest.mark.django_db
+def test_current_user_part_of_org_when_user_has_org():
+ org = OwnerFactory()
+ current_user = OwnerFactory(organizations=[org.ownerid])
+ current_user.save()
+ assert current_user_part_of_org(current_user, current_user) is True
+
+
+@pytest.mark.django_db
+@patch("codecov_auth.helpers.format_stack")
+def test_log_entry(mocked_format_stack):
+ mocked_format_stack.return_value = "test"
+ orig_owner = OwnerFactory()
+ impersonated_owner = OwnerFactory()
+ History.log(
+ impersonated_owner,
+ "Impersonation successful",
+ orig_owner.user,
+ add_traceback=True,
+ )
+ log_entries = LogEntry.objects.all()
+ assert (
+ str(log_entries.first())
+ == f"Changed “{str(impersonated_owner)}” — Impersonation successful: test"
+ )
+
+
+@pytest.mark.django_db
+@patch("codecov_auth.helpers.format_stack")
+def test_log_entry_no_object(mocked_format_stack):
+ mocked_format_stack.return_value = "test"
+ orig_owner = OwnerFactory()
+ History.log(
+ None,
+ "Impersonation successful",
+ orig_owner.user,
+ add_traceback=True,
+ )
+ log_entries = LogEntry.objects.all()
+ assert log_entries.first() is None
diff --git a/apps/codecov-api/codecov_auth/tests/unit/test_managers.py b/apps/codecov-api/codecov_auth/tests/unit/test_managers.py
new file mode 100644
index 0000000000..88fe0e8687
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/test_managers.py
@@ -0,0 +1,51 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import Owner
+
+
+class OwnerManagerTests(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+
+ def test_users_of(self):
+ org = OwnerFactory()
+ self.owner.organizations = [org.ownerid]
+ self.owner.save()
+
+ owner_in_org_and_plan_activated_users = OwnerFactory(
+ organizations=[org.ownerid]
+ )
+ owner_only_in_plan_activated_users = OwnerFactory()
+
+ org.plan_activated_users = [
+ owner_in_org_and_plan_activated_users.ownerid,
+ owner_only_in_plan_activated_users.ownerid,
+ ]
+ org.save()
+
+ with self.subTest("returns all users"):
+ users_of = Owner.objects.users_of(owner=org)
+ self.assertCountEqual(
+ [user.ownerid for user in users_of],
+ [
+ self.owner.ownerid,
+ owner_only_in_plan_activated_users.ownerid,
+ owner_in_org_and_plan_activated_users.ownerid,
+ ],
+ )
+
+ with self.subTest("no plan_activated_users"):
+ org.plan_activated_users = []
+ org.save()
+ users_of = Owner.objects.users_of(owner=org)
+ self.assertCountEqual(
+ [user.ownerid for user in users_of],
+ [self.owner.ownerid, owner_in_org_and_plan_activated_users.ownerid],
+ )
+
+ with self.subTest("no users"):
+ self.owner.delete()
+ owner_in_org_and_plan_activated_users.delete()
+ users_of = Owner.objects.users_of(owner=org)
+ self.assertEqual(list(users_of), [])
diff --git a/apps/codecov-api/codecov_auth/tests/unit/test_middleware.py b/apps/codecov-api/codecov_auth/tests/unit/test_middleware.py
new file mode 100644
index 0000000000..edc5b8179a
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/test_middleware.py
@@ -0,0 +1,21 @@
+from django.test import TestCase, override_settings
+
+from utils.test_utils import Client
+
+
+@override_settings(CORS_ALLOWED_ORIGINS=["http://localhost:3000"])
+class MiddlewareTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ def test_whitelisted_origin(self):
+ res = self.client.get("/health", headers={"Origin": "http://localhost:3000"})
+
+ assert res.headers["Access-Control-Allow-Origin"] == "http://localhost:3000"
+ assert res.headers["Access-Control-Allow-Credentials"] == "true"
+
+ def test_non_whitelisted_origin(self):
+ res = self.client.get("/health", headers={"Origin": "http://example.com"})
+
+ assert res.headers["Access-Control-Allow-Origin"] == "http://example.com"
+ assert "Access-Control-Allow-Credentials" not in res.headers
diff --git a/apps/codecov-api/codecov_auth/tests/unit/test_repo_authentication.py b/apps/codecov-api/codecov_auth/tests/unit/test_repo_authentication.py
new file mode 100644
index 0000000000..28d9241fb9
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/test_repo_authentication.py
@@ -0,0 +1,899 @@
+import uuid
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import QuerySet
+from django.test import override_settings
+from django.utils import timezone
+from jwt import PyJWTError
+from rest_framework import exceptions
+from rest_framework.test import APIRequestFactory
+from shared.django_apps.codecov_auth.models import Owner, Service
+from shared.django_apps.core.models import Repository
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyQueryTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ RepositoryTokenAuthentication,
+ TokenlessAuth,
+ TokenlessAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ UploadTokenRequiredGetFromBodyAuthenticationCheck,
+)
+from codecov_auth.models import SERVICE_GITHUB, OrganizationLevelToken, RepositoryToken
+
+
+class TestRepositoryLegacyQueryTokenAuthentication(object):
+ def test_authenticate_unauthenticated(self):
+ request = APIRequestFactory().get("/endpoint")
+ authentication = RepositoryLegacyQueryTokenAuthentication()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_authenticate_non_uuid_token(self):
+ request = APIRequestFactory().get("/endpoint?token=banana")
+ authentication = RepositoryLegacyQueryTokenAuthentication()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_authenticate_uuid_token_no_repo(self, db):
+ request = APIRequestFactory().get(
+ "/endpoint?token=testwabzdowkt4kyti9w0hxa33zetsta"
+ )
+ authentication = RepositoryLegacyQueryTokenAuthentication()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_authenticate_uuid_token_with_repo(self, db):
+ repo = RepositoryFactory.create()
+ other_repo = RepositoryFactory.create()
+ request = APIRequestFactory().get(f"/endpoint?token={repo.upload_token}")
+ authentication = RepositoryLegacyQueryTokenAuthentication()
+ res = authentication.authenticate(request)
+ assert res is not None
+ user, auth = res
+ assert user._repository == repo
+ assert auth.get_repositories() == [repo]
+ assert auth.get_scopes() == ["upload"]
+ assert user.is_authenticated()
+ assert auth.allows_repo(repo)
+ assert not auth.allows_repo(other_repo)
+
+
+class TestRepositoryLegacyTokenAuthentication(object):
+ def test_authenticate_credentials_empty(self, db):
+ token = None
+ authentication = RepositoryLegacyTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_not_uuid(self, db):
+ token = "not-a-uuid"
+ authentication = RepositoryLegacyTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_uuid_no_repo(self, db):
+ token = str(uuid.uuid4())
+ authentication = RepositoryLegacyTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_uuid_token_with_repo(self, db):
+ repo = RepositoryFactory.create()
+ authentication = RepositoryLegacyTokenAuthentication()
+ res = authentication.authenticate_credentials(repo.upload_token)
+ assert res is not None
+ user, auth = res
+ assert user._repository == repo
+ assert auth.get_repositories() == [repo]
+ assert auth.get_scopes() == ["upload"]
+
+
+class TestRepositoryTableTokenAuthentication(object):
+ def test_authenticate_credentials_empty(self, db):
+ key = ""
+ authentication = RepositoryTokenAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed):
+ authentication.authenticate_credentials(key)
+
+ def test_authenticate_credentials_valid_token_no_repo(self, db):
+ key = RepositoryToken.generate_key()
+ authentication = RepositoryTokenAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed):
+ authentication.authenticate_credentials(key)
+
+ def test_authenticate_credentials_uuid_token_with_repo(self, db):
+ token = RepositoryTokenFactory.create(
+ repository__active=True, token_type="profiling"
+ )
+ authentication = RepositoryTokenAuthentication()
+ res = authentication.authenticate_credentials(token.key)
+ assert res is not None
+ user, auth = res
+ assert user._repository == token.repository
+ assert auth.get_repositories() == [token.repository]
+ assert auth.get_scopes() == ["profiling"]
+
+ def test_authenticate_credentials_uuid_token_with_repo_not_active(self, db):
+ token = RepositoryTokenFactory.create(
+ repository__active=False, token_type="profiling"
+ )
+ authentication = RepositoryTokenAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed):
+ authentication.authenticate_credentials(token.key)
+
+ def test_authenticate_credentials_uuid_token_with_repo_valid_until_not_reached(
+ self, db
+ ):
+ token = RepositoryTokenFactory.create(
+ repository__active=True,
+ token_type="banana",
+ valid_until=timezone.now() + timedelta(seconds=1000),
+ )
+ authentication = RepositoryTokenAuthentication()
+ res = authentication.authenticate_credentials(token.key)
+ user, auth = res
+ assert user._repository == token.repository
+ assert auth.get_repositories() == [token.repository]
+ assert auth.get_scopes() == ["banana"]
+
+ def test_authenticate_credentials_uuid_token_with_repo_valid_until_already_reached(
+ self, db
+ ):
+ token = RepositoryTokenFactory.create(
+ repository__active=True,
+ token_type="banana",
+ valid_until=timezone.now() - timedelta(seconds=1000),
+ )
+ authentication = RepositoryTokenAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed) as exc:
+ authentication.authenticate_credentials(token.key)
+ assert exc.value.args == ("Invalid token.",)
+
+
+class TestGlobalTokenAuthentication(object):
+ def get_mocked_global_tokens(self):
+ return {
+ "githubuploadtoken": "github",
+ "gitlabuploadtoken": "gitlab",
+ "bitbucketserveruploadtoken": "bitbucket_server",
+ }
+
+ @patch("codecov_auth.authentication.repo_auth.get_global_tokens")
+ def test_authentication_no_global_token_available(self, mocked_get_global_tokens):
+ mocked_get_global_tokens.return_value = {}
+ authentication = GlobalTokenAuthentication()
+ request = APIRequestFactory().post("/upload/service/owner::::repo/commits")
+ res = authentication.authenticate(request)
+ assert res is None
+
+ @patch("codecov_auth.authentication.repo_auth.get_global_tokens")
+ def test_authentication_for_enterprise_wrong_token(self, mocked_get_global_tokens):
+ mocked_get_global_tokens.return_value = self.get_mocked_global_tokens()
+ authentication = GlobalTokenAuthentication()
+ request = APIRequestFactory().post(
+ "/upload/service/owner::::repo/commits",
+ headers={"Authorization": "token GUT"},
+ )
+ res = authentication.authenticate(request)
+ assert res is None
+
+ @patch("codecov_auth.authentication.repo_auth.get_global_tokens")
+ def test_authentication_for_enterprise_correct_token_repo_not_exists(
+ self, mocked_get_global_tokens, db
+ ):
+ mocked_get_global_tokens.return_value = self.get_mocked_global_tokens()
+ authentication = GlobalTokenAuthentication()
+ request = APIRequestFactory().post(
+ "/upload/service/owner::::repo/commits",
+ headers={"Authorization": "token githubuploadtoken"},
+ )
+ with pytest.raises(exceptions.AuthenticationFailed) as exc:
+ authentication.authenticate(request)
+ assert exc.value.args == (
+ "Could not find a repository, try using repo upload token",
+ )
+
+ @pytest.mark.parametrize(
+ "owner_service, owner_name, token",
+ [
+ pytest.param("github", "username", "githubuploadtoken", id="github"),
+ pytest.param(
+ "gitlab", "username", "gitlabuploadtoken", id="gitlab_single_user"
+ ),
+ pytest.param(
+ "gitlab",
+ "usergroup:username",
+ "gitlabuploadtoken",
+ id="gitlab_subgroup_user",
+ ),
+ ],
+ )
+ @patch("codecov_auth.authentication.repo_auth.get_global_tokens")
+ def test_authentication_for_enterprise_correct_token_repo_exists(
+ self, mocked_get_global_tokens, owner_service, owner_name, token, db
+ ):
+ mocked_get_global_tokens.return_value = self.get_mocked_global_tokens()
+ owner = OwnerFactory.create(service=owner_service, username=owner_name)
+ owner_name.replace(":", ":::") # encode name to test GL subgroups
+ repository = RepositoryFactory.create(author=owner)
+ authentication = GlobalTokenAuthentication()
+ request = APIRequestFactory().post(
+ f"/upload/{owner_service}/{owner_name}::::{repository.name}/commits",
+ headers={"Authorization": f"token {token}"},
+ )
+ res = authentication.authenticate(request)
+ assert res is not None
+ user, auth = res
+ assert user._repository == repository
+ assert auth.get_repositories() == [repository]
+ assert auth.get_scopes() == ["upload"]
+
+
+@patch("codecov_auth.authentication.repo_auth.get_repo_with_github_actions_oidc_token")
+class TestGitHubOIDCTokenAuthentication(object):
+ def test_authenticate_credentials_empty_returns_none(
+ self, mocked_get_repo_with_token, db
+ ):
+ token = None
+ authentication = GitHubOIDCTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_uuid_returns_none(
+ self, mocked_get_repo_with_token, db
+ ):
+ token = uuid.uuid4()
+ authentication = GitHubOIDCTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_no_repo(self, mocked_get_repo_with_token, db):
+ mocked_get_repo_with_token.side_effect = ObjectDoesNotExist()
+ token = "the best token"
+ authentication = GitHubOIDCTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_oidc_error(self, mocked_get_repo_with_token, db):
+ mocked_get_repo_with_token.side_effect = PyJWTError()
+ token = "the best token"
+ authentication = GitHubOIDCTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ def test_authenticate_credentials_oidc_valid(self, mocked_get_repo_with_token, db):
+ token = "the best token"
+ repository = RepositoryFactory()
+ owner = repository.author
+ owner.service = SERVICE_GITHUB
+ owner.save()
+ mocked_get_repo_with_token.return_value = repository
+ authentication = GitHubOIDCTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is not None
+ user, auth = res
+ assert auth.get_repositories() == [repository]
+ assert auth.allows_repo(repository)
+ assert user._repository == repository
+ assert auth.get_scopes() == ["upload"]
+
+
+valid_params_to_test = [
+ ("/upload/github/owner::::the_repo/commits", "owner/the_repo", None),
+ ("/upload/github/owner::::the_repo/commits/", "owner/the_repo", None),
+ (
+ "/upload/github/owner::::the_repo/commits/9652fb7ff577f554588ea83afded9000acd084ee/reports",
+ "owner/the_repo",
+ "9652fb7ff577f554588ea83afded9000acd084ee",
+ ),
+ (
+ "/upload/github/owner::::the_repo/commits/9652fb7ff577f554588ea83afded9000acd084ee/reports/",
+ "owner/the_repo",
+ "9652fb7ff577f554588ea83afded9000acd084ee",
+ ),
+ (
+ "/upload/github/owner::::the_repo/commits/9652fb7ff577f554588ea83afded9000acd084ee/reports/default/uploads",
+ "owner/the_repo",
+ "9652fb7ff577f554588ea83afded9000acd084ee",
+ ),
+ (
+ "/upload/github/owner::::the_repo/commits/9652fb7ff577f554588ea83afded9000acd084ee/reports/default/uploads/",
+ "owner/the_repo",
+ "9652fb7ff577f554588ea83afded9000acd084ee",
+ ),
+ (
+ "/upload/github/owner::::example-repo/commits",
+ "owner/example-repo",
+ None,
+ ),
+ (
+ "/upload/github/owner::::__example-repo__/commits",
+ "owner/__example-repo__",
+ None,
+ ),
+ (
+ "/upload/github/owner::::~example-repo:copy/commits",
+ "owner/~example-repo:copy",
+ None,
+ ),
+]
+
+
+class TestOrgLevelTokenAuthentication(object):
+ @override_settings(IS_ENTERPRISE=True)
+ def test_enterprise_no_token_return_none(self, db, mocker):
+ authentication = OrgLevelTokenAuthentication()
+ token = uuid.uuid4()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_owner_has_no_token_return_none(self, db, mocker):
+ token = uuid.uuid4()
+ authentication = OrgLevelTokenAuthentication()
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_owner_has_token_but_wrong_one_sent_return_none(self, db, mocker):
+ owner = OwnerFactory(plan="users-enterprisey")
+ owner.save()
+ owner_token, _ = OrganizationLevelToken.objects.get_or_create(owner=owner)
+ owner_token.save()
+ # Valid UUID token but doesn't belong to owner
+ wrong_token = uuid.uuid4()
+ request = APIRequestFactory().post(
+ "/endpoint", HTTP_AUTHORIZATION=f"Token {wrong_token}"
+ )
+ authentication = OrgLevelTokenAuthentication()
+ res = authentication.authenticate(request)
+ assert res is None
+ assert OrganizationLevelToken.objects.filter(owner=owner).count() == 1
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_expired_token_raises_exception(self, db, mocker):
+ owner = OwnerFactory(plan="users-enterprisey")
+ owner.save()
+ six_hours_ago = datetime.now() - timedelta(hours=6)
+ owner_token, _ = OrganizationLevelToken.objects.get_or_create(
+ owner=owner, valid_until=six_hours_ago
+ )
+ owner_token.save()
+
+ request = APIRequestFactory().post(
+ "/endpoint", HTTP_AUTHORIZATION=f"Token {owner_token.token}"
+ )
+ authentication = OrgLevelTokenAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed) as exp:
+ authentication.authenticate(request)
+
+ assert exp.match("Token is expired.")
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_orgleveltoken_success_auth(self, db, mocker):
+ owner = OwnerFactory(plan="users-enterprisey")
+ owner.save()
+ week_from_now = datetime.now() + timedelta(days=7)
+ owner_token, _ = OrganizationLevelToken.objects.get_or_create(
+ owner=owner, valid_until=week_from_now
+ )
+ owner_token.save()
+ repository = RepositoryFactory(author=owner)
+ other_repo_from_owner = RepositoryFactory(author=owner)
+ random_repo = RepositoryFactory()
+ repository.save()
+ other_repo_from_owner.save()
+ random_repo.save()
+
+ request = APIRequestFactory().post(
+ "/endpoint", HTTP_AUTHORIZATION=f"Token {owner_token.token}"
+ )
+ authentication = OrgLevelTokenAuthentication()
+ res = authentication.authenticate(request)
+
+ assert res is not None
+ user, auth = res
+ assert user == owner
+ assert auth.get_repositories() == [other_repo_from_owner, repository]
+ assert auth._org == owner
+ get_repos_queryset = auth.get_repositories_queryset()
+ assert isinstance(get_repos_queryset, QuerySet)
+ # We can apply more filters to it
+ assert list(
+ get_repos_queryset.exclude(repoid=other_repo_from_owner.repoid).all()
+ ) == [repository]
+ assert auth.allows_repo(repository)
+ assert auth.allows_repo(other_repo_from_owner)
+ assert not auth.allows_repo(random_repo)
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_orgleveltoken_success_auth_enterprise(self, db, mocker):
+ owner = OwnerFactory(plan="users-enterprisey")
+ owner.save()
+ week_from_now = datetime.now() + timedelta(days=7)
+ owner_token, _ = OrganizationLevelToken.objects.get_or_create(
+ owner=owner, valid_until=week_from_now
+ )
+ owner_token.save()
+ repository = RepositoryFactory(author=owner)
+ other_repo_from_owner = RepositoryFactory(author=owner)
+ random_repo = RepositoryFactory()
+ repository.save()
+ other_repo_from_owner.save()
+ random_repo.save()
+
+ request = APIRequestFactory().post(
+ "/endpoint", HTTP_AUTHORIZATION=f"Token {owner_token.token}"
+ )
+ authentication = OrgLevelTokenAuthentication()
+ res = authentication.authenticate(request)
+
+ assert res is not None
+ user, auth = res
+ assert user == owner
+ assert auth.get_repositories() == [other_repo_from_owner, repository]
+ assert auth._org == owner
+ get_repos_queryset = auth.get_repositories_queryset()
+ assert isinstance(get_repos_queryset, QuerySet)
+ # We can apply more filters to it
+ assert list(
+ get_repos_queryset.exclude(repoid=other_repo_from_owner.repoid).all()
+ ) == [repository]
+ assert auth.allows_repo(repository)
+ assert auth.allows_repo(other_repo_from_owner)
+ assert not auth.allows_repo(random_repo)
+
+ def test_token_is_not_uuid(self):
+ """
+ OIDC tokens are not UUID, so if you do token = OrganizationLevelToken.objects.filter(token=key).first(),
+ you get a ValidationError for trying a non-UUID in a models.UUIDField. Rather than adding a try/except,
+ check whether the incoming `key` is a UUID - if not, don't try to find it in OrganizationLevelToken.
+ """
+ authentication = OrgLevelTokenAuthentication()
+ token = "not a uuid"
+ res = authentication.authenticate_credentials(token)
+ assert res is None
+
+
+class TestTokenlessAuth(object):
+ def test_tokenless_bad_path(self):
+ request = APIRequestFactory().post(
+ "/endpoint",
+ headers={},
+ )
+ authentication = TokenlessAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed):
+ _ = authentication.authenticate(request)
+
+ def test_tokenless_unknown_repository(self, db):
+ request = APIRequestFactory().post(
+ "/upload/github/owner::::repo/commits/commit_sha/reports/report_code/uploads",
+ headers={"X-Tokenless": "user-name/repo-forked", "X-Tokenless-PR": "15"},
+ )
+ authentication = TokenlessAuthentication()
+ with pytest.raises(exceptions.AuthenticationFailed):
+ _ = authentication.authenticate(request)
+
+ @pytest.mark.parametrize("request_uri,repo_slug,commitid", valid_params_to_test)
+ def test_tokenless_matches_paths(self, request_uri, repo_slug, commitid, db):
+ author_name, repo_name = repo_slug.split("/")
+ repo = RepositoryFactory(
+ name=repo_name, author__username=author_name, private=False
+ )
+ assert repo.service == "github"
+ request = APIRequestFactory().post(
+ request_uri, {"branch": "fork:branch"}, format="json"
+ )
+ authentication = TokenlessAuthentication()
+ assert authentication._get_info_from_request_path(request) == (repo, commitid)
+
+ @pytest.mark.parametrize("private", [False, True])
+ @pytest.mark.parametrize("branch", ["branch", "fork:branch"])
+ @pytest.mark.parametrize(
+ "existing_commit,commit_branch",
+ [(False, None), (True, "branch"), (True, "fork:branch")],
+ )
+ def test_tokenless_success(
+ self,
+ db,
+ mocker,
+ private,
+ branch,
+ existing_commit,
+ commit_branch,
+ ):
+ repo = RepositoryFactory(private=private)
+
+ if existing_commit:
+ commit = CommitFactory()
+ commit.branch = commit_branch
+ commit.repository = repo
+ commit.save()
+
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits/{commit.commitid}/reports/report_code/uploads",
+ {"branch": branch},
+ format="json",
+ )
+
+ else:
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits",
+ {"branch": branch},
+ format="json",
+ )
+
+ authentication = TokenlessAuthentication()
+ expected = private is False and (
+ (existing_commit is False and ":" in branch)
+ or (existing_commit is True and ":" in commit_branch)
+ )
+
+ if expected:
+ res = authentication.authenticate(request)
+ assert res is not None
+ repo_as_user, auth_class = res
+
+ assert repo_as_user.is_authenticated() is expected
+ assert isinstance(auth_class, TokenlessAuth)
+ else:
+ with pytest.raises(exceptions.AuthenticationFailed):
+ res = authentication.authenticate(request)
+
+
+class TestUploadTokenRequiredAuthenticationCheck(object):
+ def test_token_not_required_bad_path(self):
+ request = APIRequestFactory().post(
+ "/endpoint",
+ headers={},
+ )
+ authentication = UploadTokenRequiredAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_bad_service(self):
+ request = APIRequestFactory().post(
+ "/upload/other/owner::::repo/commits/commit_sha/reports/report_code/uploads",
+ headers={"X-Tokenless": "user-name/repo-forked", "X-Tokenless-PR": "15"},
+ )
+ authentication = UploadTokenRequiredAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_unknown_repository(self, db):
+ an_owner = OwnerFactory(upload_token_required_for_public_repos=False)
+ # their repo
+ RepositoryFactory(author=an_owner, private=False)
+ request_uri = f"/upload/github/{an_owner.username}::::bad/commits"
+ request = APIRequestFactory().post(
+ request_uri, {"branch": "branch"}, format="json"
+ )
+ authentication = UploadTokenRequiredAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_unknown_owner(self, db):
+ repo = RepositoryFactory(
+ private=False, author__upload_token_required_for_public_repos=False
+ )
+ request_uri = f"/upload/github/bad::::{repo.name}/commits"
+ request = APIRequestFactory().post(
+ request_uri, {"branch": "branch"}, format="json"
+ )
+ authentication = UploadTokenRequiredAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ @pytest.mark.parametrize("request_uri,repo_slug,commitid", valid_params_to_test)
+ @pytest.mark.parametrize("private", [False, True])
+ @pytest.mark.parametrize("token_required", [False, True])
+ def test_get_repository_and_owner(
+ self, request_uri, repo_slug, commitid, private, token_required, db
+ ):
+ author_name, repo_name = repo_slug.split("/")
+ repo = RepositoryFactory(
+ name=repo_name,
+ author__username=author_name,
+ private=private,
+ author__upload_token_required_for_public_repos=token_required,
+ )
+ assert repo.service == "github"
+ request = APIRequestFactory().post(
+ request_uri, {"branch": "fork:branch"}, format="json"
+ )
+ authentication = UploadTokenRequiredAuthenticationCheck()
+ assert authentication.get_repository_and_owner(request) == (
+ repo,
+ repo.author,
+ )
+
+ @pytest.mark.parametrize("private", [False, True])
+ @pytest.mark.parametrize("branch", ["branch", "fork:branch"])
+ @pytest.mark.parametrize(
+ "existing_commit,commit_branch",
+ [(False, None), (True, "branch"), (True, "fork:branch")],
+ )
+ @pytest.mark.parametrize("token_required", [False, True])
+ def test_token_not_required_fork_branch_public_private(
+ self,
+ db,
+ mocker,
+ private,
+ branch,
+ existing_commit,
+ commit_branch,
+ token_required,
+ ):
+ repo = RepositoryFactory(
+ private=private,
+ author__upload_token_required_for_public_repos=token_required,
+ )
+
+ if existing_commit:
+ commit = CommitFactory()
+ commit.branch = commit_branch
+ commit.repository = repo
+ commit.save()
+
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits/{commit.commitid}/reports/report_code/uploads",
+ {"branch": branch},
+ format="json",
+ )
+
+ else:
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits",
+ {"branch": branch},
+ format="json",
+ )
+
+ authentication = UploadTokenRequiredAuthenticationCheck()
+
+ if not private and not token_required:
+ res = authentication.authenticate(request)
+ assert res is not None
+ repo_as_user, auth_class = res
+
+ assert repo_as_user.is_authenticated() is True
+ assert isinstance(auth_class, TokenlessAuth)
+ else:
+ res = authentication.authenticate(request)
+ assert res is None
+
+
+class TestUploadTokenRequiredGetFromBodyAuthenticationCheck(object):
+ def test_token_not_required_invalid_data(self):
+ request = APIRequestFactory().post(
+ "/endpoint",
+ data={"slug": 123, "git_service": "github"},
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_no_data(self):
+ request = APIRequestFactory().post(
+ "/endpoint",
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_no_git_service(self, db):
+ owner = OwnerFactory(upload_token_required_for_public_repos=False)
+ # their repo
+ repo = RepositoryFactory(author=owner, private=False)
+ request_uri = f"/upload/github/{owner.username}::::{repo.name}/commits"
+ request = APIRequestFactory().post(
+ request_uri,
+ data={
+ "slug": f"{owner.username}::::{repo.name}",
+ },
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ assert authentication.get_repository_and_owner(request) == (None, None)
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_unknown_repository(self, db):
+ an_owner = OwnerFactory(upload_token_required_for_public_repos=False)
+ # their repo
+ RepositoryFactory(author=an_owner, private=False)
+ request_uri = f"/upload/github/{an_owner.username}::::bad/commits"
+ request = APIRequestFactory().post(
+ request_uri,
+ data={
+ "slug": f"{an_owner.username}::::bad",
+ "service": an_owner.service,
+ },
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ assert authentication.get_repository_and_owner(request) == (None, None)
+ res = authentication.authenticate(request)
+ assert res is None
+
+ def test_token_not_required_unknown_owner(self, db):
+ repo = RepositoryFactory(
+ private=False, author__upload_token_required_for_public_repos=False
+ )
+ request_uri = f"/upload/github/bad::::{repo.name}/commits"
+ request = APIRequestFactory().post(
+ request_uri,
+ data={
+ "slug": f"bad::::{repo.name}",
+ "service": "github",
+ },
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ res = authentication.authenticate(request)
+ assert res is None
+
+ @pytest.mark.parametrize("request_uri,repo_slug,commitid", valid_params_to_test)
+ @pytest.mark.parametrize("private", [False, True])
+ @pytest.mark.parametrize("token_required", [False, True])
+ def test_get_repository_and_owner(
+ self, request_uri, repo_slug, commitid, private, token_required, db
+ ):
+ author_name, repo_name = repo_slug.split("/")
+ repo = RepositoryFactory(
+ name=repo_name,
+ author__username=author_name,
+ private=private,
+ author__upload_token_required_for_public_repos=token_required,
+ )
+ assert repo.service == "github"
+ request = APIRequestFactory().post(
+ request_uri,
+ data={"slug": f"{author_name}::::{repo_name}", "service": "github"},
+ format="json",
+ )
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+
+ assert authentication.get_repository_and_owner(request) == (
+ repo,
+ repo.author,
+ )
+
+ def test_get_repository_and_owner_with_service(self, db):
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+ repo_name = "the-repo"
+ owner_username = "the-author"
+ for name, _ in Service.choices:
+ owner = OwnerFactory(service=name, username=owner_username)
+ RepositoryFactory(name=repo_name, author=owner)
+
+ request = APIRequestFactory().post(
+ "endpoint/",
+ data={
+ "slug": f"{owner_username}::::{repo_name}",
+ "git_service": Service.BITBUCKET.value,
+ },
+ format="json",
+ )
+ matching_owner = Owner.objects.get(
+ service=Service.BITBUCKET.value, username=owner_username
+ )
+ matching_repo = Repository.objects.get(
+ name=repo_name, author__service=Service.BITBUCKET.value
+ )
+ assert authentication.get_repository_and_owner(request) == (
+ matching_repo,
+ matching_owner,
+ )
+
+ request = APIRequestFactory().post(
+ "endpoint/",
+ data={
+ "slug": f"{owner_username}::::{repo_name}",
+ "git_service": Service.GITLAB.value,
+ },
+ format="json",
+ )
+ matching_owner = Owner.objects.get(
+ service=Service.GITLAB.value, username=owner_username
+ )
+ matching_repo = Repository.objects.get(
+ name=repo_name, author__service=Service.GITLAB.value
+ )
+ assert authentication.get_repository_and_owner(request) == (
+ matching_repo,
+ matching_owner,
+ )
+
+ request = APIRequestFactory().post(
+ "endpoint/",
+ data={
+ "slug": f"{owner_username}::::{repo_name}",
+ "git_service": Service.GITHUB.value,
+ },
+ format="json",
+ )
+ matching_owner = Owner.objects.get(
+ service=Service.GITHUB.value, username=owner_username
+ )
+ matching_repo = Repository.objects.get(
+ name=repo_name, author__service=Service.GITHUB.value
+ )
+ assert authentication.get_repository_and_owner(request) == (
+ matching_repo,
+ matching_owner,
+ )
+
+ @pytest.mark.parametrize("private", [False, True])
+ @pytest.mark.parametrize("branch", ["branch", "fork:branch"])
+ @pytest.mark.parametrize(
+ "existing_commit,commit_branch",
+ [(False, None), (True, "branch"), (True, "fork:branch")],
+ )
+ @pytest.mark.parametrize("token_required", [False, True])
+ def test_token_not_required_fork_branch_public_private(
+ self,
+ db,
+ mocker,
+ private,
+ branch,
+ existing_commit,
+ commit_branch,
+ token_required,
+ ):
+ repo = RepositoryFactory(
+ private=private,
+ author__upload_token_required_for_public_repos=token_required,
+ )
+
+ if existing_commit:
+ commit = CommitFactory()
+ commit.branch = commit_branch
+ commit.repository = repo
+ commit.save()
+
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits/{commit.commitid}/reports/report_code/uploads",
+ data={
+ "slug": f"{repo.author.username}::::{repo.name}",
+ "git_service": repo.author.service,
+ },
+ format="json",
+ )
+
+ else:
+ request = APIRequestFactory().post(
+ f"/upload/github/{repo.author.username}::::{repo.name}/commits",
+ data={
+ "slug": f"{repo.author.username}::::{repo.name}",
+ "git_service": repo.author.service,
+ },
+ format="json",
+ )
+
+ authentication = UploadTokenRequiredGetFromBodyAuthenticationCheck()
+
+ if not private and not token_required:
+ res = authentication.authenticate(request)
+ assert res is not None
+ repo_as_user, auth_class = res
+
+ assert repo_as_user.is_authenticated() is True
+ assert isinstance(auth_class, TokenlessAuth)
+ else:
+ res = authentication.authenticate(request)
+ assert res is None
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/__init__.py b/apps/codecov-api/codecov_auth/tests/unit/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_base.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_base.py
new file mode 100644
index 0000000000..37fb93a9fd
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_base.py
@@ -0,0 +1,896 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import Mock, call, patch
+
+import pytest
+from django.conf import settings
+from django.contrib.sessions.backends.cache import SessionStore
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.test import RequestFactory, TestCase, override_settings
+from freezegun import freeze_time
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ SessionFactory,
+ UserFactory,
+)
+from shared.license import LicenseInformation
+
+from codecov_auth.models import DjangoSession, Owner, OwnerProfile, Session
+from codecov_auth.tests.factories import DjangoSessionFactory
+from codecov_auth.views.base import LoginMixin, StateMixin
+
+
+def set_up_mixin(to=None):
+ query_string = {"to": to} if to else None
+ mixin = StateMixin()
+ mixin.request = RequestFactory().get("", query_string)
+ mixin.request.session = SessionStore()
+ mixin.service = "github"
+ return mixin
+
+
+def test_generate_state_without_redirection_url(mock_redis):
+ mixin = set_up_mixin()
+ state = mixin.generate_state()
+ assert (
+ mock_redis.get(f"oauth-state-{state}").decode("utf-8")
+ == "http://localhost:3000/gh"
+ )
+
+
+def test_generate_state_with_path_redirection_url(mock_redis):
+ mixin = set_up_mixin("/gh/codecov")
+ state = mixin.generate_state()
+ assert mock_redis.get(f"oauth-state-{state}").decode("utf-8") == "/gh/codecov"
+
+
+@override_settings(CORS_ALLOWED_ORIGINS=["https://app.codecov.io"])
+def test_generate_state_with_safe_domain_redirection_url(mock_redis):
+ mixin = set_up_mixin("https://app.codecov.io/gh/codecov")
+ state = mixin.generate_state()
+ assert (
+ mock_redis.get(f"oauth-state-{state}").decode("utf-8")
+ == "https://app.codecov.io/gh/codecov"
+ )
+
+
+@override_settings(CORS_ALLOWED_ORIGINS=[])
+@override_settings(CORS_ALLOWED_ORIGIN_REGEXES=[r"^(https:\/\/)?(.+)\.codecov\.io$"])
+def test_generate_state_with_safe_domain_regex_redirection_url(mock_redis):
+ mixin = set_up_mixin("https://app.codecov.io/gh/codecov")
+ state = mixin.generate_state()
+ assert (
+ mock_redis.get(f"oauth-state-{state}").decode("utf-8")
+ == "https://app.codecov.io/gh/codecov"
+ )
+
+
+@override_settings(CORS_ALLOWED_ORIGINS=[])
+@override_settings(CORS_ALLOWED_ORIGIN_REGEXES=[])
+def test_generate_state_with_unsafe_domain(mock_redis):
+ mixin = set_up_mixin("http://hacker.com/i-steal-cookie")
+ state = mixin.generate_state()
+ assert mock_redis.keys("*") != []
+ assert (
+ mock_redis.get(f"oauth-state-{state}").decode("utf-8")
+ == "http://localhost:3000/gh"
+ )
+
+
+@override_settings(CORS_ALLOWED_ORIGINS=[])
+@override_settings(CORS_ALLOWED_ORIGIN_REGEXES=[])
+def test_generate_state_when_wrong_url(mock_redis):
+ mixin = set_up_mixin("http://localhost:]/")
+ state = mixin.generate_state()
+ assert mock_redis.keys("*") != []
+ assert (
+ mock_redis.get(f"oauth-state-{state}").decode("utf-8")
+ == "http://localhost:3000/gh"
+ )
+
+
+def test_get_redirection_url_from_state_without_redis_state(mock_redis):
+ mixin = set_up_mixin()
+ assert mixin.get_redirection_url_from_state("not exist") == (
+ "http://localhost:3000/gh",
+ False,
+ )
+
+
+def test_get_redirection_url_from_state_without_session_state(mock_redis):
+ mixin = set_up_mixin()
+ state = "abc"
+ mock_redis.set(mixin._get_key_redis(state), "http://localhost/gh/codecov")
+ assert mixin.get_redirection_url_from_state(state) == (
+ "http://localhost:3000",
+ False,
+ )
+
+
+def test_get_redirection_url_from_state_with_session_state_mismatch(mock_redis):
+ mixin = set_up_mixin()
+ state = "abc"
+ mock_redis.set(mixin._get_key_redis(state), "http://localhost/gh/codecov")
+ mixin.request.session[mixin._session_key()] = "def"
+
+ assert mixin.get_redirection_url_from_state(state) == (
+ "http://localhost:3000",
+ False,
+ )
+
+
+def test_get_redirection_url_from_state_give_url(mock_redis):
+ mixin = set_up_mixin()
+ state = "abc"
+ mock_redis.set(mixin._get_key_redis(state), "http://localhost/gh/codecov")
+ mixin.request.session[mixin._session_key()] = state
+
+ assert mixin.get_redirection_url_from_state("abc") == (
+ "http://localhost/gh/codecov",
+ True,
+ )
+
+
+def test_remove_state_with_with_delay(mock_redis):
+ mixin = set_up_mixin()
+ mock_redis.set("oauth-state-abc", "http://localhost/gh/codecov")
+ mixin.remove_state("abc", delay=5)
+ initial_datetime = datetime.now()
+ with freeze_time(initial_datetime) as frozen_time:
+ assert mock_redis.get("oauth-state-abc") is not None
+ frozen_time.move_to(initial_datetime + timedelta(seconds=4))
+ assert mock_redis.get("oauth-state-abc") is not None
+ frozen_time.move_to(initial_datetime + timedelta(seconds=6))
+ assert mock_redis.get("oauth-state-abc") is None
+
+
+def test_remove_state_with_with_no_delay(mock_redis):
+ mixin = set_up_mixin()
+ mock_redis.set("oauth-state-abc", "http://localhost/gh/codecov")
+ mixin.remove_state("abc")
+ assert mock_redis.get("oauth-state-abc") is None
+
+
+class LoginMixinTests(TestCase):
+ def setUp(self):
+ self.mixin_instance = LoginMixin()
+ self.mixin_instance.service = "github"
+ self.request = RequestFactory().get("", {})
+ self.request.user = None
+ self.request.current_owner = None
+ self.request.session = SessionStore()
+ self.mixin_instance.request = self.request
+
+ @patch("services.analytics.AnalyticsService.user_signed_up")
+ def test_get_or_create_calls_analytics_user_signed_up_when_owner_created(
+ self, user_signed_up_mock
+ ):
+ self.mixin_instance._get_or_create_owner(
+ {
+ "user": {"id": 12345, "key": "4567", "login": "testuser"},
+ "has_private_access": False,
+ },
+ self.request,
+ )
+ user_signed_up_mock.assert_called_once()
+
+ @patch("shared.events.amplitude.AmplitudeEventPublisher.publish")
+ def test_get_or_create_calls_amplitude_user_created_when_owner_created(
+ self, amplitude_publish_mock
+ ):
+ self.mixin_instance._get_or_create_owner(
+ {
+ "user": {"id": 12345, "key": "4567", "login": "testuser"},
+ "has_private_access": False,
+ },
+ self.request,
+ )
+
+ owner = Owner.objects.get(service_id=12345, username="testuser")
+
+ amplitude_publish_mock.assert_has_calls(
+ [
+ call("User Created", {"user_ownerid": owner.ownerid}),
+ call("set_orgs", {"user_ownerid": owner.ownerid, "org_ids": []}),
+ ]
+ )
+
+ @patch("services.analytics.AnalyticsService.user_signed_in")
+ def test_get_or_create_calls_analytics_user_signed_in_when_owner_not_created(
+ self, user_signed_in_mock
+ ):
+ owner = OwnerFactory(service_id=89, service="github")
+ self.mixin_instance._get_or_create_owner(
+ {
+ "user": {
+ "id": owner.service_id,
+ "key": "02or0sa",
+ "login": owner.username,
+ },
+ "has_private_access": owner.private_access,
+ },
+ self.request,
+ )
+ user_signed_in_mock.assert_called_once()
+
+ @patch("shared.events.amplitude.AmplitudeEventPublisher.publish")
+ def test_get_or_create_calls_amplitude_user_logged_in_when_owner_not_created(
+ self, amplitude_publish_mock
+ ):
+ owner = OwnerFactory(service_id=89, service="github", organizations=[1, 2])
+ self.mixin_instance._get_or_create_owner(
+ {
+ "user": {
+ "id": owner.service_id,
+ "key": "02or0sa",
+ "login": owner.username,
+ },
+ "has_private_access": owner.private_access,
+ },
+ self.request,
+ )
+
+ amplitude_publish_mock.assert_has_calls(
+ [
+ call("User Logged in", {"user_ownerid": owner.ownerid}),
+ call("set_orgs", {"user_ownerid": owner.ownerid, "org_ids": [1, 2]}),
+ ]
+ )
+
+ @override_settings(IS_ENTERPRISE=False)
+ @patch("services.analytics.AnalyticsService.user_signed_in")
+ def test_set_marketing_tags_on_cookies(self, user_signed_in_mock):
+ OwnerFactory(service="github")
+ self.request = RequestFactory().get(
+ "",
+ {
+ "utm_department": "a",
+ "utm_campaign": "b",
+ "utm_medium": "c",
+ "utm_source": "d",
+ "utm_content": "e",
+ "utm_term": "f",
+ },
+ )
+ self.mixin_instance.request = self.request
+ response = HttpResponse()
+ self.mixin_instance.store_to_cookie_utm_tags(response)
+ assert (
+ response.cookies["_marketing_tags"].value
+ == "utm_department=a&utm_campaign=b&utm_medium=c&utm_source=d&utm_content=e&utm_term=f"
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_get_marketing_tags_on_enterprise(self):
+ self.request = RequestFactory().get(
+ "",
+ {
+ "utm_department": "a",
+ "utm_campaign": "b",
+ "utm_medium": "c",
+ "utm_source": "d",
+ "utm_content": "e",
+ "utm_term": "f",
+ },
+ )
+ self.mixin_instance.request = self.request
+ response = HttpResponse()
+ self.mixin_instance.store_to_cookie_utm_tags(response)
+ marketing_tags = self.mixin_instance.retrieve_marketing_tags_from_cookie()
+ assert marketing_tags == {}
+
+ @patch("services.analytics.AnalyticsService.user_signed_in")
+ def test_use_marketing_tags_from_cookies(self, user_signed_in_mock):
+ owner = OwnerFactory(service_id=89, service="github")
+ self.request.COOKIES["_marketing_tags"] = (
+ "utm_department=a&utm_campaign=b&utm_medium=c&utm_source=d&utm_content=e&utm_term=f"
+ )
+ self.mixin_instance._get_or_create_owner(
+ {
+ "user": {
+ "id": owner.service_id,
+ "key": "02or0sa",
+ "login": owner.username,
+ },
+ "has_private_access": owner.private_access,
+ },
+ self.request,
+ )
+ user_signed_in_mock.assert_called_once_with(
+ owner,
+ **{
+ "utm_department": "a",
+ "utm_campaign": "b",
+ "utm_medium": "c",
+ "utm_source": "d",
+ "utm_content": "e",
+ "utm_term": "f",
+ },
+ )
+
+ def mock_get_or_create_owner(self, user_dict, *args):
+ owner = OwnerFactory(
+ service_id=user_dict.get("id", 89),
+ service="github",
+ )
+ owner.organizations = [1, 2]
+ return owner, True
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._get_or_create_owner",
+ mock_get_or_create_owner,
+ )
+ @patch(
+ "codecov_auth.views.base.LoginMixin.get_or_create_org", mock_get_or_create_owner
+ )
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._check_user_count_limitations",
+ lambda *args: True,
+ )
+ @patch("codecov_auth.views.base.get_config")
+ def test_get_and_modify_user_enterprise_raise_usernotinorganization_error(
+ self, mock_get_config: Mock
+ ):
+ user_dict = dict(
+ orgs=[],
+ is_student=False,
+ )
+ mock_get_config.return_value = ["awesome-team", "modest_mice"]
+ with pytest.raises(PermissionDenied) as exp:
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ assert exp.status_code == 401
+ mock_get_config.assert_called_with("github", "organizations")
+
+ @patch(
+ "codecov_auth.views.base.LoginMixin._get_or_create_owner",
+ mock_get_or_create_owner,
+ )
+ @patch(
+ "codecov_auth.views.base.LoginMixin.get_or_create_org", mock_get_or_create_owner
+ )
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._check_user_count_limitations",
+ lambda *args: True,
+ )
+ @patch("codecov_auth.views.base.get_config")
+ @override_settings(IS_ENTERPRISE=True)
+ def test_get_and_modify_user_enterprise_orgs_passes_if_user_in_org(
+ self, mock_get_config: Mock
+ ):
+ mock_get_config.return_value = ["awesome-team", "modest_mice"]
+ user_dict = dict(
+ orgs=[dict(username="awesome-team", id=29)],
+ is_student=False,
+ user=dict(id=121),
+ )
+ # This time it should not raise an exception because the user is in one of the orgs
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ mock_get_config.assert_any_call("github", "organizations")
+
+ @patch(
+ "codecov_auth.views.base.LoginMixin._get_or_create_owner",
+ mock_get_or_create_owner,
+ )
+ @patch(
+ "codecov_auth.views.base.LoginMixin.get_or_create_org", mock_get_or_create_owner
+ )
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._check_user_count_limitations",
+ lambda *args: True,
+ )
+ @patch("codecov_auth.views.base.get_config")
+ @override_settings(IS_ENTERPRISE=False)
+ def test_get_and_modify_user_passes_if_not_enterprise(self, mock_get_config: Mock):
+ user_dict = dict(orgs=[], is_student=False, user=dict(id=121))
+ # This time it should not raise an exception because it's not in enterprise mode
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ mock_get_config.assert_called_once_with(
+ "github", "student_disabled", default=False
+ )
+
+ @override_settings(IS_ENTERPRISE=False)
+ @patch("codecov_auth.views.base.get_current_license")
+ def test_check_user_account_limitations_not_enterprise(
+ self, mock_get_current_license: Mock
+ ):
+ login_data = dict(id=121)
+ license = LicenseInformation(
+ is_valid=True,
+ message=None,
+ number_allowed_users=2,
+ )
+ mock_get_current_license.return_value = license
+ self.mixin_instance._check_user_count_limitations(login_data)
+ mock_get_current_license.assert_not_called()
+
+ def owner_factory_side_effect(self, serivce_id, token):
+ owner = OwnerFactory(serivce_id=serivce_id, service="github")
+ owner.oauth_token = token
+ return owner
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("codecov_auth.models.Owner.objects")
+ @patch("codecov_auth.views.base.get_current_license")
+ def test_check_user_account_limitations_enterprise_user_exists_not_pr_billing(
+ self, mock_get_current_license: Mock, mock_owner_objects: Mock
+ ):
+ login_data = dict(id=121)
+ license = LicenseInformation(
+ is_valid=True, message=None, number_allowed_users=2, is_pr_billing=False
+ )
+ mock_get_current_license.return_value = license
+ mock_owner_objects.get.return_value = self.owner_factory_side_effect(
+ 1200, token="somethingsomething"
+ )
+ self.mixin_instance._check_user_count_limitations(login_data)
+ mock_get_current_license.assert_called_once()
+ mock_owner_objects.get.assert_called_once()
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("codecov_auth.views.base.get_current_license")
+ def test_check_user_account_limitations_enterprise_user_new_not_pr_billing(
+ self, mock_get_current_license: Mock
+ ):
+ login_data = dict(id=121)
+ license = LicenseInformation(
+ is_valid=True, message=None, number_allowed_users=1, is_pr_billing=False
+ )
+ mock_get_current_license.return_value = license
+ # If the number of users is smaller than the limit, no exception is raised
+ # In this case
+ self.mixin_instance._check_user_count_limitations(login_data)
+ mock_get_current_license.assert_called_once()
+ assert (
+ Owner.objects.filter(oauth_token__isnull=False, service="github").count()
+ == 0
+ )
+ # If the number of users is larger than the limit, raise error
+ with pytest.raises(PermissionDenied):
+ OwnerFactory(service="github", ownerid=12, oauth_token="very-fake-token")
+ OwnerFactory(service="github", ownerid=13, oauth_token=None)
+ OwnerFactory(service="github", ownerid=14, oauth_token="very-fake-token")
+ assert (
+ Owner.objects.filter(
+ oauth_token__isnull=False, service="github"
+ ).count()
+ == 2
+ )
+ self.mixin_instance._check_user_count_limitations(login_data)
+ mock_get_current_license.assert_called()
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("codecov_auth.views.base.get_current_license")
+ def test_check_user_account_limitations_enterprise_pr_billing(
+ self, mock_get_current_license: Mock
+ ):
+ license = LicenseInformation(
+ is_valid=True, message=None, number_allowed_users=1, is_pr_billing=True
+ )
+ mock_get_current_license.return_value = license
+ # User doesn't exist, and existing users will raise error
+ with pytest.raises(PermissionDenied):
+ OwnerFactory(ownerid=1, service="github", plan_activated_users=[1, 2, 3])
+ OwnerFactory(
+ ownerid=2,
+ service="github",
+ service_id="batata_frita",
+ plan_activated_users=[],
+ )
+ OwnerFactory(ownerid=3, service="github", plan_activated_users=None)
+ assert (
+ Owner.objects.exclude(plan_activated_users__len=0)
+ .exclude(plan_activated_users__isnull=True)
+ .count()
+ == 1
+ )
+ assert Owner.objects.exclude(plan_activated_users__len=0)[
+ 0
+ ].plan_activated_users == [1, 2, 3]
+ self.mixin_instance._check_user_count_limitations(dict(id=121))
+ mock_get_current_license.assert_called()
+ # If user exists, don't raise exception
+ assert (
+ Owner.objects.get(service="github", service_id="batata_frita").ownerid == 2
+ )
+ self.mixin_instance._check_user_count_limitations(dict(id="batata_frita"))
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._check_user_count_limitations",
+ lambda *args: True,
+ )
+ @patch(
+ "codecov_auth.views.base.LoginMixin._get_or_create_owner",
+ mock_get_or_create_owner,
+ )
+ @patch("codecov_auth.views.base.get_config")
+ def test_github_teams_restrictions(self, mock_get_config: Mock):
+ def side_effect(*args, **kwargs):
+ if len(args) == 2 and args[0] == "github" and args[1] == "organizations":
+ return ["my-org"]
+ if len(args) == 2 and args[0] == "github" and args[1] == "teams":
+ return ["My Team"]
+ if len(args) == 2 and args[0] == "github" and args[1] == "student_disabled":
+ return False
+
+ mock_get_config.side_effect = side_effect
+ user_dict = dict(
+ orgs=[dict(username="my-org", id=29)],
+ is_student=False,
+ user=dict(id=121, login="something"),
+ teams=[],
+ )
+ # Raise exception because user is not member of My Team
+ with pytest.raises(PermissionDenied) as exp:
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ mock_get_config.assert_any_call("github", "organizations")
+ mock_get_config.assert_any_call("github", "teams")
+ assert (
+ str(exp)
+ == "You must be a member of an allowed team in your organization."
+ )
+ assert exp.status_code == 401
+ # No exception if user is in My Team
+ user_dict["teams"] = [dict(name="My Team")]
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ mock_get_config.assert_any_call("github", "organizations")
+ mock_get_config.assert_any_call("github", "teams")
+ mock_get_config.assert_any_call("github", "student_disabled", default=False)
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ @patch(
+ "codecov_auth.views.base.LoginMixin._check_user_count_limitations",
+ lambda *args: True,
+ )
+ @patch(
+ "codecov_auth.views.base.LoginMixin._get_or_create_owner",
+ mock_get_or_create_owner,
+ )
+ @patch("codecov_auth.views.base.get_config")
+ def test_github_teams_restrictions_no_teams_in_config(self, mock_get_config: Mock):
+ def side_effect(*args, **kwargs):
+ if len(args) == 2 and args[0] == "github" and args[1] == "organizations":
+ return ["my-org"]
+ if len(args) == 2 and args[0] == "github" and args[1] == "teams":
+ return []
+ if len(args) == 2 and args[0] == "github" and args[1] == "student_disabled":
+ return False
+
+ mock_get_config.side_effect = side_effect
+ user_dict = dict(
+ orgs=[dict(username="my-org", id=29)],
+ is_student=False,
+ user=dict(id=121, login="something"),
+ teams=[dict(name="My Team")],
+ )
+ # Don't raise exception if there's no team in the config
+ user = self.mixin_instance.get_and_modify_owner(user_dict, self.request)
+ self.mixin_instance.login_owner(user, self.request, HttpResponse())
+ mock_get_config.assert_any_call("github", "organizations")
+ mock_get_config.assert_any_call("github", "teams")
+ mock_get_config.assert_any_call("github", "student_disabled", default=False)
+
+ def test_adjust_redirection_url_is_unchanged_if_url_is_different_from_base_url(
+ self,
+ ):
+ provider = "gh"
+ owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ )
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}/some/random/path/to/file.py"
+
+ redirect_url = (
+ self.mixin_instance.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url
+
+ def test_adjust_redirection_url_is_unchanged_if_no_owner_profile(self):
+ provider = "gh"
+ owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ )
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ redirect_url = (
+ self.mixin_instance.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url
+
+ def test_adjust_redirection_url_is_unchanged_if_no_default_org(self):
+ provider = "gh"
+ owner = OwnerFactory(
+ username="sample-owner-gh",
+ service="github",
+ )
+ # OwnerProfile implicitly has no default org
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ redirect_url = (
+ self.mixin_instance.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url
+
+ def test_adjust_redirection_url_user_has_a_default_org_for_github(self):
+ provider = "gh"
+ default_org_username = "sample-org-gh"
+ organization = OwnerFactory(username=default_org_username, service="github")
+ owner = OwnerFactory(
+ username="sample-owner-gh",
+ service="github",
+ organizations=[organization.ownerid],
+ )
+ OwnerProfile.objects.filter(owner_id=owner.ownerid).update(
+ default_org=organization
+ )
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ redirect_url = (
+ self.mixin_instance.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url + f"/{default_org_username}"
+
+ def test_adjust_redirection_url_user_has_a_default_org_for_gitlab(self):
+ provider = "gl"
+ default_org_username = "sample-org-gl"
+ organization = OwnerFactory(username=default_org_username, service="gitlab")
+ owner = OwnerFactory(
+ username="sample-owner-gl",
+ service="gitlab",
+ organizations=[organization.ownerid],
+ )
+ OwnerProfile.objects.filter(owner_id=owner.ownerid).update(
+ default_org=organization
+ )
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ mixin_instance_gitlab = LoginMixin()
+ mixin_instance_gitlab.service = "gitlab"
+
+ redirect_url = (
+ mixin_instance_gitlab.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url + f"/{default_org_username}"
+
+ def test_adjust_redirection_url_user_has_a_default_org_for_bitbucket(self):
+ provider = "bb"
+ default_org_username = "sample-org-bb"
+ organization = OwnerFactory(username=default_org_username, service="bitbucket")
+ owner = OwnerFactory(
+ username="sample-owner-bb",
+ service="bitbucket",
+ organizations=[organization.ownerid],
+ )
+ OwnerProfile.objects.filter(owner_id=owner.ownerid).update(
+ default_org=organization
+ )
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ mixin_instance_bitbucket = LoginMixin()
+ mixin_instance_bitbucket.service = "bitbucket"
+
+ redirect_url = (
+ mixin_instance_bitbucket.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url + f"/{default_org_username}"
+
+ def test_adjust_redirection_url_user_has_a_default_org_for_github_long_org_name(
+ self,
+ ):
+ provider = "github"
+ default_org_username = "sample-org-gh"
+ organization = OwnerFactory(username=default_org_username, service="github")
+ owner = OwnerFactory(
+ username="sample-owner-gh",
+ service="github",
+ organizations=[organization.ownerid],
+ )
+ # OwnerProfiles get created automatically, so we need to fetch and update the entry manually
+ OwnerProfile.objects.filter(owner_id=owner.ownerid).update(
+ default_org=organization
+ )
+
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{provider}"
+
+ redirect_url = (
+ self.mixin_instance.modify_redirection_url_based_on_default_user_org(
+ url, owner
+ )
+ )
+ assert redirect_url == url + f"/{default_org_username}"
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_unauthenticated_with_claimed_owner(self):
+ self.request.user = None
+ owner = OwnerFactory()
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ assert self.request.user == owner.user
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_unauthenticated_with_unclaimed_owner(self):
+ self.request.user = None
+ owner = OwnerFactory(user=None)
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ # creates new user
+ assert self.request.user == owner.user
+ assert self.request.user.email == owner.email
+ assert self.request.user.name == owner.name
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_authenticated_with_unclaimed_owner(self):
+ user = UserFactory()
+ owner = OwnerFactory(user=None)
+ self.request.user = user
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ owner.refresh_from_db()
+ assert owner.user == user
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_authenticated_with_existing_service_owner(self):
+ user = UserFactory()
+ OwnerFactory(service="github", user=user)
+ owner = OwnerFactory(user=None, service="github")
+ self.request.user = user
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ owner.refresh_from_db()
+
+ # logs in new user
+ assert self.request.user is not None
+ assert self.request.user != user
+
+ # and claims owner w/ that new user
+ assert owner.user is not None
+ assert owner.user != user
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_authenticated_with_claimed_owner(self):
+ user = UserFactory()
+ owner = OwnerFactory(service="github")
+ self.request.user = user
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ owner.refresh_from_db()
+
+ assert self.request.user == owner.user
+
+ # does not re-claim owner
+ assert owner.user is not None
+ assert owner.user != user
+
+ @patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
+ def test_login_owner_with_expired_login_session(self):
+ user = UserFactory()
+ owner = OwnerFactory(service="github", user=user)
+
+ another_user = UserFactory()
+ another_owner = OwnerFactory(service="github", user=another_user)
+
+ now = datetime.now(timezone.utc)
+
+ # Create a session that will be deleted
+ to_be_deleted_1 = SessionFactory(
+ owner=owner,
+ type="login",
+ name="to_be_deleted",
+ lastseen="2021-01-01T00:00:00+00:00",
+ login_session=DjangoSessionFactory(expire_date=now - timedelta(days=1)),
+ )
+ to_be_deleted_1_session_key = to_be_deleted_1.login_session.session_key
+
+ # Create a session that will not be deleted because its not a login session
+ to_be_kept_1 = SessionFactory(
+ owner=owner,
+ type="api",
+ name="to_be_kept",
+ lastseen="2021-01-01T00:00:00+00:00",
+ login_session=DjangoSessionFactory(expire_date=now + timedelta(days=1)),
+ )
+
+ # Create a session that will not be deleted because it's not expired
+ to_be_kept_2 = SessionFactory(
+ owner=owner,
+ type="login",
+ name="to_be_kept",
+ lastseen="2021-01-01T00:00:00+00:00",
+ login_session=DjangoSessionFactory(expire_date=now + timedelta(days=1)),
+ )
+
+ # Create a session that will not be deleted because it's not the owner's session
+ to_be_kept_3 = SessionFactory(
+ owner=another_owner,
+ type="login",
+ name="to_be_kept",
+ lastseen="2021-01-01T00:00:00+00:00",
+ login_session=DjangoSessionFactory(expire_date=now - timedelta(seconds=1)),
+ )
+
+ assert (
+ len(DjangoSession.objects.filter(session_key=to_be_deleted_1_session_key))
+ == 1
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_1.login_session.session_key
+ )
+ )
+ == 1
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_2.login_session.session_key
+ )
+ )
+ == 1
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_3.login_session.session_key
+ )
+ )
+ == 1
+ )
+
+ self.request.user = user
+ self.mixin_instance.login_owner(owner, self.request, HttpResponse())
+ owner.refresh_from_db()
+
+ new_login_session = Session.objects.filter(name=None)
+
+ assert len(new_login_session) == 1
+ assert len(Session.objects.filter(name="to_be_deleted").all()) == 0
+ assert len(Session.objects.filter(name="to_be_kept").all()) == 3
+
+ assert (
+ len(DjangoSession.objects.filter(session_key=to_be_deleted_1_session_key))
+ == 0
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_1.login_session.session_key
+ )
+ )
+ == 1
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_2.login_session.session_key
+ )
+ )
+ == 1
+ )
+ assert (
+ len(
+ DjangoSession.objects.filter(
+ session_key=to_be_kept_3.login_session.session_key
+ )
+ )
+ == 1
+ )
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket.py
new file mode 100644
index 0000000000..a854c86cf0
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket.py
@@ -0,0 +1,217 @@
+from unittest.mock import call, patch
+
+from django.core import signing
+from django.http.cookie import SimpleCookie
+from django.test import TestCase
+from django.urls import reverse
+from shared.torngit.bitbucket import Bitbucket
+from shared.torngit.exceptions import TorngitServer5xxCodeError
+
+from codecov_auth.models import Owner
+from codecov_auth.views.bitbucket import BitbucketLoginView
+from utils.encryption import encryptor
+
+
+def test_get_bitbucket_redirect(client, settings, mocker):
+ mocked_get = mocker.patch.object(
+ Bitbucket,
+ "generate_request_token",
+ return_value={
+ "oauth_token": "testy6r2of6ajkmrub",
+ "oauth_token_secret": "testzibw5q01scpl8qeeupzh8u9yu8hz",
+ },
+ )
+ settings.BITBUCKET_REDIRECT_URI = "http://localhost"
+ settings.BITBUCKET_CLIENT_ID = "testqmo19ebdkseoby"
+ settings.BITBUCKET_CLIENT_SECRET = "testfi8hzehvz453qj8mhv21ca4rf83f"
+ url = reverse("bitbucket-login")
+ res = client.get(url, SERVER_NAME="localhost:8000")
+ assert res.status_code == 302
+
+ assert "_oauth_request_token" in res.cookies
+ cookie = res.cookies["_oauth_request_token"]
+ assert cookie.value
+ assert cookie.get("domain") == settings.COOKIES_DOMAIN
+ assert (
+ res.url
+ == "https://bitbucket.org/api/1.0/oauth/authenticate?oauth_token=testy6r2of6ajkmrub"
+ )
+ mocked_get.assert_called_with(settings.BITBUCKET_REDIRECT_URI)
+
+
+def test_get_bitbucket_redirect_bitbucket_unavailable(client, settings, mocker):
+ mocked_get = mocker.patch.object(
+ Bitbucket, "generate_request_token", side_effect=TorngitServer5xxCodeError()
+ )
+ settings.BITBUCKET_REDIRECT_URI = "http://localhost"
+ settings.BITBUCKET_CLIENT_ID = "testqmo19ebdkseoby"
+ settings.BITBUCKET_CLIENT_SECRET = "testfi8hzehvz453qj8mhv21ca4rf83f"
+ url = reverse("bitbucket-login")
+ res = client.get(url, SERVER_NAME="localhost:8000")
+ assert res.status_code == 302
+ assert "_oauth_request_token" not in res.cookies
+ assert res.url == url
+ mocked_get.assert_called_with(settings.BITBUCKET_REDIRECT_URI)
+
+
+async def fake_get_authenticated_user():
+ return {
+ "username": "ThiagoCodecov",
+ "has_2fa_enabled": None,
+ "display_name": "Thiago Ramos",
+ "account_id": "5bce04c759d0e84f8c7555e9",
+ "links": {
+ "hooks": {
+ "href": "https://bitbucket.org/!api/2.0/users/%7B9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645%7D/hooks"
+ },
+ "self": {
+ "href": "https://bitbucket.org/!api/2.0/users/%7B9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645%7D"
+ },
+ "repositories": {
+ "href": "https://bitbucket.org/!api/2.0/repositories/%7B9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645%7D"
+ },
+ "html": {
+ "href": "https://bitbucket.org/%7B9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645%7D/"
+ },
+ "avatar": {
+ "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/TR-6.png"
+ },
+ "snippets": {
+ "href": "https://bitbucket.org/!api/2.0/snippets/%7B9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645%7D"
+ },
+ },
+ "nickname": "thiago",
+ "created_on": "2018-11-06T12:12:59.588751+00:00",
+ "is_staff": False,
+ "location": None,
+ "account_status": "active",
+ "type": "user",
+ "uuid": "{9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645}",
+ }
+
+
+def test_get_bitbucket_already_token(client, settings, mocker, db, mock_redis):
+ mocker.patch.object(
+ Bitbucket, "get_authenticated_user", side_effect=fake_get_authenticated_user
+ )
+
+ async def fake_list_teams():
+ return []
+
+ mocker.patch.object(Bitbucket, "list_teams", side_effect=fake_list_teams)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ mocked_get = mocker.patch.object(
+ Bitbucket,
+ "generate_access_token",
+ return_value={
+ "key": "test6tl3evq7c8vuyn",
+ "secret": "testdm61tppb5x0tam7nae3qajhcepzz",
+ },
+ )
+ settings.BITBUCKET_REDIRECT_URI = "http://localhost"
+ settings.BITBUCKET_CLIENT_ID = "testqmo19ebdkseoby"
+ settings.BITBUCKET_CLIENT_SECRET = "testfi8hzehvz453qj8mhv21ca4rf83f"
+ settings.CODECOV_DASHBOARD_URL = "dashboard.value"
+ settings.COOKIE_SECRET = "aaaaa"
+ url = reverse("bitbucket-login")
+ oauth_request_token = (
+ "dGVzdDZ0bDNldnE3Yzh2dXlu|dGVzdGRtNjF0cHBiNXgwdGFtN25hZTNxYWpoY2Vweno="
+ )
+ client.cookies = SimpleCookie(
+ {
+ "_oauth_request_token": signing.get_cookie_signer(
+ salt="_oauth_request_token"
+ ).sign(encryptor.encode(oauth_request_token).decode())
+ }
+ )
+ mock_create_user_onboarding_metric = mocker.patch(
+ "shared.django_apps.codecov_metrics.service.codecov_metrics.UserOnboardingMetricsService.create_user_onboarding_metric"
+ )
+
+ res = client.get(
+ url,
+ {"oauth_verifier": 8519288973, "oauth_token": "test1daxl4jnhegoh4"},
+ SERVER_NAME="localhost:8000",
+ )
+ assert res.status_code == 302
+ assert res.url == "dashboard.value/bb"
+ assert "_oauth_request_token" in res.cookies
+ cookie = res.cookies["_oauth_request_token"]
+ assert cookie.value == ""
+ assert cookie.get("domain") == settings.COOKIES_DOMAIN
+ mocked_get.assert_called_with(
+ "test6tl3evq7c8vuyn", "testdm61tppb5x0tam7nae3qajhcepzz", "8519288973"
+ )
+ owner = Owner.objects.get(username="ThiagoCodecov", service="bitbucket")
+ expected_call = call(
+ org_id=owner.ownerid,
+ event="INSTALLED_APP",
+ payload={"login": "bitbucket"},
+ )
+ assert mock_create_user_onboarding_metric.call_args_list == [expected_call]
+
+ assert (
+ encryptor.decode(owner.oauth_token)
+ == "test6tl3evq7c8vuyn:testdm61tppb5x0tam7nae3qajhcepzz"
+ )
+
+
+def test_get_bitbucket_already_token_no_cookie(
+ client, settings, mocker, db, mock_redis
+):
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ mocked_get = mocker.patch.object(
+ Bitbucket,
+ "generate_access_token",
+ return_value={
+ "key": "test6tl3evq7c8vuyn",
+ "secret": "testdm61tppb5x0tam7nae3qajhcepzz",
+ },
+ )
+ settings.BITBUCKET_REDIRECT_URI = "http://localhost"
+ settings.BITBUCKET_CLIENT_ID = "testqmo19ebdkseoby"
+ settings.BITBUCKET_CLIENT_SECRET = "testfi8hzehvz453qj8mhv21ca4rf83f"
+ url = reverse("bitbucket-login")
+ res = client.get(
+ url,
+ {"oauth_verifier": 8519288973, "oauth_token": "test1daxl4jnhegoh4"},
+ SERVER_NAME="localhost:8000",
+ )
+ assert res.status_code == 302
+ assert res.url == "/login/bitbucket"
+ assert not mocked_get.called
+
+
+class TestBitbucketLoginView(TestCase):
+ def test_fetch_user_data(self):
+ async def fake_list_teams():
+ return []
+
+ with patch.object(
+ Bitbucket, "get_authenticated_user", side_effect=fake_get_authenticated_user
+ ):
+ with patch.object(Bitbucket, "list_teams", side_effect=fake_list_teams):
+ view = BitbucketLoginView()
+ token = {"key": "aaaa", "secret": "bbbb"}
+ res = view.fetch_user_data(token)
+ assert res == {
+ "has_private_access": True,
+ "is_student": False,
+ "orgs": [],
+ "user": {
+ "key": "aaaa",
+ "secret": "bbbb",
+ "id": "9a01f37b-b1b2-40c5-8c5e-1a39f4b5e645",
+ "login": "ThiagoCodecov",
+ },
+ }
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket_server.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket_server.py
new file mode 100644
index 0000000000..e7eb7cc982
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_bitbucket_server.py
@@ -0,0 +1,115 @@
+import pytest
+from django.core import signing
+from django.http.cookie import SimpleCookie
+from django.urls import reverse
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from codecov_auth.models import Owner
+from codecov_auth.views.bitbucket_server import (
+ BitbucketServer,
+)
+from utils.encryption import encryptor
+
+
+def test_get_bbs_redirect(client, settings, mocker):
+ client_request_mock = mocker.patch.object(
+ BitbucketServer,
+ "api",
+ side_effect=lambda *args, **kwargs: dict(
+ oauth_token="SomeToken", oauth_token_secret="SomeTokenSecret"
+ ),
+ )
+ settings.BITBUCKET_SERVER_CLIENT_ID = "this-is-the-important-bit"
+ settings.BITBUCKET_SERVER_URL = "https://my.bitbucketserver.com"
+ url = reverse("bbs-login")
+ res = client.get(url, SERVER_NAME="localhost:8000")
+
+ assert res.status_code == 302
+ assert (
+ res.url
+ == "https://my.bitbucketserver.com/plugins/servlet/oauth/authorize?oauth_token=SomeToken"
+ )
+ client_request_mock.assert_called_with(
+ "POST", f"{settings.BITBUCKET_SERVER_URL}/plugins/servlet/oauth/request-token"
+ )
+
+
+def test_get_bbs_redirect_bitbucket_fails_to_get_request_token(
+ client, settings, mocker
+):
+ def faulty_response(*args, **kwargs):
+ # This is the error class that BitbucketServer.api generates
+ raise TorngitClientGeneralError(500, "data data", "BBS unavailable")
+
+ mocker.patch.object(
+ BitbucketServer,
+ "api",
+ side_effect=faulty_response,
+ )
+ settings.BITBUCKET_REDIRECT_URI = "http://localhost"
+ settings.CODECOV_DASHBOARD_URL = "dashboard.value"
+ settings.BITBUCKET_CLIENT_ID = "testqmo19ebdkseoby"
+ settings.BITBUCKET_CLIENT_SECRET = "testfi8hzehvz453qj8mhv21ca4rf83f"
+ with pytest.raises(TorngitClientGeneralError):
+ client.get(reverse("bbs-login"), SERVER_NAME="localhost:8000")
+
+
+def test_get_bbs_already_token(client, settings, mocker, db, mock_redis):
+ settings.BITBUCKET_SERVER_CLIENT_ID = "this-is-the-important-bit"
+ settings.BITBUCKET_SERVER_URL = "https://my.bitbucketserver.com"
+ settings.BITBUCKET_SERVER_REDIRECT_URI = "http://localhost"
+ settings.CODECOV_DASHBOARD_URL = "dashboard.value"
+ settings.COOKIE_SECRET = "aaaaa"
+
+ async def fake_list_teams():
+ return []
+
+ async def fake_api(method, url):
+ if method == "POST" and (
+ url.endswith("/plugins/servlet/oauth/access-token")
+ or url.endswith("/plugins/servlet/oauth/request-token")
+ ):
+ return dict(oauth_token="SomeToken", oauth_token_secret="SomeTokenSecret")
+ elif method == "GET" and url.endswith("/plugins/servlet/applinks/whoami"):
+ return "ThiagoCodecov"
+ elif method == "GET" and ("/users/ThiagoCodecov" in url):
+ return dict(
+ name="ThiagoCodecov", id=101, displayName="Thiago Codecov", active=True
+ )
+
+ mocker.patch.object(BitbucketServer, "list_teams", side_effect=fake_list_teams)
+ client_request_mock = mocker.patch.object(
+ BitbucketServer, "api", side_effect=fake_api
+ )
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ url = reverse("bbs-login")
+ oauth_request_token = (
+ "dGVzdDZ0bDNldnE3Yzh2dXlu|dGVzdGRtNjF0cHBiNXgwdGFtN25hZTNxYWpoY2Vweno="
+ )
+ client.cookies = SimpleCookie(
+ {
+ "_oauth_request_token": signing.get_cookie_signer(
+ salt="_oauth_request_token"
+ ).sign(encryptor.encode(oauth_request_token).decode())
+ }
+ )
+ res = client.get(
+ url,
+ {"oauth_verifier": 8519288973, "oauth_token": "test1daxl4jnhegoh4"},
+ SERVER_NAME="localhost:8000",
+ )
+ client_request_mock.assert_called()
+ assert res.status_code == 302
+ assert res.url == "dashboard.value/bbs"
+ assert "_oauth_request_token" in res.cookies
+ cookie = res.cookies["_oauth_request_token"]
+ assert cookie.value == ""
+ assert cookie.get("domain") == settings.COOKIES_DOMAIN
+ owner = Owner.objects.get(username="ThiagoCodecov", service="bitbucket_server")
+ assert encryptor.decode(owner.oauth_token) == "SomeToken:SomeTokenSecret"
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_github.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_github.py
new file mode 100644
index 0000000000..958dc3729c
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_github.py
@@ -0,0 +1,505 @@
+import re
+from datetime import datetime
+from unittest.mock import call
+
+import pytest
+from django.http.cookie import SimpleCookie
+from django.test import override_settings
+from django.urls import reverse
+from django.utils import timezone
+from freezegun import freeze_time
+from shared.config import ConfigHelper
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.torngit import Github
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from codecov_auth.models import Owner
+from codecov_auth.views.github import GithubLoginView
+
+
+def _get_state_from_redis(mock_redis):
+ key_redis = mock_redis.keys("*")[0].decode()
+ return key_redis.replace("oauth-state-", "")
+
+
+@override_settings(GITHUB_CLIENT_ID="testclientid")
+@pytest.mark.django_db
+def test_get_github_redirect(client, mocker, mock_redis, settings):
+ settings.IS_ENTERPRISE = False
+
+ url = reverse("github-login")
+ res = client.get(url)
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://github.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook&client_id=testclientid&state={state}"
+ )
+
+
+@override_settings(GITHUB_CLIENT_ID="testclientid")
+@pytest.mark.django_db
+def test_get_github_redirect_host_override(client, mocker, mock_redis, settings):
+ settings.IS_ENTERPRISE = False
+ config = ConfigHelper()
+ gh_data = {"github": {"url": "https://fake.com", "host_override": "extrafake.com"}}
+
+ def fake_config(*path, default=None):
+ curr = config
+ for key in path:
+ if key in gh_data:
+ curr = gh_data.get(key)
+ elif key in curr:
+ curr = curr.get(key)
+ else:
+ return default
+ return curr
+
+ mocker.patch(
+ "shared.torngit.github.get_config",
+ side_effect=fake_config,
+ )
+ url = reverse("github-login")
+ res = client.get(url)
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://extrafake.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook&client_id=testclientid&state={state}"
+ )
+
+
+@override_settings(GITHUB_CLIENT_ID="testclientid")
+@pytest.mark.django_db
+def test_get_github_redirect_with_ghpr_cookie(client, mocker, mock_redis, settings):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+ client.cookies = SimpleCookie({"ghpr": "true"})
+
+ url = reverse("github-login")
+ res = client.get(url)
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://github.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook%2Crepo&client_id=testclientid&state={state}"
+ )
+ assert "ghpr" in res.cookies
+ ghpr_cooke = res.cookies["ghpr"]
+ assert ghpr_cooke.value == "true"
+ assert ghpr_cooke.get("domain") == ".simple.site"
+
+
+@override_settings(GITHUB_CLIENT_ID="testclientid")
+@pytest.mark.django_db
+def test_get_github_redirect_with_private_url(client, mocker, mock_redis, settings):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ url = reverse("github-login")
+ res = client.get(url, {"private": "true"})
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://github.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook%2Crepo&client_id=testclientid&state={state}"
+ )
+ assert "ghpr" in res.cookies
+ ghpr_cooke = res.cookies["ghpr"]
+ assert ghpr_cooke.value == "true"
+ assert ghpr_cooke.get("domain") == ".simple.site"
+
+
+def test_get_github_already_with_code(client, mocker, db, mock_redis, settings):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+ now = datetime.now()
+ now_tz = timezone.now()
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "node_id": "MDQ6VXNlcjQ0Mzc2OTkx",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/44376991?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/ThiagoCodecov",
+ "html_url": "https://github.com/ThiagoCodecov",
+ "followers_url": "https://api.github.com/users/ThiagoCodecov/followers",
+ "following_url": "https://api.github.com/users/ThiagoCodecov/following{/other_user}",
+ "gists_url": "https://api.github.com/users/ThiagoCodecov/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ThiagoCodecov/subscriptions",
+ "organizations_url": "https://api.github.com/users/ThiagoCodecov/orgs",
+ "repos_url": "https://api.github.com/users/ThiagoCodecov/repos",
+ "events_url": "https://api.github.com/users/ThiagoCodecov/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/ThiagoCodecov/received_events",
+ "type": "User",
+ "site_admin": False,
+ "name": "Thiago",
+ "company": "@codecov ",
+ "blog": "",
+ "location": None,
+ "email": None,
+ "hireable": None,
+ "bio": None,
+ "twitter_username": None,
+ "public_repos": 3,
+ "public_gists": 0,
+ "followers": 0,
+ "following": 0,
+ "created_at": "2018-10-22T17:51:44Z",
+ "updated_at": "2020-10-14T17:58:13Z",
+ "access_token": "test3k5zz19xqwhgr3eitwcm0lis74s9o0dlovnr",
+ "token_type": "bearer",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return False
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Github, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch.object(Github, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+
+ url = reverse("github-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "44376991"
+ assert owner.email is None
+ assert owner.private_access is True
+ assert owner.service == "github"
+ assert owner.name == "Thiago"
+ assert owner.oauth_token is not None # cannot test exact value
+ assert owner.stripe_customer_id is None
+ assert owner.stripe_subscription_id is None
+ assert owner.createstamp > now_tz
+ assert owner.service_id == "44376991"
+ assert owner.parent_service_id is None
+ assert owner.root_parent_service_id is None
+ assert not owner.staff
+ assert owner.cache is None
+ assert owner.plan == DEFAULT_FREE_PLAN
+ assert owner.plan_provider is None
+ assert owner.plan_user_count == 1
+ assert owner.plan_auto_activate is True
+ assert owner.plan_activated_users is None
+ assert owner.did_trial is None
+ assert owner.free == 0
+ assert owner.invoice_details is None
+ assert owner.delinquent is None
+ assert owner.yaml is None
+ assert owner.updatestamp > now
+ assert owner.admins is None
+ assert owner.integration_id is None
+ assert owner.permission is None
+ assert owner.bot is None
+ assert owner.student is False
+ assert owner.student_created_at is None
+ assert owner.student_updated_at is None
+ # testing orgs
+ assert owner.organizations is not None
+ assert len(owner.organizations) == 1
+ org = Owner.objects.get(ownerid=owner.organizations[0])
+ assert org.service_id == "8226205"
+ assert org.service == "github"
+ assert res.url == "http://localhost:3000/gh"
+
+
+def test_get_github_already_with_code_github_error(
+ client, mocker, db, mock_redis, settings
+):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ raise TorngitClientGeneralError(403, "response", "message")
+
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ url = reverse("github-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ assert "current_owner_id" not in client.session
+ assert res.url == "/"
+
+
+def test_state_not_known(client, mocker, db, mock_redis, settings):
+ url = reverse("github-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "doesnt exist"})
+ assert res.status_code == 302
+ assert "current_owner_id" not in client.session
+
+
+@freeze_time("2023-02-01T00:00:00")
+def test_get_github_already_with_code_with_email(
+ client, mocker, db, mock_redis, settings
+):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "email": "thiago@codecov.io",
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "access_token": "testh04ph89fx0nkd3diauxcw75fyiuo3b86fw4j",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return True
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Github, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch.object(Github, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+ url = reverse("github-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "44376991"
+ assert owner.email == "thiago@codecov.io"
+ assert owner.private_access is True
+ assert res.url == "http://localhost:3000/gh"
+
+
+@freeze_time("2023-01-01T00:00:00")
+def test_get_github_already_with_code_is_student(
+ client, mocker, db, mock_redis, settings
+):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "access_token": "testh04ph89fx0nkd3diauxcw75fyiuo3b86fw4j",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return True
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Github, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch.object(Github, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ mock_create_user_onboarding_metric = mocker.patch(
+ "shared.django_apps.codecov_metrics.service.codecov_metrics.UserOnboardingMetricsService.create_user_onboarding_metric"
+ )
+
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+ url = reverse("github-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ expected_call = call(
+ org_id=client.session["current_owner_id"],
+ event="INSTALLED_APP",
+ payload={"login": "github"},
+ )
+ assert mock_create_user_onboarding_metric.call_args_list == [expected_call]
+
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.email is None
+ assert owner.service_id == "44376991"
+ assert owner.private_access is True
+ assert res.url == "http://localhost:3000/gh"
+ assert owner.student is True
+
+
+@freeze_time("2023-01-01T00:00:00")
+def test_get_github_already_owner_already_exist(
+ client, mocker, db, mock_redis, settings
+):
+ the_bot = OwnerFactory.create(service="github")
+ the_bot.save()
+ owner = OwnerFactory.create(bot=the_bot, service="github", service_id="44376991")
+ owner.save()
+ old_ownerid = owner.ownerid
+ assert owner.bot is not None
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "access_token": "testh04ph89fx0nkd3diauxcw75fyiuo3b86fw4j",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return True
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Github, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch.object(Github, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+
+ url = reverse("github-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.ownerid == old_ownerid
+ assert owner.bot is None
+ assert owner.service_id == "44376991"
+ assert owner.private_access is True
+ assert res.url == "http://localhost:3000/gh"
+
+
+@pytest.mark.asyncio
+@override_settings(IS_ENTERPRISE=True)
+async def test__get_teams_info(client, mocker):
+ github = GithubLoginView()
+ repo_service = Github()
+
+ async def helper_api(*args):
+ url: str = args[2]
+ if url.startswith("/user/teams"):
+ match = re.search(r"&page=(\d+)", url)
+ page_number = match.group(1)
+ if page_number == "1":
+ return [dict(name="My team")]
+ elif page_number == "2":
+ return [dict(name="My team in another page")]
+ return []
+ return None
+
+ mocker.patch.object(Github, "api", side_effect=helper_api)
+ result = await github._get_teams_data(repo_service)
+ assert result == [dict(name="My team"), dict(name="My team in another page")]
+
+
+@pytest.mark.asyncio
+@override_settings(IS_ENTERPRISE=True)
+async def test__get_teams_info_fails(client, mocker):
+ github = GithubLoginView()
+ repo_service = Github()
+
+ async def helper_api(*args):
+ raise TorngitClientGeneralError(
+ status_code=500,
+ response_data=dict(error="generic error"),
+ message="generic error",
+ )
+
+ mocker.patch.object(Github, "api", side_effect=helper_api)
+
+ result = await github._get_teams_data(repo_service)
+ assert result == []
+
+
+def test_get_github_missing_access_token(client, mocker, db, mock_redis, settings):
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "id": 44376991,
+ }
+
+ mocker.patch.object(Github, "get_authenticated_user", side_effect=helper_func)
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gh")
+ session = client.session
+ session["github_oauth_state"] = "abc"
+ session.save()
+ url = reverse("github-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+ assert res.headers["Location"] == "/"
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_github_enterprise.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_github_enterprise.py
new file mode 100644
index 0000000000..07b8626877
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_github_enterprise.py
@@ -0,0 +1,383 @@
+from datetime import datetime
+
+import pytest
+from django.http.cookie import SimpleCookie
+from django.urls import reverse
+from django.utils import timezone
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.torngit import GithubEnterprise
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from codecov_auth.models import Owner
+
+
+def _get_state_from_redis(mock_redis):
+ key_redis = mock_redis.keys("*")[0].decode()
+ return key_redis.replace("oauth-state-", "")
+
+
+@pytest.mark.django_db
+def test_get_ghe_redirect(client, mocker, mock_redis, settings):
+ mock_get_config = mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.IS_ENTERPRISE = True
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ url = reverse("ghe-login")
+ res = client.get(url)
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://my.githubenterprise.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook%2Crepo&client_id=3d44be0e772666136a13&state={state}"
+ )
+ mock_get_config.assert_called_with("github_enterprise", "url")
+
+
+@pytest.mark.django_db
+def test_get_ghe_redirect_with_ghpr_cookie(client, mocker, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+ client.cookies = SimpleCookie({"ghpr": "true"})
+ url = reverse("ghe-login")
+ res = client.get(url)
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://my.githubenterprise.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook%2Crepo&client_id=3d44be0e772666136a13&state={state}"
+ )
+ assert "ghpr" in res.cookies
+ ghpr_cooke = res.cookies["ghpr"]
+ assert ghpr_cooke.value == "true"
+ assert ghpr_cooke.get("domain") == ".simple.site"
+
+
+@pytest.mark.django_db
+def test_get_github_redirect_with_private_url(client, mocker, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+ url = reverse("ghe-login")
+ res = client.get(url, {"private": "true"})
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://my.githubenterprise.com/login/oauth/authorize?response_type=code&scope=user%3Aemail%2Cread%3Aorg%2Crepo%3Astatus%2Cwrite%3Arepo_hook%2Crepo&client_id=3d44be0e772666136a13&state={state}"
+ )
+ assert "ghpr" in res.cookies
+ ghpr_cooke = res.cookies["ghpr"]
+ assert ghpr_cooke.value == "true"
+ assert ghpr_cooke.get("domain") == ".simple.site"
+
+
+def test_get_ghe_already_with_code(client, mocker, db, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+ now = datetime.now()
+ now_tz = timezone.now()
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "node_id": "MDQ6VXNlcjQ0Mzc2OTkx",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/44376991?v=4",
+ "gravatar_id": "",
+ "url": "https://api.githubenterprise.com/users/ThiagoCodecov",
+ "html_url": "https://github.com/ThiagoCodecov",
+ "followers_url": "https://api.githubenterprise.com/users/ThiagoCodecov/followers",
+ "following_url": "https://api.githubenterprise.com/users/ThiagoCodecov/following{/other_user}",
+ "gists_url": "https://api.githubenterprise.com/users/ThiagoCodecov/gists{/gist_id}",
+ "starred_url": "https://api.githubenterprise.com/users/ThiagoCodecov/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.githubenterprise.com/users/ThiagoCodecov/subscriptions",
+ "organizations_url": "https://api.githubenterprise.com/users/ThiagoCodecov/orgs",
+ "repos_url": "https://api.githubenterprise.com/users/ThiagoCodecov/repos",
+ "events_url": "https://api.githubenterprise.com/users/ThiagoCodecov/events{/privacy}",
+ "received_events_url": "https://api.githubenterprise.com/users/ThiagoCodecov/received_events",
+ "type": "User",
+ "site_admin": False,
+ "name": "Thiago",
+ "company": "@codecov ",
+ "blog": "",
+ "location": None,
+ "email": None,
+ "hireable": None,
+ "bio": None,
+ "twitter_username": None,
+ "public_repos": 3,
+ "public_gists": 0,
+ "followers": 0,
+ "following": 0,
+ "created_at": "2018-10-22T17:51:44Z",
+ "updated_at": "2020-10-14T17:58:13Z",
+ "access_token": "test3k5zz19xqwhgr3eitwcm0lis74s9o0dlovnr",
+ "token_type": "bearer",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return False
+
+ mocker.patch.object(
+ GithubEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ mocker.patch.object(
+ GithubEnterprise, "list_teams", side_effect=helper_list_teams_func
+ )
+ mocker.patch.object(GithubEnterprise, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ session = client.session
+ session["github_enterprise_oauth_state"] = "abc"
+ session.save()
+
+ url = reverse("ghe-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/ghe")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "44376991"
+ assert owner.email is None
+ assert owner.private_access is True
+ assert owner.service == "github_enterprise"
+ assert owner.name == "Thiago"
+ assert owner.oauth_token is not None # cannot test exact value
+ assert owner.stripe_customer_id is None
+ assert owner.stripe_subscription_id is None
+ assert owner.createstamp > now_tz
+ assert owner.service_id == "44376991"
+ assert owner.parent_service_id is None
+ assert owner.root_parent_service_id is None
+ assert not owner.staff
+ assert owner.cache is None
+ assert owner.plan == DEFAULT_FREE_PLAN
+ assert owner.plan_provider is None
+ assert owner.plan_user_count == 1
+ assert owner.plan_auto_activate is True
+ assert owner.plan_activated_users is None
+ assert owner.did_trial is None
+ assert owner.free == 0
+ assert owner.invoice_details is None
+ assert owner.delinquent is None
+ assert owner.yaml is None
+ assert owner.updatestamp > now
+ assert owner.admins is None
+ assert owner.integration_id is None
+ assert owner.permission is None
+ assert owner.bot is None
+ assert owner.student is False
+ assert owner.student_created_at is None
+ assert owner.student_updated_at is None
+ # testing orgs
+ assert owner.organizations is not None
+ assert len(owner.organizations) == 1
+ org = Owner.objects.get(ownerid=owner.organizations[0])
+ assert org.service_id == "8226205"
+ assert org.service == "github_enterprise"
+ assert res.url == "http://localhost:3000/ghe"
+
+
+def test_get_ghe_already_with_code_github_error(
+ client, mocker, db, mock_redis, settings
+):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ raise TorngitClientGeneralError(403, "response", "message")
+
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/ghe")
+ session = client.session
+ session["github_enterprise_oauth_state"] = "abc"
+ session.save()
+
+ mocker.patch.object(
+ GithubEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ url = reverse("ghe-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+ assert "github_enterprise-token" not in res.cookies
+ assert "github_enterprise-username" not in res.cookies
+ assert res.url == "/"
+
+
+def test_state_not_known(client, mocker, db, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ url = reverse("ghe-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "doesnt exist"})
+ assert res.status_code == 302
+ assert "github_enterprise-token" not in res.cookies
+ assert "github_enterprise-username" not in res.cookies
+
+
+def test_get_ghe_already_with_code_with_email(client, mocker, db, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "email": "thiago@codecov.io",
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "access_token": "testh04ph89fx0nkd3diauxcw75fyiuo3b86fw4j",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return False
+
+ mocker.patch.object(
+ GithubEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ mocker.patch.object(
+ GithubEnterprise, "list_teams", side_effect=helper_list_teams_func
+ )
+ mocker.patch.object(GithubEnterprise, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ session = client.session
+ session["github_enterprise_oauth_state"] = "abc"
+ session.save()
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/ghe")
+ url = reverse("ghe-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "44376991"
+ assert owner.email == "thiago@codecov.io"
+ assert owner.private_access is True
+ assert res.url == "http://localhost:3000/ghe"
+
+
+def test_get_ghe_already_owner_already_exist(client, mocker, db, mock_redis, settings):
+ mocker.patch(
+ "shared.torngit.github_enterprise.get_config",
+ side_effect=lambda *args: "https://my.githubenterprise.com",
+ )
+ settings.GITHUB_ENTERPRISE_CLIENT_ID = "3d44be0e772666136a13"
+ the_bot = OwnerFactory.create(service="github_enterprise")
+ the_bot.save()
+ owner = OwnerFactory.create(
+ bot=the_bot, service="github_enterprise", service_id="44376991"
+ )
+ owner.save()
+ old_ownerid = owner.ownerid
+ assert owner.bot is not None
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "secret"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "login": "ThiagoCodecov",
+ "id": 44376991,
+ "access_token": "testh04ph89fx0nkd3diauxcw75fyiuo3b86fw4j",
+ "scope": "read:org,repo:status,user:email,write:repo_hook,repo",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ async def is_student(*args, **kwargs):
+ return True
+
+ mocker.patch.object(
+ GithubEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ mocker.patch.object(
+ GithubEnterprise, "list_teams", side_effect=helper_list_teams_func
+ )
+ mocker.patch.object(GithubEnterprise, "is_student", side_effect=is_student)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ url = reverse("ghe-login")
+ session = client.session
+ session["github_enterprise_oauth_state"] = "abc"
+ session.save()
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/ghe")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.ownerid == old_ownerid
+ assert owner.bot is None
+ assert owner.service_id == "44376991"
+ assert owner.private_access is True
+ assert res.url == "http://localhost:3000/ghe"
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab.py
new file mode 100644
index 0000000000..c6a0e4e50b
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab.py
@@ -0,0 +1,179 @@
+from unittest.mock import call
+from uuid import UUID
+
+import pytest
+from django.urls import reverse
+from shared.torngit import Gitlab
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from codecov_auth.models import Owner
+from utils.encryption import encryptor
+
+
+def _get_state_from_redis(mock_redis):
+ key_redis = mock_redis.keys("*")[0].decode()
+ return key_redis.replace("oauth-state-", "")
+
+
+@pytest.mark.django_db
+def test_get_gitlab_redirect(client, settings, mock_redis, mocker):
+ mocker.patch(
+ "codecov_auth.views.gitlab.uuid4",
+ return_value=UUID("fbdf86c6c8d64ed1b814e80b33df85c9"),
+ )
+ settings.GITLAB_CLIENT_ID = (
+ "testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth"
+ )
+ settings.GITLAB_CLIENT_SECRET = (
+ "testi1iinnfrhnf2q6htycgexmp04f1z2mrd7w7u8bigskhwq2km6yls8e2mddzh"
+ )
+ settings.GITLAB_REDIRECT_URI = "http://localhost/login/gitlab"
+ url = reverse("gitlab-login")
+ res = client.get(url, SERVER_NAME="localhost:8000")
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://gitlab.com/oauth/authorize?response_type=code&client_id=testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth&redirect_uri=http%3A%2F%2Flocalhost%2Flogin%2Fgitlab&state={state}&scope=api"
+ )
+
+
+def test_get_gitlab_already_with_code(client, mocker, db, settings, mock_redis):
+ settings.GITLAB_CLIENT_ID = (
+ "testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth"
+ )
+ settings.GITLAB_CLIENT_SECRET = (
+ "testi1iinnfrhnf2q6htycgexmp04f1z2mrd7w7u8bigskhwq2km6yls8e2mddzh"
+ )
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "cookie-secret"
+
+ access_token = "testp2twc8gxedplfn91tm4zn4r4ak2xgyr4ug96q86r2gr0re0143f20nuftka8"
+ refresh_token = "testqyuk6z4s086jcvwoncxz8owl57o30qx1mhxlw3lgqliisujsiakh3ejq91tt"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "id": 3124507,
+ "name": "Thiago Ramos",
+ "username": "ThiagoCodecov",
+ "state": "active",
+ "access_token": access_token,
+ "token_type": "Bearer",
+ "refresh_token": refresh_token,
+ "scope": "api",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ mocker.patch.object(Gitlab, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Gitlab, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+
+ session = client.session
+ session["gitlab_oauth_state"] = "abc"
+ session.save()
+ mock_create_user_onboarding_metric = mocker.patch(
+ "shared.django_apps.codecov_metrics.service.codecov_metrics.UserOnboardingMetricsService.create_user_onboarding_metric"
+ )
+ url = reverse("gitlab-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gl")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "3124507"
+ assert res.url == "http://localhost:3000/gl"
+
+ expected_call = call(
+ org_id=owner.ownerid,
+ event="INSTALLED_APP",
+ payload={"login": "gitlab"},
+ )
+ assert mock_create_user_onboarding_metric.call_args_list == [expected_call]
+
+ assert encryptor.decode(owner.oauth_token) == f"{access_token}: :{refresh_token}"
+
+
+def test_get_gitlab_already_with_code_no_session(
+ client, mocker, db, settings, mock_redis
+):
+ settings.GITLAB_CLIENT_ID = (
+ "testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth"
+ )
+ settings.GITLAB_CLIENT_SECRET = (
+ "testi1iinnfrhnf2q6htycgexmp04f1z2mrd7w7u8bigskhwq2km6yls8e2mddzh"
+ )
+ settings.COOKIES_DOMAIN = ".simple.site"
+ settings.COOKIE_SECRET = "cookie-secret"
+
+ access_token = "testp2twc8gxedplfn91tm4zn4r4ak2xgyr4ug96q86r2gr0re0143f20nuftka8"
+ refresh_token = "testqyuk6z4s086jcvwoncxz8owl57o30qx1mhxlw3lgqliisujsiakh3ejq91tt"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "id": 3124507,
+ "name": "Thiago Ramos",
+ "username": "ThiagoCodecov",
+ "state": "active",
+ "access_token": access_token,
+ "token_type": "Bearer",
+ "refresh_token": refresh_token,
+ "scope": "api",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ mocker.patch.object(Gitlab, "get_authenticated_user", side_effect=helper_func)
+ mocker.patch.object(Gitlab, "list_teams", side_effect=helper_list_teams_func)
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ url = reverse("gitlab-login")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+ assert res.url == "http://localhost:3000/gl"
+
+ assert "current_owner_id" not in client.session
+
+
+def test_get_github_already_with_code_gitlab_error(
+ client, mocker, db, mock_redis, settings
+):
+ settings.COOKIES_DOMAIN = ".simple.site"
+
+ async def helper_func(*args, **kwargs):
+ raise TorngitClientGeneralError(403, "response", "message")
+
+ mocker.patch.object(Gitlab, "get_authenticated_user", side_effect=helper_func)
+ url = reverse("gitlab-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gl")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ assert "current_owner_id" not in client.session
+ assert res.url == "/"
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab_enterprise.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab_enterprise.py
new file mode 100644
index 0000000000..416afdfe73
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_gitlab_enterprise.py
@@ -0,0 +1,121 @@
+from uuid import UUID
+
+import pytest
+from django.test import override_settings
+from django.urls import reverse
+from shared.torngit import GitlabEnterprise
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from codecov_auth.models import Owner
+
+
+def _get_state_from_redis(mock_redis):
+ key_redis = mock_redis.keys("*")[0].decode()
+ return key_redis.replace("oauth-state-", "")
+
+
+@override_settings(
+ GITLAB_ENTERPRISE_CLIENT_ID="testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth"
+)
+@override_settings(
+ GITLAB_ENTERPRISE_CLIENT_SECRET="testi1iinnfrhnf2q6htycgexmp04f1z2mrd7w7u8bigskhwq2km6yls8e2mddzh"
+)
+@override_settings(GITLAB_ENTERPRISE_REDIRECT_URI="http://localhost/login/gle")
+@pytest.mark.django_db
+def test_get_gle_redirect(client, settings, mock_redis, mocker):
+ mock_get_config = mocker.patch(
+ "shared.torngit.gitlab_enterprise.get_config",
+ side_effect=lambda *args: "https://my.gitlabenterprise.com",
+ )
+ mocker.patch(
+ "codecov_auth.views.gitlab.uuid4",
+ return_value=UUID("fbdf86c6c8d64ed1b814e80b33df85c9"),
+ )
+ url = reverse("gle-login")
+ res = client.get(url, SERVER_NAME="localhost:8000")
+ state = _get_state_from_redis(mock_redis)
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"https://my.gitlabenterprise.com/oauth/authorize?response_type=code&client_id=testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth&redirect_uri=http%3A%2F%2Flocalhost%2Flogin%2Fgle&state={state}&scope=api"
+ )
+ mock_get_config.assert_called_with("gitlab_enterprise", "url")
+
+
+@override_settings(
+ GITLAB_ENTERPRISE_CLIENT_ID="testfiuozujcfo5kxgigugr5x3xxx2ukgyandp16x6w566uits7f32crzl4yvmth"
+)
+@override_settings(
+ GITLAB_ENTERPRISE_CLIENT_SECRET="testi1iinnfrhnf2q6htycgexmp04f1z2mrd7w7u8bigskhwq2km6yls8e2mddzh"
+)
+@override_settings(GITLAB_ENTERPRISE_REDIRECT_URI="http://localhost/login/gle")
+def test_get_gle_already_with_code(client, mocker, db, settings, mock_redis):
+ settings.COOKIE_SECRET = "secret"
+ settings.COOKIES_DOMAIN = ".simple.site"
+
+ async def helper_func(*args, **kwargs):
+ return {
+ "id": 3124507,
+ "name": "Thiago Ramos",
+ "username": "ThiagoCodecov",
+ "state": "active",
+ "access_token": "testp2twc8gxedplfn91tm4zn4r4ak2xgyr4ug96q86r2gr0re0143f20nuftka8",
+ "token_type": "Bearer",
+ "refresh_token": "testqyuk6z4s086jcvwoncxz8owl57o30qx1mhxlw3lgqliisujsiakh3ejq91tt",
+ "scope": "api",
+ }
+
+ async def helper_list_teams_func(*args, **kwargs):
+ return [
+ {
+ "email": "hello@codecov.io",
+ "id": "8226205",
+ "name": "Codecov",
+ "username": "codecov",
+ }
+ ]
+
+ mocker.patch.object(
+ GitlabEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ mocker.patch.object(
+ GitlabEnterprise, "list_teams", side_effect=helper_list_teams_func
+ )
+ mocker.patch(
+ "services.task.TaskService.refresh",
+ return_value=mocker.MagicMock(
+ as_tuple=mocker.MagicMock(return_value=("a", "b"))
+ ),
+ )
+ url = reverse("gle-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gle")
+ session = client.session
+ session["gitlab_enterprise_oauth_state"] = "abc"
+ session.save()
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+
+ owner = Owner.objects.get(pk=client.session["current_owner_id"])
+ assert owner.username == "ThiagoCodecov"
+ assert owner.service_id == "3124507"
+ assert res.url == "http://localhost:3000/gle"
+
+
+def test_get_gle_already_with_code_github_error(
+ client, mocker, db, mock_redis, settings
+):
+ settings.COOKIES_DOMAIN = ".simple.site"
+
+ async def helper_func(*args, **kwargs):
+ raise TorngitClientGeneralError(403, "response", "message")
+
+ mocker.patch.object(
+ GitlabEnterprise, "get_authenticated_user", side_effect=helper_func
+ )
+ url = reverse("gle-login")
+ mock_redis.setex("oauth-state-abc", 300, "http://localhost:3000/gle")
+ res = client.get(url, {"code": "aaaaaaa", "state": "abc"})
+ assert res.status_code == 302
+ assert "gitlab_enterprise-token" not in res.cookies
+ assert "gitlab_enterprise-username" not in res.cookies
+ assert res.url == "/"
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_logout.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_logout.py
new file mode 100644
index 0000000000..53132a35fb
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_logout.py
@@ -0,0 +1,38 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from utils.test_utils import Client
+
+
+class LogoutViewTest(TestCase):
+ def _get(self, url):
+ return self.client.get(url, content_type="application/json")
+
+ def _post(self, url):
+ return self.client.post(url, content_type="application/json")
+
+ def _is_authenticated(self):
+ response = self.client.post(
+ "/graphql/gh",
+ {"query": "{ me { user { username } } }"},
+ content_type="application/json",
+ )
+ return response.json()["data"]["me"] is not None
+
+ def test_logout_when_unauthenticated(self):
+ res = self._post("/logout")
+ assert res.status_code == 401
+
+ def test_logout_when_authenticated(self):
+ owner = OwnerFactory()
+ self.client = Client()
+ self.client.force_login_owner(owner)
+
+ res = self._post("/graphql/gh/")
+ self.assertEqual(self._is_authenticated(), True)
+
+ res = self._post("/logout")
+ self.assertEqual(res.status_code, 205)
+
+ res = self._get("/graphql/gh/")
+ self.assertEqual(self._is_authenticated(), False)
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_okta.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta.py
new file mode 100644
index 0000000000..b4acd80431
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta.py
@@ -0,0 +1,346 @@
+import pytest
+from django.conf import settings
+from django.contrib import auth
+from django.test import override_settings
+from django.urls import reverse
+from shared.django_apps.codecov_auth.tests.factories import (
+ OktaUserFactory,
+ OwnerFactory,
+ UserFactory,
+)
+
+from codecov_auth.models import OktaUser
+from codecov_auth.views.okta import OKTA_BASIC_AUTH
+from codecov_auth.views.okta_mixin import OktaIdTokenPayload
+
+
+@pytest.fixture
+def mocked_okta_token_request(mocker):
+ return mocker.patch(
+ "codecov_auth.views.okta_mixin.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=200,
+ json=mocker.MagicMock(
+ return_value={
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "id_token": "test-id-token",
+ "state": "test-state",
+ },
+ ),
+ ),
+ )
+
+
+@pytest.fixture
+def mocked_validate_id_token(mocker):
+ return mocker.patch(
+ "codecov_auth.views.okta.validate_id_token",
+ return_value=OktaIdTokenPayload(
+ sub="test-id",
+ email="test@example.com",
+ name="Some User",
+ iss="https://example.com",
+ aud="test-client-id",
+ ),
+ )
+
+
+# random keypair for RS256 JWTs used below
+public_key = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo
+4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u
++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh
+kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ
+0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg
+cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc
+mwIDAQAB
+-----END PUBLIC KEY-----
+"""
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_redirect_to_authorize(client, db):
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "iss": "https://example.okta.com",
+ },
+ )
+ state = client.session["okta_oauth_state"]
+
+ assert res.status_code == 302
+ assert (
+ res.url
+ == "https://example.okta.com/oauth2/v1/authorize?response_type=code&client_id=test-client-id&scope=openid+email+profile&redirect_uri=https%3A%2F%2Flocalhost%3A8000%2Flogin%2Fokta&state={}".format(
+ state
+ )
+ )
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+ OKTA_ISS=None,
+)
+def test_okta_redirect_to_authorize_no_iss(client):
+ res = client.get(reverse("okta-login"))
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+ OKTA_ISS="https://non.okta.domain",
+)
+def test_okta_redirect_to_authorize_invalid_iss(client):
+ res = client.get(reverse("okta-login"))
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login(
+ client, mocked_okta_token_request, mocked_validate_id_token, db
+):
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ mocked_okta_token_request.assert_called_once_with(
+ "https://example.okta.com/oauth2/v1/token",
+ auth=OKTA_BASIC_AUTH,
+ data={
+ "grant_type": "authorization_code",
+ "code": "test-code",
+ "redirect_uri": "https://localhost:8000/login/okta",
+ "state": state,
+ },
+ )
+
+ mocked_validate_id_token.assert_called_once_with(
+ "https://example.okta.com", "test-id-token", "test-client-id"
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # creates new user records
+ okta_user = OktaUser.objects.get(okta_id="test-id")
+ assert okta_user.access_token == "test-access-token"
+ assert okta_user.email == "test@example.com"
+ assert okta_user.name == "Some User"
+ user = okta_user.user
+ assert user is not None
+ assert user.email == okta_user.email
+ assert user.name == okta_user.name
+
+ # logs in new user
+ current_user = auth.get_user(client)
+ assert user == current_user
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login_authenticated(
+ client, mocked_okta_token_request, mocked_validate_id_token, db
+):
+ user = UserFactory()
+ client.force_login(user=user)
+
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # creates new user records
+ okta_user = OktaUser.objects.get(okta_id="test-id")
+ assert okta_user.access_token == "test-access-token"
+ assert okta_user.email == "test@example.com"
+ assert okta_user.name == "Some User"
+ assert okta_user.user == user
+
+ # leaves user logged in
+ current_user = auth.get_user(client)
+ assert user == current_user
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login_existing_okta_user(
+ client, mocked_okta_token_request, mocked_validate_id_token, db
+):
+ okta_user = OktaUserFactory(okta_id="test-id")
+
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # logs in Okta user
+ current_user = auth.get_user(client)
+ assert current_user == okta_user.user
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login_authenticated_existing_okta_user(
+ client, mocked_okta_token_request, mocked_validate_id_token, db
+):
+ okta_user = OktaUserFactory(okta_id="test-id")
+ other_okta_user = OktaUserFactory()
+
+ client.force_login(user=other_okta_user.user)
+
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # logs in Okta user
+ current_user = auth.get_user(client)
+ assert current_user == okta_user.user
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+@pytest.mark.django_db
+def test_okta_perform_login_existing_okta_user_existing_owner(
+ client, mocked_okta_token_request, mocked_validate_id_token
+):
+ okta_user = OktaUserFactory(okta_id="test-id")
+ OwnerFactory(service="github", user=okta_user.user)
+
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ # redirects to service page
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/gh"
+
+ # logs in Okta user
+ current_user = auth.get_user(client)
+ assert current_user == okta_user.user
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login_error(client, mocker, db):
+ mocker.patch(
+ "codecov_auth.views.okta_mixin.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=401,
+ ),
+ )
+ state = "test-state"
+ session = client.session
+ session["okta_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRET="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_okta_perform_login_state_mismatch(client, mocker, db):
+ res = client.get(
+ reverse("okta-login"),
+ data={
+ "code": "test-code",
+ "state": "invalid-state",
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+ # does not login user
+ current_user = auth.get_user(client)
+ assert current_user.is_anonymous
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_cloud.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_cloud.py
new file mode 100644
index 0000000000..5472454a52
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_cloud.py
@@ -0,0 +1,554 @@
+from logging import LogRecord
+from typing import Any
+from unittest.mock import ANY
+from urllib.parse import unquote, urlparse
+
+import pytest
+from django.test import override_settings
+from pytest import LogCaptureFixture
+from pytest_mock import MockerFixture
+from shared.django_apps.codecov_auth.models import Account, OktaSettings, Owner
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ OktaSettingsFactory,
+)
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.views.okta_cloud import (
+ OKTA_CURRENT_SESSION,
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY,
+)
+from codecov_auth.views.okta_mixin import OktaIdTokenPayload
+from utils.test_utils import Client as TestClient
+
+
+@pytest.fixture
+def signed_in_client() -> TestClient:
+ new_client = TestClient()
+ new_client.force_login_owner(OwnerFactory())
+ return new_client
+
+
+@pytest.fixture
+def okta_org_name() -> str:
+ return "foo-bar-organization"
+
+
+@pytest.fixture
+def okta_org(okta_org_name: str) -> Owner:
+ org: Owner = OwnerFactory.create(username=okta_org_name, service="github")
+ org.save()
+ return org
+
+
+@pytest.fixture
+def okta_account(okta_org: Owner):
+ account = AccountFactory()
+ okta_org.account = account
+ okta_org.save()
+
+ okta_settings: OktaSettings = OktaSettingsFactory(account=account)
+ okta_settings.url = "https://foo-bar.okta.com/"
+ okta_settings.save()
+ return account
+
+
+@pytest.fixture
+def mocked_okta_token_request(mocker):
+ return mocker.patch(
+ "codecov_auth.views.okta_mixin.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=200,
+ json=mocker.MagicMock(
+ return_value={
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "id_token": "test-id-token",
+ "state": "test-state",
+ },
+ ),
+ ),
+ )
+
+
+@pytest.fixture
+def mocked_validate_id_token(mocker):
+ return mocker.patch(
+ "codecov_auth.views.okta_cloud.validate_id_token",
+ return_value=OktaIdTokenPayload(
+ sub="test-id",
+ email="test@example.com",
+ name="Some User",
+ iss="https://example.com",
+ aud="test-client-id",
+ ),
+ )
+
+
+def log_message_exists(message: str, logs: list[LogRecord]) -> bool:
+ """Helper method to check that a particular log record was emitted"""
+ for log in logs:
+ if log.message == message:
+ return True
+ return False
+
+
+@pytest.mark.django_db
+def test_okta_login_unauthenticated_user(
+ client: TestClient,
+ caplog: LogCaptureFixture,
+):
+ res = client.get("/login/okta/github/some-unknown-service")
+ assert log_message_exists(
+ "User needs to be signed in before authenticating organization with Okta.",
+ caplog.records,
+ )
+ assert res.status_code == 403
+
+
+@pytest.mark.django_db
+def test_okta_login_invalid_organization(
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+):
+ res = signed_in_client.get("/login/okta/github/some-unknown-service")
+ assert log_message_exists("The organization doesn't exist.", caplog.records)
+ assert res.status_code == 404
+
+
+@pytest.mark.django_db
+def test_okta_login_no_account(signed_in_client: TestClient, caplog: LogCaptureFixture):
+ org: Owner = OwnerFactory.create(username="org-no-account", service="github")
+ org.save()
+ res = signed_in_client.get("/login/okta/github/org-no-account")
+ assert log_message_exists(
+ "Okta settings not found. Cannot sign into Okta", caplog.records
+ )
+ assert res.status_code == 404
+
+
+@pytest.mark.django_db
+def test_okta_login_no_okta_settings(
+ signed_in_client: TestClient, caplog: LogCaptureFixture
+):
+ org: Owner = OwnerFactory.create(username="account-no-okta", service="github")
+ org.account = AccountFactory()
+ org.save()
+ res = signed_in_client.get("/login/okta/github/account-no-okta")
+ assert log_message_exists(
+ "Okta settings not found. Cannot sign into Okta", caplog.records
+ )
+ assert res.status_code == 404
+
+
+@pytest.mark.django_db
+def test_okta_login_already_signed_into_okta(
+ signed_in_client: TestClient,
+ okta_org_name: str,
+ okta_account: Account,
+):
+ session = signed_in_client.session
+ session[OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY] = [okta_account.id]
+ session.save()
+ res = signed_in_client.get(f"/login/okta/gh/{okta_org_name}")
+ assert res.status_code == 302
+ assert res.url == f"http://localhost:3000/github/{okta_org_name}"
+
+
+@override_settings(
+ CODECOV_API_URL="http://localhost:8000",
+)
+@pytest.mark.django_db
+def test_okta_login_redirect_to_okta_issuer(
+ signed_in_client: TestClient, okta_org_name: str, okta_account: Account
+):
+ res = signed_in_client.get(f"/login/okta/gh/{okta_org_name}")
+ assert res.status_code == 302
+ parsed_url = urlparse(res.url)
+ assert parsed_url.hostname == "foo-bar.okta.com"
+ assert parsed_url.path == "/oauth2/v1/authorize"
+
+ parsed_query = parsed_url.query.split("&")
+ raw_redirect_url = next(x for x in parsed_query if x.startswith("redirect_uri="))
+ assert raw_redirect_url
+ assert (
+ unquote(raw_redirect_url.split("=")[1])
+ == "http://localhost:8000/login/okta/callback"
+ )
+
+
+@pytest.mark.django_db
+def test_okta_callback_login_success(
+ signed_in_client: TestClient,
+ okta_account: Account,
+ okta_org: Owner,
+ mocked_validate_id_token: Any,
+ mocked_okta_token_request: Any,
+):
+ state = "test-state"
+ session = signed_in_client.session
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+ session["okta_cloud_oauth_state"] = state
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"http://localhost:3000/github/{okta_org.username}"
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) == [okta_account.id]
+
+ mocked_validate_id_token.assert_called_with("https://foo-bar.okta.com", ANY, ANY)
+
+
+@pytest.mark.django_db
+def test_okta_callback_login_success_multiple_accounts(
+ signed_in_client: TestClient,
+ okta_account: Account,
+ okta_org: Owner,
+ mocked_validate_id_token: Any,
+ mocked_okta_token_request: Any,
+):
+ state = "test-state"
+ session = signed_in_client.session
+ # Put in a random account that's not current okta_account
+ session[OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY] = [okta_account.id + 1]
+
+ session["okta_cloud_oauth_state"] = state
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"http://localhost:3000/github/{okta_org.username}"
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) == [
+ okta_account.id + 1,
+ okta_account.id,
+ ]
+
+ mocked_validate_id_token.assert_called_with("https://foo-bar.okta.com", ANY, ANY)
+
+
+@pytest.mark.django_db
+def test_okta_callback_missing_session(
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+):
+ session = signed_in_client.session
+ state = "test-state"
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+ assert res.status_code == 403
+
+ assert log_message_exists(
+ "Trying to sign into Okta with no existing sign-in session.", caplog.records
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_missing_user(
+ client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+):
+ session = client.session
+ state = "test-state"
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+ session.save()
+
+ res = client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+ assert res.status_code == 403
+
+ assert log_message_exists("User not logged in for Okta callback.", caplog.records)
+
+ updated_session = client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_missing_okta_settings(
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+):
+ session = signed_in_client.session
+ state = "test-state"
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": 12345,
+ }
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+ assert res.status_code == 404
+
+ assert log_message_exists(
+ "Okta settings not found. Cannot sign into Okta", caplog.records
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_no_code(
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+):
+ session = signed_in_client.session
+ state = "test-state"
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "state": state,
+ },
+ )
+ assert res.status_code == 400
+
+ assert log_message_exists(
+ "No code is passed. Invalid callback. Cannot sign into Okta", caplog.records
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_perform_login_invalid_state(
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+):
+ session = signed_in_client.session
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = "random-state"
+
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+ session.save()
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": "different-state",
+ },
+ )
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"http://localhost:3000/github/{okta_org.username}?error=invalid_state"
+ )
+
+ assert log_message_exists("Invalid state during Okta login", caplog.records)
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_perform_login_no_user_data(
+ mocker: MockerFixture,
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+ mocked_okta_token_request: Any,
+):
+ state = "test-state"
+ session = signed_in_client.session
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+ session.save()
+
+ mocked_okta_token_request.return_value = mocker.MagicMock(
+ status_code=400,
+ )
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"http://localhost:3000/github/{okta_org.username}?error=invalid_token_response"
+ )
+
+ assert log_message_exists(
+ "Can't log in. Invalid Okta Token Response", caplog.records
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_perform_login_invalid_id_token(
+ mocker: MockerFixture,
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+ mocked_okta_token_request: Any,
+):
+ state = "test-state"
+ session = signed_in_client.session
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+
+ session.save()
+
+ mocked_okta_token_request.return_value = mocker.MagicMock(
+ status_code=200,
+ json=lambda: {"access_token": "mock_access_token", "id_token": "mock_id_token"},
+ )
+
+ mocker.patch(
+ "codecov_auth.views.okta_cloud.validate_id_token",
+ side_effect=Exception("Invalid ID token"),
+ )
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "code": "random-code",
+ "state": state,
+ },
+ )
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"http://localhost:3000/github/{okta_org.username}?error=invalid_id_token"
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+
+
+@pytest.mark.django_db
+def test_okta_callback_perform_login_access_denied(
+ mocker: MockerFixture,
+ signed_in_client: TestClient,
+ caplog: LogCaptureFixture,
+ okta_org: Owner,
+ okta_account: Account,
+ mocked_okta_token_request: Any,
+):
+ state = "test-state"
+ session = signed_in_client.session
+ assert session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
+ session["okta_cloud_oauth_state"] = state
+
+ session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": okta_org.ownerid,
+ "okta_settings_id": okta_account.okta_settings.first().id,
+ }
+ session.save()
+
+ mocked_okta_token_request.return_value = mocker.MagicMock(
+ status_code=403,
+ )
+
+ res = signed_in_client.get(
+ "/login/okta/callback",
+ data={
+ "state": state,
+ "error": "access_denied",
+ },
+ )
+ assert res.status_code == 302
+ assert (
+ res.url
+ == f"http://localhost:3000/github/{okta_org.username}?error=access_denied"
+ )
+
+ updated_session = signed_in_client.session
+ assert updated_session.get(OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY) is None
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_mixin.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_mixin.py
new file mode 100644
index 0000000000..32f612901a
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_okta_mixin.py
@@ -0,0 +1,110 @@
+from unittest.mock import MagicMock, patch
+
+import jwt
+
+from codecov_auth.views.okta import OKTA_BASIC_AUTH, OktaLoginView
+from codecov_auth.views.okta_mixin import validate_id_token
+
+private_key = """-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj
+MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu
+NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ
+qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg
+p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR
+ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi
+VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV
+laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8
+sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H
+mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY
+dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw
+ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ
+DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T
+N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t
+0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv
+t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU
+AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk
+48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL
+DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK
+xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA
+mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh
+2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz
+et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr
+VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD
+TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc
+dn/RsYEONbwQSjIfMPkvxF+8HQ==
+-----END PRIVATE KEY-----
+"""
+
+
+def test_validate_id_token(mocker):
+ data = {
+ "sub": "test-okta-id",
+ "name": "Some User",
+ "email": "test@example.com",
+ "iss": "https://example.okta.com",
+ "aud": "test-client-id",
+ }
+ id_token = jwt.encode(
+ data, private_key, algorithm="RS256", headers={"kid": "test-kid"}
+ )
+
+ # did this offline so as not to need an additional dependency - here's the code
+ # if we ever need to regenerate this:
+ #
+ # from Crypto.PublicKey import RSA
+ # import base64
+ # pub = RSA.importKey(public_key)
+ # modulus = base64.b64encode(pub.n.to_bytes(256, "big")).decode("ascii")
+ # exponent = base64.b64encode(pub.e.to_bytes(3, "big")).decode("ascii")
+
+ exponent = "AQAB"
+ modulus = "u1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmw=="
+
+ get_keys = mocker.patch(
+ "codecov_auth.views.okta_mixin.requests.get",
+ return_value=mocker.MagicMock(
+ status_code=200,
+ json=mocker.MagicMock(
+ return_value={
+ "keys": [
+ {
+ "kty": "RSA",
+ "alg": "RS256",
+ "kid": "test-kid",
+ "use": "sig",
+ "e": exponent,
+ "n": modulus,
+ }
+ ]
+ }
+ ),
+ ),
+ )
+
+ id_payload = validate_id_token(
+ iss="https://example.okta.com", id_token=id_token, client_id="test-client-id"
+ )
+ assert id_payload.sub == "test-okta-id"
+ assert id_payload.name == "Some User"
+ assert id_payload.email == "test@example.com"
+
+ get_keys.assert_called_once_with("https://example.okta.com/oauth2/v1/keys")
+
+
+def test_okta_fetch_user_data_invalid_state(client, db):
+ with patch("codecov_auth.views.okta_mixin.requests.post") as mock_post:
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_post.return_value = mock_response
+
+ with patch.object(OktaLoginView, "verify_state", return_value=False):
+ view = OktaLoginView()
+ res = view._fetch_user_data(
+ "https://example.okta.com",
+ "test-code",
+ "invalid-state",
+ "https://localhost:8000/login/okta",
+ OKTA_BASIC_AUTH,
+ )
+
+ assert res is None
diff --git a/apps/codecov-api/codecov_auth/tests/unit/views/test_sentry.py b/apps/codecov-api/codecov_auth/tests/unit/views/test_sentry.py
new file mode 100644
index 0000000000..265ed5ae79
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/tests/unit/views/test_sentry.py
@@ -0,0 +1,382 @@
+from unittest.mock import MagicMock, patch
+
+import jwt
+import pytest
+from django.conf import settings
+from django.contrib import auth
+from django.test import override_settings
+from django.urls import reverse
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ SentryUserFactory,
+ UserFactory,
+)
+
+from codecov_auth.models import SentryUser
+from codecov_auth.views.sentry import SentryLoginView
+
+
+@pytest.fixture
+def mocked_sentry_request(mocker):
+ return mocker.patch(
+ "codecov_auth.views.sentry.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=200,
+ json=mocker.MagicMock(
+ return_value={
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "user": {
+ "id": "test-id",
+ "email": "test@example.com",
+ "name": "Some User",
+ },
+ "id_token": jwt.encode(
+ {
+ "iss": "https://sentry.io",
+ "aud": "test-client-id",
+ },
+ key="test-oidc-shared-secret",
+ ),
+ }
+ ),
+ ),
+ )
+
+
+@override_settings(SENTRY_OAUTH_CLIENT_ID="test-client-id")
+def test_sentry_redirect_to_consent(client, db):
+ res = client.get(reverse("sentry-login"))
+ state_from_session = client.session["sentry_oauth_state"]
+ assert res.status_code == 302
+ assert (
+ res.url
+ == "https://sentry.io/oauth/authorize?response_type=code&client_id=test-client-id&scope=openid+email+profile&state={}".format(
+ state_from_session
+ )
+ )
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login(client, mocked_sentry_request, db):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ mocked_sentry_request.assert_called_once_with(
+ "https://sentry.io/oauth/token/",
+ data={
+ "grant_type": "authorization_code",
+ "client_id": settings.SENTRY_OAUTH_CLIENT_ID,
+ "client_secret": settings.SENTRY_OAUTH_CLIENT_SECRET,
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # creates new user records
+ sentry_user = SentryUser.objects.get(sentry_id="test-id")
+ assert sentry_user.access_token == "test-access-token"
+ assert sentry_user.refresh_token == "test-refresh-token"
+ assert sentry_user.email == "test@example.com"
+ assert sentry_user.name == "Some User"
+ user = sentry_user.user
+ assert user is not None
+ assert user.email == sentry_user.email
+ assert user.name == sentry_user.name
+
+ # logs in new user
+ current_user = auth.get_user(client)
+ assert user == current_user
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_authenticated(client, mocked_sentry_request, db):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ user = UserFactory()
+ client.force_login(user=user)
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # creates new user records
+ sentry_user = SentryUser.objects.get(sentry_id="test-id")
+ assert sentry_user.access_token == "test-access-token"
+ assert sentry_user.refresh_token == "test-refresh-token"
+ assert sentry_user.email == "test@example.com"
+ assert sentry_user.name == "Some User"
+ assert sentry_user.user == user
+
+ # leaves user logged in
+ current_user = auth.get_user(client)
+ assert user == current_user
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_existing_sentry_user(client, mocked_sentry_request, db):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ sentry_user = SentryUserFactory(sentry_id="test-id")
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # logs in sentry user
+ current_user = auth.get_user(client)
+ assert current_user == sentry_user.user
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_authenticated_existing_sentry_user(
+ client, mocked_sentry_request, db
+):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ sentry_user = SentryUserFactory(sentry_id="test-id")
+ other_sentry_user = SentryUserFactory()
+ client.force_login(user=other_sentry_user.user)
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/sync"
+
+ # logs in sentry user
+ current_user = auth.get_user(client)
+ assert current_user == sentry_user.user
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_existing_sentry_user_existing_owner(
+ client, mocked_sentry_request, db
+):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ sentry_user = SentryUserFactory(sentry_id="test-id")
+ OwnerFactory(service="github", user=sentry_user.user)
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ # redirects to service page
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/gh"
+
+ # logs in sentry user
+ current_user = auth.get_user(client)
+ assert current_user == sentry_user.user
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_error(client, mocker, db):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ mocker.patch(
+ "codecov_auth.views.sentry.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=401,
+ ),
+ )
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="invalid-oidc-shared-secret",
+)
+def test_sentry_perform_login_invalid_id_token(client, mocked_sentry_request, db):
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+ # does not login user
+ current_user = auth.get_user(client)
+ assert current_user.is_anonymous
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_invalid_id_token_issuer(client, mocker, db):
+ mocker.patch(
+ "codecov_auth.views.sentry.requests.post",
+ return_value=mocker.MagicMock(
+ status_code=200,
+ json=mocker.MagicMock(
+ return_value={
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "user": {
+ "id": "test-id",
+ "email": "test@example.com",
+ "name": "Some User",
+ },
+ "id_token": jwt.encode(
+ {
+ "iss": "invalid-issuer",
+ "aud": "test-client-id",
+ },
+ key="test-oidc-shared-secret",
+ ),
+ }
+ ),
+ ),
+ )
+
+ state = "test-state"
+ session = client.session
+ session["sentry_oauth_state"] = state
+ session.save()
+
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": state,
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+ # does not login user
+ current_user = auth.get_user(client)
+ assert current_user.is_anonymous
+
+
+@override_settings(
+ SENTRY_OAUTH_CLIENT_ID="test-client-id",
+ SENTRY_OIDC_SHARED_SECRET="test-oidc-shared-secret",
+)
+def test_sentry_perform_login_state_mismatch(client, mocked_sentry_request, db):
+ res = client.get(
+ reverse("sentry-login"),
+ data={
+ "code": "test-code",
+ "state": "invalid-state",
+ },
+ )
+
+ assert res.status_code == 302
+ assert res.url == f"{settings.CODECOV_DASHBOARD_URL}/login"
+
+ # does not login user
+ current_user = auth.get_user(client)
+ assert current_user.is_anonymous
+
+
+@override_settings(
+ OKTA_OAUTH_CLIENT_ID="test-client-id",
+ OKTA_OAUTH_CLIENT_SECRE="test-client-secret",
+ OKTA_OAUTH_REDIRECT_URL="https://localhost:8000/login/okta",
+)
+def test_sentry_fetch_user_data_invalid_state(client, db):
+ with patch("codecov_auth.views.sentry.requests.post") as mock_post:
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_post.return_value = mock_response
+
+ with patch.object(SentryLoginView, "verify_state", return_value=False):
+ view = SentryLoginView()
+ res = view._fetch_user_data(
+ "test-code",
+ "invalid-state",
+ )
+
+ assert res is None
diff --git a/apps/codecov-api/codecov_auth/urls.py b/apps/codecov-api/codecov_auth/urls.py
new file mode 100644
index 0000000000..47081a2b96
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/urls.py
@@ -0,0 +1,56 @@
+from django.conf import settings
+from django.urls import path, re_path
+
+from .views.bitbucket import BitbucketLoginView
+from .views.bitbucket_server import BitbucketServerLoginView
+from .views.github import GithubLoginView
+from .views.github_enterprise import GithubEnterpriseLoginView
+from .views.gitlab import GitlabLoginView
+from .views.gitlab_enterprise import GitlabEnterpriseLoginView
+from .views.logout import logout_view
+from .views.okta import OktaLoginView
+from .views.okta_cloud import OktaCloudCallbackView, OktaCloudLoginView
+from .views.sentry import SentryLoginView
+
+urlpatterns = [
+ path("logout", logout_view, name="logout"),
+ path("logout/", logout_view, name="logout-service"),
+ path("login/github", GithubLoginView.as_view(), name="github-login"),
+ path("login/gh", GithubLoginView.as_view(), name="gh-login"),
+ path("login/gitlab", GitlabLoginView.as_view(), name="gitlab-login"),
+ path("login/gl", GitlabLoginView.as_view(), name="gl-login"),
+ path("login/bitbucket", BitbucketLoginView.as_view(), name="bitbucket-login"),
+ path("login/bb", BitbucketLoginView.as_view(), name="bb-login"),
+ re_path(
+ r"login/github[-_]enterprise/?",
+ GithubEnterpriseLoginView.as_view(),
+ name="github-enterprise-login",
+ ),
+ path("login/ghe", GithubEnterpriseLoginView.as_view(), name="ghe-login"),
+ re_path(
+ r"login/gitlab[-_]enterprise/?",
+ GitlabEnterpriseLoginView.as_view(),
+ name="gitlab-enterprise-login",
+ ),
+ path("login/gle", GitlabEnterpriseLoginView.as_view(), name="gle-login"),
+ re_path(
+ r"login/bitbucket[-_]server/?",
+ BitbucketServerLoginView.as_view(),
+ name="bitbucket-server-login",
+ ),
+ path("login/bbs", BitbucketServerLoginView.as_view(), name="bbs-login"),
+ path("login/stash", BitbucketServerLoginView.as_view(), name="stash-login"),
+ path("login/sentry", SentryLoginView.as_view(), name="sentry-login"),
+ path(
+ "login/okta//",
+ OktaCloudLoginView.as_view(),
+ name="okta-cloud-login",
+ ),
+ path(
+ "login/okta/callback",
+ OktaCloudCallbackView.as_view(),
+ name="okta-cloud-callback",
+ ),
+]
+if settings.OKTA_ISS is not None:
+ urlpatterns += [path("login/okta", OktaLoginView.as_view(), name="okta-login")]
diff --git a/apps/codecov-api/codecov_auth/views/__init__.py b/apps/codecov-api/codecov_auth/views/__init__.py
new file mode 100644
index 0000000000..7992bf6347
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/__init__.py
@@ -0,0 +1,3 @@
+from codecov_auth.views.github import GithubLoginView
+
+__all__ = ["GithubLoginView"]
diff --git a/apps/codecov-api/codecov_auth/views/base.py b/apps/codecov-api/codecov_auth/views/base.py
new file mode 100644
index 0000000000..2d4a9483b1
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/base.py
@@ -0,0 +1,491 @@
+import logging
+import re
+import uuid
+from functools import reduce
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse
+
+from django.conf import settings
+from django.contrib.auth import login, logout
+from django.contrib.sessions.models import Session as DjangoSession
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.utils import timezone
+from django.utils.timezone import now
+from shared.encryption.token import encode_token
+from shared.events.amplitude import AmplitudeEventPublisher
+from shared.helpers.redis import get_redis_connection
+from shared.license import LICENSE_ERRORS_MESSAGES, get_current_license
+
+from codecov_auth.models import Owner, OwnerProfile, Session, User
+from services.analytics import AnalyticsService
+from services.refresh import RefreshService
+from utils.config import get_config
+from utils.encryption import encryptor
+from utils.services import get_long_service_name, get_short_service_name
+
+log = logging.getLogger(__name__)
+
+
+class StateMixin(object):
+ """
+ Implement the bevavior described here: https://auth0.com/docs/protocols/state-parameters
+
+ - Generating a random string (called state) and storing it in Redis
+ - Passing that state as argument to the oauth2 provider (eg github)
+ - The oauth2 provider redirects to Codecov with the same state
+ - We can verify if this state is in Redis, meaning Codecov generated when starting the redirection
+ - Additionnally; we store in redis the redirection url after auth passed by the front-end
+ - On the request callback; we can fetch the redirection url via the state
+ - If the state is not in Redis; we raise an exception which will return a 400 error
+ - Right before returning the response; we need to remove the state from Redis so it cannot be used again
+
+ How to use:
+
+ Mixin for a Django ClassBaseView (must have self.request set)
+
+ To generate the state:
+ - self.generate_state()
+ -> Will return a state to give to the oauth2 provider.
+ -> Will also store the redirect url from request.GET['to'] query param.
+
+ To get the redirect url from state:
+ - self.get_redirection_url_from_state(state)
+ -> Will return a safe URL to redirect after authentication
+ -> raise django.core.exceptions.SuspiciousOperation if no state was found
+
+ To remove the state:
+ - self.remove_state(state, delay=0)
+ -> Will remove the state from Redis; must be called at the end of the request
+ -> The delay parameter is the number of second in which the state will be removed
+
+ """
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ self.redis = get_redis_connection()
+ super().__init__(*args, **kwargs)
+
+ def _session_key(self) -> str:
+ return f"{self.service}_oauth_state"
+
+ def _get_key_redis(self, state: str) -> str:
+ return f"oauth-state-{state}"
+
+ def _is_matching_cors_domains(self, url_domain: str) -> bool:
+ # make sure the domain is part of the CORS so that's a safe domain to
+ # redirect to.
+ if url_domain in settings.CORS_ALLOWED_ORIGINS:
+ return True
+ for domain_pattern in settings.CORS_ALLOWED_ORIGIN_REGEXES:
+ if re.match(domain_pattern, url_domain):
+ return True
+ return False
+
+ def _is_valid_redirection(self, to: str) -> bool:
+ # make sure the redirect url is from a domain we own
+ try:
+ url = urlparse(to)
+ except ValueError:
+ return False
+ # the url is only a path without domain, it's valid
+ only_path = not url.scheme and not url.netloc and url.path
+ if only_path:
+ return True
+ url_domain = f"{url.scheme}://{url.netloc}"
+ return self._is_matching_cors_domains(url_domain)
+
+ def _generate_redirection_url(self) -> str:
+ redirection_url = self.request.GET.get("to")
+ if redirection_url and self._is_valid_redirection(redirection_url):
+ return redirection_url
+ return (
+ f"{settings.CODECOV_DASHBOARD_URL}/{get_short_service_name(self.service)}"
+ )
+
+ def generate_state(self) -> str:
+ state = uuid.uuid4().hex
+ redirection_url = self._generate_redirection_url()
+ self.redis.setex(self._get_key_redis(state), 500, redirection_url)
+
+ # By saving the state in a session cookie, we can ensure that the user
+ # following the redirection URL after OAuth authorization is the same
+ # as the user who initiated it. Otherwise, a trickster could generate
+ # a final redirect URL to log into their account, send it to some
+ # victim, and trick the victim into linking their account with the
+ # trickster's.
+ self.request.session[self._session_key()] = state
+
+ return state
+
+ def verify_state(self, state: str) -> bool:
+ state_from_session = self.request.session.get(self._session_key(), None)
+ return state_from_session and state == state_from_session
+
+ def get_redirection_url_from_state(self, state: str) -> tuple[str, bool]:
+ cached_url = self.redis.get(self._get_key_redis(state))
+
+ if not cached_url:
+ # we come here after an installation event if the setup url is not set correctly, in that case we usually don't
+ # have the state set, because that only happens when users try to login, therefore we should just ignore
+ # this case and redirect them to what the setup url should be
+ return (
+ f"{settings.CODECOV_DASHBOARD_URL}/{get_short_service_name(self.service)}",
+ False,
+ )
+
+ # At this point the git provider has redirected the user back to our
+ # site. If the state that the git provider relayed in that redirect
+ # matches the state that we have saved in our session cookie, everything
+ # is fine and we should return the final redirect URL to complete the
+ # login. If we're missing that cookie, or if its state doesn't match up,
+ # we want don't to allow the login.
+ if not self.verify_state(state):
+ log.warning(
+ "Warning: login request is missing state or has disagreeing state"
+ )
+ return (
+ f"{settings.CODECOV_DASHBOARD_URL}",
+ False,
+ )
+
+ # Return the final redirect URL to complete the login.
+ return (cached_url.decode("utf-8"), True)
+
+ def remove_state(self, state: str, delay: int = 0) -> None:
+ redirection_url, _ = self.get_redirection_url_from_state(state)
+ if delay == 0:
+ self.redis.delete(self._get_key_redis(state))
+ else:
+ self.redis.setex(self._get_key_redis(state), delay, redirection_url)
+
+ session_state = self.request.session.get(self._session_key(), None)
+ if session_state and session_state == state:
+ self.request.session.pop(self._session_key(), None)
+
+
+class LoginMixin(object):
+ analytics_service = AnalyticsService()
+
+ def modify_redirection_url_based_on_default_user_org(
+ self, url: str, owner: Owner
+ ) -> str:
+ if (
+ url
+ != f"{settings.CODECOV_DASHBOARD_URL}/{get_short_service_name(self.service)}"
+ and url
+ != f"{settings.CODECOV_DASHBOARD_URL}/{get_long_service_name(self.service)}"
+ ):
+ return url
+
+ owner_profile = None
+ if owner:
+ owner_profile = OwnerProfile.objects.filter(owner_id=owner.ownerid).first()
+ if owner_profile is not None and owner_profile.default_org is not None:
+ url += f"/{owner_profile.default_org.username}"
+ return url
+
+ def get_or_create_org(self, single_organization: dict) -> Owner:
+ owner, was_created = Owner.objects.get_or_create(
+ service=self.service,
+ service_id=single_organization["id"],
+ defaults={"createstamp": timezone.now()},
+ )
+ return owner
+
+ def login_owner(
+ self, owner: Owner, request: HttpRequest, response: HttpResponse
+ ) -> None:
+ # if there's a currently authenticated user
+ if request.user is not None and not request.user.is_anonymous:
+ if owner.user is None:
+ # TEMPORARY: We have no mechanism in the UI for supporting multiple
+ # owners of the same service linked to the same user. If the current
+ # user is already linked to an owner of the same service as this one then
+ # we'll logout the current user, create a new user and link the owner to
+ # that new user. This is not ideal since it creates multiple user records
+ # for the same person that will need to be merged later on.
+ if request.user.owners.filter(service=owner.service).exists():
+ logout(request)
+ current_user = User.objects.create(
+ email=owner.email,
+ name=owner.name,
+ is_staff=owner.staff,
+ )
+ owner.user = current_user
+ owner.save()
+ login(request, current_user)
+ else:
+ # assign the owner to the currently authenticated user
+ owner.user = request.user
+ owner.save()
+ log.info(
+ "User claimed owner",
+ extra=dict(user_id=request.user.pk, ownerid=owner.ownerid),
+ )
+ elif request.user != owner.user:
+ log.warning(
+ "Owner already linked to another user",
+ extra=dict(user_id=request.user.pk, ownerid=owner.ownerid),
+ )
+ # TEMPORARY: We may want to handle this better in the future by indicating
+ # the issue to the user and letting them decide how to proceeed. For now
+ # we'll just logout the current user and login the user that controls the owner
+ # that just OAuth-ed.
+ logout(request)
+ login(request, owner.user)
+ return
+ # else we do not have a currently authenticated user
+ else:
+ current_user = None
+ if owner.user is not None:
+ current_user = owner.user
+ else:
+ # no current user and owner has not already been assigned a user
+ current_user = User.objects.create(
+ email=owner.email,
+ name=owner.name,
+ is_staff=owner.staff,
+ )
+ owner.user = current_user
+ owner.save()
+
+ login(request, current_user)
+ log.info(
+ "User logged in",
+ extra=dict(user_id=request.user.pk, ownerid=owner.ownerid),
+ )
+
+ request.session["current_owner_id"] = owner.pk
+ RefreshService().trigger_refresh(owner.ownerid, owner.username)
+
+ self.delete_expired_sessions_and_django_sessions(owner)
+ self.store_login_session(owner)
+
+ def get_and_modify_owner(self, user_dict: dict, request: HttpRequest) -> Owner:
+ user_orgs = user_dict["orgs"]
+ formatted_orgs = [
+ dict(username=org["username"], id=str(org["id"])) for org in user_orgs
+ ]
+
+ self._check_enterprise_organizations_membership(user_dict, formatted_orgs)
+ upserted_orgs = [self.get_or_create_org(org) for org in formatted_orgs]
+
+ self._check_user_count_limitations(user_dict["user"])
+ owner, is_new_user = self._get_or_create_owner(user_dict, request)
+ fields_to_update = []
+ if (
+ not get_config(self.service, "student_disabled", default=False)
+ and user_dict.get("is_student") != owner.student
+ ):
+ owner.student = user_dict.get("is_student")
+ if owner.student_created_at is None:
+ owner.student_created_at = timezone.now()
+ owner.student_updated_at = timezone.now()
+ fields_to_update.extend(
+ ["student", "student_created_at", "student_updated_at"]
+ )
+
+ # Updated by the task `SyncTeams` that is called after login.
+ # We will only set this for the initial "oranizations is none" login.
+ if owner.organizations is None:
+ owner.organizations = [o.ownerid for o in upserted_orgs]
+ fields_to_update.extend(["organizations"])
+
+ if owner.bot is not None:
+ log.info(
+ "Clearing user bot field",
+ extra=dict(ownerid=owner.ownerid, old_bot=owner.bot),
+ )
+ owner.bot = None
+ fields_to_update.append("bot")
+
+ if fields_to_update:
+ owner.save(update_fields=fields_to_update + ["updatestamp"])
+
+ return owner
+
+ def _check_enterprise_organizations_membership(
+ self, user_dict: dict, orgs: list[dict]
+ ) -> None:
+ """Checks if a user belongs to the restricted organizations (or teams if GitHub) allowed in settings."""
+ if settings.IS_ENTERPRISE and get_config(self.service, "organizations"):
+ orgs_in_settings = set(get_config(self.service, "organizations"))
+ orgs_in_user = {org["username"] for org in orgs}
+ if not (orgs_in_settings & orgs_in_user):
+ raise PermissionDenied(
+ "You must be a member of an organization listed in the Codecov Enterprise setup."
+ )
+ if get_config(self.service, "teams") and "teams" in user_dict:
+ teams_in_settings = set(get_config(self.service, "teams"))
+ teams_in_user = {team["name"] for team in user_dict["teams"]}
+ if not (teams_in_settings & teams_in_user):
+ raise PermissionDenied(
+ "You must be a member of an allowed team in your organization."
+ )
+
+ def _check_user_count_limitations(self, login_data: dict) -> None:
+ if not settings.IS_ENTERPRISE:
+ return
+ license = get_current_license()
+ if not license.is_valid:
+ return
+
+ try:
+ user_logging_in_if_exists = Owner.objects.get(
+ service=f"{self.service}", service_id=login_data["id"]
+ )
+ except Owner.DoesNotExist:
+ user_logging_in_if_exists = None
+
+ if license.number_allowed_users:
+ if license.is_pr_billing:
+ # User is consuming seat if found in _any_ owner's plan_activated_users
+ is_consuming_seat = user_logging_in_if_exists and Owner.objects.filter(
+ plan_activated_users__contains=[user_logging_in_if_exists.ownerid]
+ )
+ if not is_consuming_seat:
+ owners_with_activated_users = Owner.objects.exclude(
+ plan_activated_users__len=0
+ ).exclude(plan_activated_users__isnull=True)
+ all_distinct_actiaved_users: set[str] = reduce(
+ lambda acc, curr: set(curr.plan_activated_users) | acc,
+ owners_with_activated_users,
+ set(),
+ )
+ if len(all_distinct_actiaved_users) > license.number_allowed_users:
+ raise PermissionDenied(
+ LICENSE_ERRORS_MESSAGES["users-exceeded"]
+ )
+ elif not user_logging_in_if_exists or (
+ user_logging_in_if_exists and not user_logging_in_if_exists.oauth_token
+ ):
+ users_on_service_count = Owner.objects.filter(
+ oauth_token__isnull=False, service=f"{self.service}"
+ ).count()
+ if users_on_service_count > license.number_allowed_users:
+ raise PermissionDenied(LICENSE_ERRORS_MESSAGES["users-exceeded"])
+
+ def _get_or_create_owner(
+ self, user_dict: dict, request: HttpRequest
+ ) -> tuple[Owner, bool]:
+ fields_to_update = ["oauth_token", "private_access", "updatestamp"]
+ login_data = user_dict["user"]
+ owner, was_created = Owner.objects.get_or_create(
+ service=f"{self.service}",
+ service_id=login_data["id"],
+ defaults={"createstamp": timezone.now()},
+ )
+ if login_data["login"] != owner.username:
+ fields_to_update.append("username")
+ owner.username = login_data["login"]
+
+ owner.oauth_token = encryptor.encode(encode_token(login_data)).decode()
+ owner.private_access = user_dict["has_private_access"]
+ if user_dict["user"].get("name"):
+ owner.name = user_dict["user"]["name"]
+ fields_to_update.append("name")
+
+ if user_dict["user"].get("email"):
+ owner.email = user_dict["user"].get("email")
+ fields_to_update.append("email")
+
+ owner.save(update_fields=fields_to_update)
+
+ marketing_tags = self.retrieve_marketing_tags_from_cookie()
+ amplitude = AmplitudeEventPublisher()
+ if was_created:
+ self.analytics_service.user_signed_up(owner, **marketing_tags)
+ amplitude.publish("User Created", {"user_ownerid": owner.ownerid})
+ else:
+ self.analytics_service.user_signed_in(owner, **marketing_tags)
+ amplitude.publish("User Logged in", {"user_ownerid": owner.ownerid})
+ orgs = owner.organizations
+ amplitude.publish(
+ "set_orgs",
+ {
+ "user_ownerid": owner.ownerid,
+ "org_ids": orgs if orgs is not None else [],
+ },
+ )
+
+ return (owner, was_created)
+
+ # below are functions to save marketing UTM params to cookie to retrieve them
+ # on the oauth callback for the tracking functions
+ def _get_utm_params(self, params: dict) -> dict:
+ filtered_params = {
+ "utm_department": params.get("utm_department", None),
+ "utm_campaign": params.get("utm_campaign", None),
+ "utm_medium": params.get("utm_medium", None),
+ "utm_source": params.get("utm_source", None),
+ "utm_content": params.get("utm_content", None),
+ "utm_term": params.get("utm_term", None),
+ }
+ # remove None values from the dict
+ return {k: v for k, v in filtered_params.items() if v is not None}
+
+ def store_to_cookie_utm_tags(self, response: HttpResponse) -> None:
+ if not settings.IS_ENTERPRISE:
+ data = urlencode(self._get_utm_params(self.request.GET))
+ response.set_cookie(
+ "_marketing_tags",
+ data,
+ max_age=86400, # Same as state validatiy
+ httponly=True,
+ domain=settings.COOKIES_DOMAIN,
+ )
+
+ def retrieve_marketing_tags_from_cookie(self) -> dict:
+ if not settings.IS_ENTERPRISE:
+ cookie_data = self.request.COOKIES.get("_marketing_tags", "")
+ params_as_dict = parse_qs(cookie_data)
+ filtered_params = self._get_utm_params(params_as_dict)
+ return {k: v[0] for k, v in filtered_params.items()}
+ else:
+ return {}
+
+ def store_login_session(self, owner: Owner) -> None:
+ # Store user's login session info after logging in
+ http_x_forwarded_for = self.request.META.get("HTTP_X_FORWARDED_FOR")
+ if http_x_forwarded_for:
+ ip = http_x_forwarded_for.split(",")[0]
+ else:
+ ip = self.request.META.get("REMOTE_ADDR")
+
+ login_session = DjangoSession.objects.filter(
+ session_key=self.request.session.session_key
+ ).first()
+
+ Session.objects.create(
+ lastseen=timezone.now(),
+ useragent=self.request.META.get("HTTP_USER_AGENT"),
+ ip=ip,
+ login_session=login_session,
+ type=Session.SessionType.LOGIN,
+ owner=owner,
+ )
+
+ def delete_expired_sessions_and_django_sessions(self, owner: Owner) -> None:
+ """
+ This function deletes expired login sessions for a given owner
+ """
+ with transaction.atomic():
+ # Get the primary keys of expired DjangoSessions for the given owner
+ expired_sessions = Session.objects.filter(
+ owner=owner,
+ type="login",
+ login_session__isnull=False,
+ login_session__expire_date__lt=now(),
+ )
+
+ # Delete the rows in the Session table using sessionid
+ Session.objects.filter(
+ sessionid__in=[es.sessionid for es in expired_sessions]
+ ).delete()
+
+ # Delete the rows in the DjangoSession table using the extracted keys
+ DjangoSession.objects.filter(
+ session_key__in=[es.login_session for es in expired_sessions]
+ ).delete()
diff --git a/apps/codecov-api/codecov_auth/views/bitbucket.py b/apps/codecov-api/codecov_auth/views/bitbucket.py
new file mode 100644
index 0000000000..a204e237cd
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/bitbucket.py
@@ -0,0 +1,126 @@
+import base64
+import logging
+from urllib.parse import urlencode
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.views import View
+from shared.django_apps.codecov_metrics.service.codecov_metrics import (
+ UserOnboardingMetricsService,
+)
+from shared.torngit import Bitbucket
+from shared.torngit.exceptions import TorngitServerFailureError
+
+from codecov_auth.views.base import LoginMixin
+from utils.encryption import encryptor
+
+log = logging.getLogger(__name__)
+
+
+class BitbucketLoginView(View, LoginMixin):
+ service = "bitbucket"
+
+ @async_to_sync
+ async def fetch_user_data(self, token):
+ repo_service = Bitbucket(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_CLIENT_ID,
+ secret=settings.BITBUCKET_CLIENT_SECRET,
+ ),
+ token=token,
+ )
+ user_data = await repo_service.get_authenticated_user()
+ authenticated_user = {
+ "key": token["key"],
+ "secret": token["secret"],
+ "id": user_data["uuid"][1:-1],
+ "login": user_data.pop("username"),
+ }
+ user_orgs = await repo_service.list_teams()
+ return dict(
+ user=authenticated_user,
+ orgs=user_orgs,
+ is_student=False,
+ has_private_access=True,
+ )
+
+ def redirect_to_bitbucket_step(self, request):
+ repo_service = Bitbucket(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_CLIENT_ID,
+ secret=settings.BITBUCKET_CLIENT_SECRET,
+ )
+ )
+ oauth_token_pair = repo_service.generate_request_token(
+ settings.BITBUCKET_REDIRECT_URI
+ )
+ oauth_token = oauth_token_pair["oauth_token"]
+ oauth_token_secret = oauth_token_pair["oauth_token_secret"]
+ url_params = urlencode({"oauth_token": oauth_token})
+ url_to_redirect = f"{Bitbucket._OAUTH_AUTHORIZE_URL}?{url_params}"
+ response = redirect(url_to_redirect)
+ data = (
+ base64.b64encode(oauth_token.encode())
+ + b"|"
+ + base64.b64encode(oauth_token_secret.encode())
+ ).decode()
+ response.set_signed_cookie(
+ "_oauth_request_token",
+ encryptor.encode(data).decode(),
+ domain=settings.COOKIES_DOMAIN,
+ )
+ self.store_to_cookie_utm_tags(response)
+ return response
+
+ def actual_login_step(self, request):
+ repo_service = Bitbucket(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_CLIENT_ID,
+ secret=settings.BITBUCKET_CLIENT_SECRET,
+ )
+ )
+ oauth_verifier = request.GET.get("oauth_verifier")
+ request_cookie = request.get_signed_cookie("_oauth_request_token", default=None)
+ if not request_cookie:
+ log.warning(
+ "Request arrived with proper url params but not the proper cookies"
+ )
+ return redirect(reverse("bitbucket-login"))
+ request_cookie = encryptor.decode(request_cookie)
+ cookie_key, cookie_secret = [
+ base64.b64decode(i).decode() for i in request_cookie.split("|")
+ ]
+ token = repo_service.generate_access_token(
+ cookie_key, cookie_secret, oauth_verifier
+ )
+ user_dict = self.fetch_user_data(token)
+ user = self.get_and_modify_owner(user_dict, request)
+ redirection_url = settings.CODECOV_DASHBOARD_URL + "/bb"
+ redirection_url = self.modify_redirection_url_based_on_default_user_org(
+ redirection_url, user
+ )
+ response = redirect(redirection_url)
+ response.delete_cookie("_oauth_request_token", domain=settings.COOKIES_DOMAIN)
+ self.login_owner(user, request, response)
+ log.info("User successfully logged in", extra=dict(ownerid=user.ownerid))
+ UserOnboardingMetricsService.create_user_onboarding_metric(
+ org_id=user.ownerid, event="INSTALLED_APP", payload={"login": "bitbucket"}
+ )
+ return response
+
+ def get(self, request):
+ if settings.DISABLE_GIT_BASED_LOGIN and request.user.is_anonymous:
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ try:
+ if request.GET.get("oauth_verifier"):
+ log.info("Logging into bitbucket after authorization")
+ return self.actual_login_step(request)
+ else:
+ log.info("Redirecting user to bitbucket for authorization")
+ return self.redirect_to_bitbucket_step(request)
+ except TorngitServerFailureError:
+ log.warning("Bitbucket not available for login")
+ return redirect(reverse("bitbucket-login"))
diff --git a/apps/codecov-api/codecov_auth/views/bitbucket_server.py b/apps/codecov-api/codecov_auth/views/bitbucket_server.py
new file mode 100644
index 0000000000..de9e5a4c05
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/bitbucket_server.py
@@ -0,0 +1,151 @@
+import base64
+import logging
+import threading
+from urllib.parse import urlencode
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.views import View
+from shared.torngit import BitbucketServer
+from shared.torngit.exceptions import TorngitServerFailureError
+
+from codecov_auth.models import SERVICE_BITBUCKET_SERVER
+from codecov_auth.views.base import LoginMixin
+from utils.encryption import encryptor
+
+log = logging.getLogger(__name__)
+
+
+class BitbucketServerLoginView(View, LoginMixin):
+ service = SERVICE_BITBUCKET_SERVER
+
+ async def fetch_user_data(self, token):
+ repo_service = BitbucketServer(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_SERVER_CLIENT_ID,
+ secret=settings.BITBUCKET_SERVER_CLIENT_SECRET,
+ ),
+ token=token,
+ )
+ # Whoami? Get the user
+ # https://answers.atlassian.com/questions/9379031/answers/9379803
+ whoami_url = f"{settings.BITBUCKET_SERVER_URL}/plugins/servlet/applinks/whoami"
+ username = await repo_service.api("GET", whoami_url)
+ # https://developer.atlassian.com/static/rest/bitbucket-server/4.0.1/bitbucket-rest.html#idp2649152
+ user = await repo_service.api("GET", "/users/%s" % username)
+
+ authenticated_user = {
+ "key": token["key"],
+ "secret": token["secret"],
+ "id": user["id"],
+ "login": user["name"],
+ }
+ user_orgs = await repo_service.list_teams()
+ return dict(
+ user=authenticated_user,
+ orgs=user_orgs,
+ is_student=False,
+ has_private_access=True,
+ )
+
+ async def redirect_to_bitbucket_server_step(self, request):
+ # And the consumer needs to have the defined client id. The secret is ignored.
+ # https://developer.atlassian.com/server/jira/platform/oauth/
+ repo_service = BitbucketServer(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_SERVER_CLIENT_ID,
+ secret="",
+ )
+ )
+ # In this part we make a request for the unauthorized request token.
+ # Here the user will be redirected to the authorize page and allow our app to be used.
+ # At the end of this step client will see a screen saying "you have authorized this application. Return to application and click continue."
+ request_token_url = (
+ f"{settings.BITBUCKET_SERVER_URL}/plugins/servlet/oauth/request-token"
+ )
+ request_token = await repo_service.api("POST", request_token_url)
+
+ auth_token = request_token["oauth_token"]
+ auth_token_secret = request_token["oauth_token_secret"]
+
+ data = (
+ base64.b64encode(auth_token.encode())
+ + b"|"
+ + base64.b64encode(auth_token_secret.encode())
+ ).decode()
+
+ url_params = urlencode(dict(oauth_token=auth_token))
+ authorize_url = f"{settings.BITBUCKET_SERVER_URL}/plugins/servlet/oauth/authorize?{url_params}"
+ response = redirect(authorize_url)
+ response.set_signed_cookie(
+ "_oauth_request_token",
+ encryptor.encode(data).decode(),
+ domain=settings.COOKIES_DOMAIN,
+ )
+ self.store_to_cookie_utm_tags(response)
+ return response
+
+ async def actual_login_step(self, request):
+ # Retrieve the authorized request_token and create a new client
+ # This new client has the same consumer as before, but uses the request token.
+ # ! Each request_token can only be used once
+ request_cookie = request.get_signed_cookie("_oauth_request_token", default=None)
+ if not request_cookie:
+ log.warning(
+ "Request arrived with proper url params but not the proper cookies"
+ )
+ return redirect(reverse("bbs-login"))
+
+ request_cookie = encryptor.decode(request_cookie)
+ cookie_key, cookie_secret = [
+ base64.b64decode(i).decode() for i in request_cookie.split("|")
+ ]
+ token = {"key": cookie_key, "secret": cookie_secret}
+ repo_service = BitbucketServer(
+ oauth_consumer_token=dict(
+ key=settings.BITBUCKET_SERVER_CLIENT_ID,
+ secret=settings.BITBUCKET_SERVER_CLIENT_SECRET,
+ ),
+ token=token,
+ )
+ # Get the access token from the request token
+ # The access token can be stored and reused.
+ response = redirect(settings.CODECOV_DASHBOARD_URL + "/bbs")
+ response.delete_cookie("_oauth_request_token", domain=settings.COOKIES_DOMAIN)
+ access_token_url = (
+ f"{settings.BITBUCKET_SERVER_URL}/plugins/servlet/oauth/access-token"
+ )
+ access_token = await repo_service.api("POST", access_token_url)
+ auth_token = access_token["oauth_token"]
+ auth_token_secret = access_token["oauth_token_secret"]
+
+ user_dict = await self.fetch_user_data(
+ dict(key=auth_token, secret=auth_token_secret)
+ )
+
+ def async_login():
+ user = self.get_and_modify_owner(user_dict, request)
+ self.login_owner(user, request, response)
+ log.info(
+ "User (async) successfully logged in", extra=dict(ownerid=user.ownerid)
+ )
+
+ force_sync = threading.Thread(target=async_login)
+ force_sync.start()
+ force_sync.join()
+ return response
+
+ @async_to_sync
+ async def get(self, request):
+ try:
+ if request.COOKIES.get("_oauth_request_token"):
+ log.info("Logging into bitbucket_server after authorization")
+ return await self.actual_login_step(request)
+ else:
+ log.info("Redirecting user to bitbucket_server for authorization")
+ return await self.redirect_to_bitbucket_server_step(request)
+ except TorngitServerFailureError:
+ log.warning("Bitbucket Server not available for login")
+ return redirect(settings.CODECOV_DASHBOARD_URL + "/bbs")
diff --git a/apps/codecov-api/codecov_auth/views/github.py b/apps/codecov-api/codecov_auth/views/github.py
new file mode 100644
index 0000000000..cdb39d1a62
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/github.py
@@ -0,0 +1,165 @@
+import logging
+from typing import Optional
+from urllib.parse import urlencode, urljoin
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.shortcuts import redirect
+from django.views import View
+from shared.django_apps.codecov_metrics.service.codecov_metrics import (
+ UserOnboardingMetricsService,
+)
+from shared.torngit import Github
+from shared.torngit.exceptions import TorngitError
+
+from codecov_auth.views.base import LoginMixin, StateMixin
+from utils.config import get_config
+
+log = logging.getLogger(__name__)
+
+
+class GithubLoginView(LoginMixin, StateMixin, View):
+ service = "github"
+ error_redirection_page = "/"
+
+ @property
+ def repo_service_instance(self):
+ return Github(
+ oauth_consumer_token=dict(
+ key=settings.GITHUB_CLIENT_ID, secret=settings.GITHUB_CLIENT_SECRET
+ )
+ )
+
+ @property
+ def redirect_info(self):
+ return dict(repo_service=Github(), client_id=settings.GITHUB_CLIENT_ID)
+
+ def get_url_to_redirect_to(self, scope):
+ redirect_info = self.redirect_info
+ redirect_host = (
+ redirect_info["repo_service"].get_service_url()
+ if redirect_info["repo_service"].get_host_header() is None
+ else "https://" + redirect_info["repo_service"].get_host_header()
+ )
+ base_url = urljoin(redirect_host, "login/oauth/authorize")
+ state = self.generate_state()
+ query = dict(
+ response_type="code",
+ scope=",".join(scope),
+ client_id=redirect_info["client_id"],
+ state=state,
+ )
+ query_str = urlencode(query)
+ return f"{base_url}?{query_str}"
+
+ async def _get_teams_data(self, repo_service):
+ # https://docs.github.com/en/rest/reference/teams#list-teams-for-the-authenticated-user
+ teams = []
+ if settings.IS_ENTERPRISE:
+ async with repo_service.get_client() as client:
+ try:
+ teams = []
+ curr_page = 1
+ while True:
+ curr_teams = await repo_service.api(
+ client, "get", f"/user/teams?per_page=100&page={curr_page}"
+ )
+ teams.extend(curr_teams)
+ curr_page += 1
+ if len(curr_teams) == 0:
+ break
+ except TorngitError as exp:
+ log.error(f"Failed to get GitHub teams information: {exp}")
+ return teams
+
+ @async_to_sync
+ async def fetch_user_data(self, code) -> Optional[dict]:
+ # https://docs.github.com/en/rest/reference/teams#list-teams-for-the-authenticated-user
+ # This is specific to GitHub
+ repo_service = self.repo_service_instance
+ authenticated_user = await repo_service.get_authenticated_user(code)
+ if "access_token" not in authenticated_user:
+ log.warning(
+ "Missing access_token during GitHub OAuth",
+ extra=dict(
+ user_info=authenticated_user,
+ ),
+ )
+ return None
+ # Comply to torngit's token encoding
+ authenticated_user["key"] = authenticated_user["access_token"]
+ user_orgs = await repo_service.list_teams()
+ student_disabled = get_config(self.service, "student_disabled", default=False)
+ if not student_disabled:
+ is_student = await repo_service.is_student()
+ else:
+ is_student = False
+ has_private_access = "repo" in authenticated_user["scope"].split(",")
+
+ teams = await self._get_teams_data(repo_service)
+
+ return dict(
+ user=authenticated_user,
+ orgs=user_orgs,
+ teams=teams,
+ is_student=is_student,
+ has_private_access=has_private_access,
+ )
+
+ def actual_login_step(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ redirection_url, is_valid = self.get_redirection_url_from_state(state)
+ if not is_valid:
+ return redirect(redirection_url)
+ try:
+ user_dict = self.fetch_user_data(code)
+ if user_dict is None:
+ return redirect(self.error_redirection_page)
+ except TorngitError:
+ log.warning("Unable to log in due to problem on Github", exc_info=True)
+ return redirect(self.error_redirection_page)
+ owner = self.get_and_modify_owner(user_dict, request)
+ redirection_url = self.modify_redirection_url_based_on_default_user_org(
+ redirection_url, owner
+ )
+ response = redirect(redirection_url)
+ self.login_owner(owner, request, response)
+ self.remove_state(state)
+ UserOnboardingMetricsService.create_user_onboarding_metric(
+ org_id=owner.ownerid, event="INSTALLED_APP", payload={"login": "github"}
+ )
+ return response
+
+ def get(self, request):
+ if settings.DISABLE_GIT_BASED_LOGIN and request.user.is_anonymous:
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ if request.GET.get("code"):
+ return self.actual_login_step(request)
+ else:
+ scope = ["user:email", "read:org", "repo:status", "write:repo_hook"]
+ if (
+ settings.IS_ENTERPRISE
+ or request.COOKIES.get("ghpr") == "true"
+ or request.GET.get("private")
+ ):
+ log.info("Appending repo to scope")
+ scope.append("repo")
+ url_to_redirect_to = self.get_url_to_redirect_to(scope)
+ response = redirect(url_to_redirect_to)
+ seconds_in_one_year = 365 * 24 * 60 * 60
+ domain_to_use = settings.COOKIES_DOMAIN
+ response.set_cookie(
+ "ghpr",
+ "true",
+ max_age=seconds_in_one_year,
+ httponly=True,
+ domain=domain_to_use,
+ )
+ self.store_to_cookie_utm_tags(response)
+ return response
+ url_to_redirect_to = self.get_url_to_redirect_to(scope)
+ response = redirect(url_to_redirect_to)
+ self.store_to_cookie_utm_tags(response)
+ return response
diff --git a/apps/codecov-api/codecov_auth/views/github_enterprise.py b/apps/codecov-api/codecov_auth/views/github_enterprise.py
new file mode 100644
index 0000000000..521e51359c
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/github_enterprise.py
@@ -0,0 +1,29 @@
+import logging
+
+from django.conf import settings
+from shared.torngit import GithubEnterprise
+
+from .github import GithubLoginView
+
+log = logging.getLogger(__name__)
+
+
+class GithubEnterpriseLoginView(GithubLoginView):
+ service = "github_enterprise"
+ error_redirection_page = "/"
+
+ @property
+ def repo_service_instance(self):
+ return GithubEnterprise(
+ oauth_consumer_token=dict(
+ key=settings.GITHUB_ENTERPRISE_CLIENT_ID,
+ secret=settings.GITHUB_ENTERPRISE_CLIENT_SECRET,
+ )
+ )
+
+ @property
+ def redirect_info(self):
+ return dict(
+ repo_service=GithubEnterprise(),
+ client_id=settings.GITHUB_ENTERPRISE_CLIENT_ID,
+ )
diff --git a/apps/codecov-api/codecov_auth/views/gitlab.py b/apps/codecov-api/codecov_auth/views/gitlab.py
new file mode 100644
index 0000000000..399e7c5ac6
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/gitlab.py
@@ -0,0 +1,103 @@
+import logging
+from urllib.parse import urlencode, urljoin
+from uuid import uuid4 # noqa: F401
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.shortcuts import redirect
+from django.views import View
+from shared.django_apps.codecov_metrics.service.codecov_metrics import (
+ UserOnboardingMetricsService,
+)
+from shared.torngit import Gitlab
+from shared.torngit.exceptions import TorngitError
+
+from codecov_auth.views.base import LoginMixin, StateMixin
+
+log = logging.getLogger(__name__)
+
+
+class GitlabLoginView(LoginMixin, StateMixin, View):
+ service = "gitlab"
+ error_redirection_page = "/"
+
+ @property
+ def repo_service_instance(self):
+ return Gitlab(
+ oauth_consumer_token=dict(
+ key=settings.GITLAB_CLIENT_ID, secret=settings.GITLAB_CLIENT_SECRET
+ )
+ )
+
+ @property
+ def redirect_info(self):
+ return dict(
+ redirect_uri=settings.GITLAB_REDIRECT_URI,
+ repo_service=Gitlab(),
+ client_id=settings.GITLAB_CLIENT_ID,
+ )
+
+ def get_url_to_redirect_to(self):
+ redirect_info = self.redirect_info
+ base_url = urljoin(redirect_info["repo_service"].service_url, "oauth/authorize")
+ state = self.generate_state()
+
+ scope = settings.GITLAB_SCOPE
+ log.info(f"Gitlab oauth with scope: '{scope}'")
+
+ query = dict(
+ response_type="code",
+ client_id=redirect_info["client_id"],
+ redirect_uri=redirect_info["redirect_uri"],
+ state=state,
+ scope=scope,
+ )
+ query_str = urlencode(query)
+ return f"{base_url}?{query_str}"
+
+ @async_to_sync
+ async def fetch_user_data(self, request, code):
+ repo_service = self.repo_service_instance
+ user_dict = await repo_service.get_authenticated_user(code)
+ user_dict["login"] = user_dict["username"]
+ # Comply to torngit's token encoding
+ user_dict["key"] = user_dict["access_token"]
+ user_orgs = await repo_service.list_teams()
+ return dict(
+ user=user_dict, orgs=user_orgs, is_student=False, has_private_access=True
+ )
+
+ def actual_login_step(self, request):
+ state = request.GET.get("state")
+ code = request.GET.get("code")
+ try:
+ user_dict = self.fetch_user_data(request, code)
+ except TorngitError:
+ log.warning("Unable to log in due to problem on Gitlab", exc_info=True)
+ return redirect(self.error_redirection_page)
+ user = self.get_and_modify_owner(user_dict, request)
+ redirection_url, is_valid = self.get_redirection_url_from_state(state)
+ if not is_valid:
+ return redirect(redirection_url)
+ redirection_url = self.modify_redirection_url_based_on_default_user_org(
+ redirection_url, user
+ )
+ response = redirect(redirection_url)
+ self.login_owner(user, request, response)
+ self.remove_state(state, delay=5)
+ UserOnboardingMetricsService.create_user_onboarding_metric(
+ org_id=user.ownerid, event="INSTALLED_APP", payload={"login": "gitlab"}
+ )
+ return response
+
+ def get(self, request):
+ if settings.DISABLE_GIT_BASED_LOGIN and request.user.is_anonymous:
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ if request.GET.get("code"):
+ return self.actual_login_step(request)
+ else:
+ url_to_redirect_to = self.get_url_to_redirect_to()
+ response = redirect(url_to_redirect_to)
+ self.store_to_cookie_utm_tags(response)
+ return response
diff --git a/apps/codecov-api/codecov_auth/views/gitlab_enterprise.py b/apps/codecov-api/codecov_auth/views/gitlab_enterprise.py
new file mode 100644
index 0000000000..906900a0d4
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/gitlab_enterprise.py
@@ -0,0 +1,30 @@
+import logging
+
+from django.conf import settings
+from shared.torngit import GitlabEnterprise
+
+from .gitlab import GitlabLoginView
+
+log = logging.getLogger(__name__)
+
+
+class GitlabEnterpriseLoginView(GitlabLoginView):
+ service = "gitlab_enterprise"
+ error_redirection_page = "/"
+
+ @property
+ def repo_service_instance(self):
+ return GitlabEnterprise(
+ oauth_consumer_token=dict(
+ key=settings.GITLAB_ENTERPRISE_CLIENT_ID,
+ secret=settings.GITLAB_ENTERPRISE_CLIENT_SECRET,
+ )
+ )
+
+ @property
+ def redirect_info(self):
+ return dict(
+ redirect_uri=settings.GITLAB_ENTERPRISE_REDIRECT_URI,
+ repo_service=GitlabEnterprise(),
+ client_id=settings.GITLAB_ENTERPRISE_CLIENT_ID,
+ )
diff --git a/apps/codecov-api/codecov_auth/views/logout.py b/apps/codecov-api/codecov_auth/views/logout.py
new file mode 100644
index 0000000000..d82f6c88fb
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/logout.py
@@ -0,0 +1,24 @@
+from django.conf import settings
+from django.contrib.auth import logout
+from django.http import HttpRequest
+from django.shortcuts import HttpResponse
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+
+@api_view(["POST"])
+def logout_view(request: HttpRequest, **kwargs: str) -> HttpResponse:
+ logout(request)
+
+ response = Response(status=205)
+ kwargs_cookie = dict(
+ domain=settings.COOKIES_DOMAIN, samesite=settings.COOKIE_SAME_SITE
+ )
+ response.delete_cookie("staff_user", **kwargs_cookie)
+
+ # temporary as we use to set cookie to Strict SameSite; but we need Lax
+ # So we need delete in both samesite Strict / Lax for a little while
+ kwargs_cookie = dict(domain=settings.COOKIES_DOMAIN, samesite="Strict")
+ response.delete_cookie("staff_user", **kwargs_cookie)
+
+ return response
diff --git a/apps/codecov-api/codecov_auth/views/okta.py b/apps/codecov-api/codecov_auth/views/okta.py
new file mode 100644
index 0000000000..76931c75b6
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/okta.py
@@ -0,0 +1,142 @@
+import logging
+
+from django.conf import settings
+from django.contrib.auth import login, logout
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.views import View
+from requests.auth import HTTPBasicAuth
+
+from codecov_auth.models import OktaUser, User
+from codecov_auth.views.base import LoginMixin
+from codecov_auth.views.okta_mixin import (
+ ISS_REGEX,
+ OktaLoginMixin,
+ OktaTokenResponse,
+ validate_id_token,
+)
+from utils.services import get_short_service_name
+
+log = logging.getLogger(__name__)
+
+OKTA_BASIC_AUTH = HTTPBasicAuth(
+ settings.OKTA_OAUTH_CLIENT_ID, settings.OKTA_OAUTH_CLIENT_SECRET
+)
+
+
+class OktaLoginView(LoginMixin, OktaLoginMixin, View):
+ service = "okta"
+
+ def _perform_login(self, request: HttpRequest, iss: str) -> HttpResponse:
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+
+ if not self.verify_state(state):
+ log.warning("Invalid state during Okta login")
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ user_data: OktaTokenResponse | None = self._fetch_user_data(
+ iss, code, state, settings.OKTA_OAUTH_REDIRECT_URL, OKTA_BASIC_AUTH
+ )
+ if user_data is None:
+ log.warning("Unable to log in due to problem on Okta", exc_info=True)
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ current_user = self._login_user(request, iss, user_data)
+
+ # TEMPORARY: we're assuming a single owner for the time being since there's
+ # no supporting UI to select which owner you'd like to view
+ owner = current_user.owners.first()
+ self.remove_state(state)
+ if owner is not None:
+ service = get_short_service_name(owner.service)
+ response = redirect(f"{settings.CODECOV_DASHBOARD_URL}/{service}")
+ else:
+ # user has not connected any owners yet
+ response = redirect(f"{settings.CODECOV_DASHBOARD_URL}/sync")
+
+ return response
+
+ def _login_user(
+ self, request: HttpRequest, iss: str, user_data: OktaTokenResponse
+ ) -> User:
+ id_token = user_data.id_token
+ id_payload = validate_id_token(iss, id_token, settings.OKTA_OAUTH_CLIENT_ID)
+
+ okta_id = id_payload.sub
+ user_email = id_payload.email
+ user_name = id_payload.name
+
+ okta_user = OktaUser.objects.filter(okta_id=okta_id).first()
+
+ if request.user is not None and not request.user.is_anonymous:
+ # we're already authenticated
+ current_user = request.user
+
+ if okta_user and okta_user.user != request.user:
+ log.warning(
+ "Okta account already linked to another user",
+ extra=dict(
+ current_user_id=request.user.pk, okta_user_id=okta_user.pk
+ ),
+ )
+ # Logout the current user and login the user who already
+ # claimed this Okta account (below)
+ logout(request)
+ current_user = okta_user.user
+ else:
+ # we're not authenticated
+ if okta_user:
+ log.info(
+ "Existing Okta user logging in",
+ extra=dict(okta_user_id=okta_user.pk),
+ )
+ current_user = okta_user.user
+ else:
+ current_user = User.objects.create(
+ name=user_name,
+ email=user_email,
+ )
+
+ if okta_user is None:
+ okta_user = OktaUser.objects.create(
+ user=current_user,
+ okta_id=okta_id,
+ name=user_name,
+ email=user_email,
+ access_token=user_data.access_token,
+ )
+ log.info(
+ "Created Okta user",
+ extra=dict(okta_user_id=okta_user.pk),
+ )
+
+ login(request, current_user)
+ return current_user
+
+ def validate_issuer(self) -> str | None:
+ """Checks that the issuer is valid. If not, it returns None."""
+ iss = settings.OKTA_ISS
+ if iss is None:
+ log.warning("Unable to log in due to missing Okta issuer", exc_info=True)
+ return None
+ if not ISS_REGEX.match(iss):
+ log.warning("Invalid Okta issuer")
+ return None
+ return iss
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ iss = self.validate_issuer()
+ if iss is None:
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ if request.GET.get("code"):
+ return self._perform_login(request, iss)
+ else:
+ response = self._redirect_to_consent(
+ iss=iss,
+ client_id=settings.OKTA_OAUTH_CLIENT_ID,
+ oauth_redirect_url=settings.OKTA_OAUTH_REDIRECT_URL,
+ )
+ self.store_to_cookie_utm_tags(response)
+ return response
diff --git a/apps/codecov-api/codecov_auth/views/okta_cloud.py b/apps/codecov-api/codecov_auth/views/okta_cloud.py
new file mode 100644
index 0000000000..d9268b3833
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/okta_cloud.py
@@ -0,0 +1,210 @@
+import logging
+
+from django.conf import settings
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.views import View
+from requests.auth import HTTPBasicAuth
+from shared.django_apps.codecov_auth.models import Account, OktaSettings, Owner
+
+from codecov_auth.views.okta_mixin import (
+ OktaLoginMixin,
+ OktaTokenResponse,
+ validate_id_token,
+)
+
+# The key for accessing the Okta signed in accounts list in the session
+OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY = "okta_signed_in_accounts"
+
+# The key for the currently signing in session in Okta.
+# This is so that the callback can reference the orgs/accounts that we're
+# signing in for.
+OKTA_CURRENT_SESSION = "okta_current_session"
+
+log = logging.getLogger(__name__)
+
+
+def get_app_redirect_url(org_username: str, service: str) -> str:
+ """The Codecov app page we redirect users to."""
+ return f"{settings.CODECOV_DASHBOARD_URL}/{service}/{org_username}"
+
+
+def get_oauth_redirect_url() -> str:
+ """The Okta callback URL for us to finish the authentication."""
+ return f"{settings.CODECOV_API_URL}/login/okta/callback"
+
+
+def get_okta_settings(organization: Owner) -> OktaSettings | None:
+ account: Account | None = organization.account
+ if account:
+ okta_settings: OktaSettings | None = account.okta_settings.first()
+ if okta_settings:
+ return okta_settings
+ return None
+
+
+class OktaCloudLoginView(OktaLoginMixin, View):
+ service = "okta_cloud"
+
+ def get(
+ self, request: HttpRequest, service: str, org_username: str
+ ) -> HttpResponse:
+ log_context: dict = {"service": service, "username": org_username}
+ if not request.user or request.user.is_anonymous:
+ log.warning(
+ "User needs to be signed in before authenticating organization with Okta.",
+ extra=log_context,
+ )
+ return HttpResponse(status=403)
+
+ try:
+ organization: Owner = Owner.objects.get(
+ service=service, username=org_username
+ )
+ except Owner.DoesNotExist:
+ log.warning("The organization doesn't exist.", extra=log_context)
+ return HttpResponse(status=404)
+
+ okta_settings = get_okta_settings(organization)
+ if not okta_settings:
+ log.warning(
+ "Okta settings not found. Cannot sign into Okta", extra=log_context
+ )
+ return HttpResponse(status=404)
+
+ app_redirect_url = get_app_redirect_url(
+ organization.username, organization.service
+ )
+ oauth_redirect_url = get_oauth_redirect_url()
+
+ # User is already logged in, redirect them to the org page
+ if organization.account.id in request.session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ ):
+ return redirect(app_redirect_url)
+
+ # Otherwise start the process redirect them to the Issuer page to authenticate
+ else:
+ consent = self._redirect_to_consent(
+ iss=okta_settings.url.strip("/ "),
+ client_id=okta_settings.client_id,
+ oauth_redirect_url=oauth_redirect_url,
+ )
+ request.session[OKTA_CURRENT_SESSION] = {
+ "org_ownerid": organization.ownerid,
+ "okta_settings_id": okta_settings.id,
+ }
+ return consent
+
+
+class OktaCloudCallbackView(OktaLoginMixin, View):
+ service = "okta_cloud"
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ current_okta_session: dict[str, int] | None = request.session.get(
+ OKTA_CURRENT_SESSION
+ )
+ if not current_okta_session:
+ log.warning("Trying to sign into Okta with no existing sign-in session.")
+ return HttpResponse(status=403)
+
+ org_owner = Owner.objects.get(ownerid=current_okta_session["org_ownerid"])
+ log_context: dict = {
+ "service": org_owner.service,
+ "username": org_owner.username,
+ }
+
+ if not request.user or request.user.is_anonymous:
+ log.warning(
+ "User not logged in for Okta callback.",
+ extra=log_context,
+ )
+ return HttpResponse(status=403)
+
+ try:
+ okta_settings = OktaSettings.objects.get(
+ id=current_okta_session["okta_settings_id"]
+ )
+ except OktaSettings.DoesNotExist:
+ log.warning(
+ "Okta settings not found. Cannot sign into Okta", extra=log_context
+ )
+ return HttpResponse(status=404)
+
+ app_redirect_url = get_app_redirect_url(org_owner.username, org_owner.service)
+ oauth_redirect_url = get_oauth_redirect_url()
+
+ # Check for error in the callback
+ error = request.GET.get("error")
+ if error:
+ log.warning(
+ f"Okta authentication error: {error}",
+ extra=log_context,
+ )
+ return redirect(f"{app_redirect_url}?error={error}")
+
+ # Redirect URL, need to validate and mark user as logged in
+ if request.GET.get("code"):
+ return self._perform_login(
+ request,
+ org_owner,
+ okta_settings,
+ app_redirect_url,
+ oauth_redirect_url,
+ )
+ else:
+ log.warning(
+ "No code is passed. Invalid callback. Cannot sign into Okta",
+ extra=log_context,
+ )
+
+ return HttpResponse(status=400)
+
+ def _perform_login(
+ self,
+ request: HttpRequest,
+ organization: Owner,
+ okta_settings: OktaSettings,
+ app_redirect_url: str,
+ oauth_redirect_url: str,
+ ) -> HttpResponse:
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+
+ if not self.verify_state(state):
+ log.warning("Invalid state during Okta login")
+ return redirect(f"{app_redirect_url}?error=invalid_state")
+
+ issuer: str = okta_settings.url.strip("/ ")
+ user_data: OktaTokenResponse | None = self._fetch_user_data(
+ issuer,
+ code,
+ state,
+ oauth_redirect_url,
+ HTTPBasicAuth(okta_settings.client_id, okta_settings.client_secret),
+ )
+
+ if user_data is None:
+ log.warning("Can't log in. Invalid Okta Token Response", exc_info=True)
+ return redirect(f"{app_redirect_url}?error=invalid_token_response")
+
+ try:
+ _ = validate_id_token(issuer, user_data.id_token, okta_settings.client_id)
+ except Exception as e:
+ log.warning(f"Invalid ID token: {str(e)}", exc_info=True)
+ return redirect(f"{app_redirect_url}?error=invalid_id_token")
+
+ self._login_user(request, organization)
+
+ return redirect(app_redirect_url)
+
+ def _login_user(self, request: HttpRequest, organization: Owner) -> None:
+ """Logging in the user will just mean adding the account to the user's
+ okta_logged_in_accounts session.
+ """
+ okta_signed_in_accounts: list[int] = request.session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ )
+ okta_signed_in_accounts.append(organization.account.id)
+ request.session[OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY] = okta_signed_in_accounts
+ return
diff --git a/apps/codecov-api/codecov_auth/views/okta_mixin.py b/apps/codecov-api/codecov_auth/views/okta_mixin.py
new file mode 100644
index 0000000000..052dc54f4b
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/okta_mixin.py
@@ -0,0 +1,112 @@
+import json
+import logging
+import re
+from urllib.parse import urlencode
+
+import jwt
+import pydantic
+import requests
+from django.http import HttpResponse
+from django.shortcuts import redirect
+from requests.auth import HTTPBasicAuth
+
+from codecov_auth.views.base import StateMixin
+
+log = logging.getLogger(__name__)
+
+ISS_REGEX = re.compile(r"https://[\w\d\-\_]+.okta.com/?")
+
+
+class OktaTokenResponse(pydantic.BaseModel):
+ """This model serializes the response from Okta's oauth/v1/token endpoint.
+ ref: https://developer.okta.com/docs/reference/api/oidc/#token
+
+ Keeping reference to only the fields that are used.
+ """
+
+ access_token: str
+ id_token: str # this will be present since we requested the `oidc` scope
+
+
+class OktaIdTokenPayload(pydantic.BaseModel):
+ """Serializes the ID Payload from Okta's id_token deserialization.
+ ref: https://developer.okta.com/docs/reference/api/oidc/#id-token
+ """
+
+ aud: str
+ iss: str
+ sub: str
+ email: str
+ name: str
+
+
+def validate_id_token(iss: str, id_token: str, client_id: str) -> OktaIdTokenPayload:
+ res = requests.get(f"{iss}/oauth2/v1/keys")
+ jwks = res.json()
+
+ public_keys = {}
+ for jwk in jwks["keys"]:
+ kid = jwk["kid"]
+ public_keys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
+
+ kid = jwt.get_unverified_header(id_token)["kid"]
+ key = public_keys[kid]
+
+ id_payload = jwt.decode(
+ id_token,
+ key=key,
+ algorithms=["RS256"],
+ audience=client_id,
+ )
+ id_token_payload = OktaIdTokenPayload(**id_payload)
+ assert id_token_payload.iss == iss
+ assert id_token_payload.aud == client_id
+
+ return id_token_payload
+
+
+class OktaLoginMixin(StateMixin):
+ def _fetch_user_data(
+ self,
+ iss: str,
+ code: str,
+ state: str,
+ redirect_url: str,
+ auth: HTTPBasicAuth,
+ ) -> OktaTokenResponse | None:
+ res = requests.post(
+ f"{iss}/oauth2/v1/token",
+ auth=auth,
+ data={
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_url,
+ "state": state,
+ },
+ )
+
+ if not self.verify_state(state):
+ log.warning("Invalid state during Okta OAuth")
+ return None
+
+ if res.status_code >= 400:
+ return None
+
+ return OktaTokenResponse(**res.json())
+
+ def _redirect_to_consent(
+ self, iss: str, client_id: str, oauth_redirect_url: str
+ ) -> HttpResponse:
+ state = self.generate_state()
+ qs = urlencode(
+ dict(
+ response_type="code",
+ client_id=client_id,
+ scope="openid email profile",
+ redirect_uri=oauth_redirect_url,
+ state=state,
+ )
+ )
+ redirect_url = f"{iss}/oauth2/v1/authorize?{qs}"
+ response = redirect(redirect_url)
+ return response
diff --git a/apps/codecov-api/codecov_auth/views/sentry.py b/apps/codecov-api/codecov_auth/views/sentry.py
new file mode 100644
index 0000000000..a05a903c1f
--- /dev/null
+++ b/apps/codecov-api/codecov_auth/views/sentry.py
@@ -0,0 +1,184 @@
+import logging
+from typing import Dict, Optional
+from urllib.parse import urlencode
+
+import jwt
+import requests
+from django.conf import settings
+from django.contrib.auth import login, logout
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.views import View
+
+from codecov_auth.models import SentryUser, User
+from codecov_auth.views.base import LoginMixin, StateMixin
+from utils.services import get_short_service_name
+
+log = logging.getLogger(__name__)
+
+
+OAUTH_AUTHORIZE_URL = "https://sentry.io/oauth/authorize"
+OAUTH_TOKEN_URL = (
+ "https://sentry.io/oauth/token/" # seems to require the trailing slash
+)
+
+
+class SentryLoginView(LoginMixin, StateMixin, View):
+ service = "sentry"
+
+ def _fetch_user_data(self, code: str, state: str) -> Optional[Dict]:
+ res = requests.post(
+ OAUTH_TOKEN_URL,
+ data={
+ "grant_type": "authorization_code",
+ "client_id": settings.SENTRY_OAUTH_CLIENT_ID,
+ "client_secret": settings.SENTRY_OAUTH_CLIENT_SECRET,
+ "code": code,
+ "state": state,
+ },
+ )
+
+ if res.status_code >= 400:
+ return None
+
+ if not self.verify_state(state):
+ log.warning("Invalid state during Sentry OAuth")
+ return None
+
+ return res.json()
+
+ def _redirect_to_consent(self) -> HttpResponse:
+ state = self.generate_state()
+ qs = urlencode(
+ dict(
+ response_type="code",
+ client_id=settings.SENTRY_OAUTH_CLIENT_ID,
+ scope="openid email profile",
+ state=state,
+ )
+ )
+ redirect_url = f"{OAUTH_AUTHORIZE_URL}?{qs}"
+ response = redirect(redirect_url)
+ self.store_to_cookie_utm_tags(response)
+ return response
+
+ def _verify_id_token(self, id_token: str) -> bool:
+ try:
+ id_payload = jwt.decode(
+ id_token,
+ settings.SENTRY_OIDC_SHARED_SECRET,
+ algorithms=["HS256"],
+ audience=settings.SENTRY_OAUTH_CLIENT_ID,
+ )
+
+ if id_payload["iss"] != "https://sentry.io":
+ log.warning(
+ "Invalid issuer of OIDC ID token",
+ exc_info=True,
+ extra=dict(
+ id_payload=id_payload,
+ ),
+ )
+ return False
+
+ return True
+ except jwt.exceptions.InvalidSignatureError:
+ id_payload = jwt.decode(id_token, options={"verify_signature": False})
+ log.warning(
+ "Unable to verify signature of OIDC ID token",
+ exc_info=True,
+ extra=dict(
+ id_payload=id_payload,
+ ),
+ )
+ return False
+
+ def _perform_login(self, request: HttpRequest) -> HttpResponse:
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+
+ if not self.verify_state(state):
+ log.warning("Invalid state during Sentry OAuth")
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ user_data = self._fetch_user_data(code, state)
+ if user_data is None:
+ log.warning("Unable to log in due to problem on Sentry", exc_info=True)
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ if not self._verify_id_token(user_data["id_token"]):
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/login")
+
+ current_user = self._login_user(request, user_data)
+
+ # TEMPORARY: we're assuming a single owner for the time being since there's
+ # no supporting UI to select which owner you'd like to view
+ owner = current_user.owners.first()
+ self.remove_state(state)
+
+ if owner is not None:
+ service = get_short_service_name(owner.service)
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/{service}")
+ else:
+ # user has not connected any owners yet
+ return redirect(f"{settings.CODECOV_DASHBOARD_URL}/sync")
+
+ def _login_user(self, request: HttpRequest, user_data: dict) -> User:
+ sentry_id = user_data["user"]["id"]
+ user_name = user_data["user"].get("name")
+ user_email = user_data["user"].get("email")
+
+ sentry_user = SentryUser.objects.filter(sentry_id=sentry_id).first()
+
+ current_user = None
+ if request.user is not None and not request.user.is_anonymous:
+ # we're already authenticated
+ current_user = request.user
+
+ if sentry_user and sentry_user.user != request.user:
+ log.warning(
+ "Sentry account already linked to another user",
+ extra=dict(
+ current_user_id=request.user.pk, sentry_user_id=sentry_user.pk
+ ),
+ )
+ # Logout the current user and login the user who already
+ # claimed this Sentry account (below)
+ logout(request)
+ current_user = sentry_user.user
+ else:
+ # we're not authenticated
+ if sentry_user:
+ log.info(
+ "Existing Sentry user logging in",
+ extra=dict(sentry_user_id=sentry_user.pk),
+ )
+ current_user = sentry_user.user
+ else:
+ current_user = User.objects.create(
+ name=user_name,
+ email=user_email,
+ )
+
+ if sentry_user is None:
+ sentry_user = SentryUser.objects.create(
+ user=current_user,
+ sentry_id=sentry_id,
+ name=user_name,
+ email=user_email,
+ access_token=user_data["access_token"],
+ refresh_token=user_data["refresh_token"],
+ )
+ log.info(
+ "Created Sentry user",
+ extra=dict(sentry_user_id=sentry_user.pk),
+ )
+
+ login(request, current_user)
+ return current_user
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if request.GET.get("code"):
+ return self._perform_login(request)
+ else:
+ return self._redirect_to_consent()
diff --git a/apps/codecov-api/compare/__init__.py b/apps/codecov-api/compare/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/compare/admin.py b/apps/codecov-api/compare/admin.py
new file mode 100644
index 0000000000..cbd8d78b37
--- /dev/null
+++ b/apps/codecov-api/compare/admin.py
@@ -0,0 +1,42 @@
+from django.contrib import admin
+
+from .models import CommitComparison
+
+
+@admin.register(CommitComparison)
+class CommitComparisonAdmin(admin.ModelAdmin):
+ list_display = (
+ "get_base_commit",
+ "get_compare_commit",
+ "get_repo_name",
+ "state",
+ "created_at",
+ "updated_at",
+ )
+
+ def get_base_commit(self, obj):
+ return obj.base_commit.commitid
+
+ get_base_commit.short_description = "Base Commit Sha"
+
+ def get_compare_commit(self, obj):
+ return obj.compare_commit.commitid
+
+ get_compare_commit.short_description = "Compare Commit Sha"
+
+ def get_repo_name(self, obj):
+ return obj.base_commit.repository.name
+
+ get_repo_name.short_description = "Repository name"
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ return qs.select_related(
+ "base_commit", "compare_commit", "base_commit__repository"
+ ).defer("base_commit___report", "compare_commit___report")
+
+ def has_add_permission(self, *args, **kwargs):
+ return False
+
+ def has_delete_permission(self, *args, **kwargs):
+ return True
diff --git a/apps/codecov-api/compare/commands/__init__.py b/apps/codecov-api/compare/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/compare/commands/compare/__init__.py b/apps/codecov-api/compare/commands/compare/__init__.py
new file mode 100644
index 0000000000..efe8c26c2f
--- /dev/null
+++ b/apps/codecov-api/compare/commands/compare/__init__.py
@@ -0,0 +1,3 @@
+from .compare import CompareCommands
+
+__all__ = ["CompareCommands"]
diff --git a/apps/codecov-api/compare/commands/compare/compare.py b/apps/codecov-api/compare/commands/compare/compare.py
new file mode 100644
index 0000000000..9f62947dcf
--- /dev/null
+++ b/apps/codecov-api/compare/commands/compare/compare.py
@@ -0,0 +1,16 @@
+from codecov.commands.base import BaseCommand
+from services.comparison import Comparison, ComparisonReport
+
+from .interactors.fetch_impacted_files import FetchImpactedFiles
+
+
+class CompareCommands(BaseCommand):
+ def fetch_impacted_files(
+ self,
+ comparison_report: ComparisonReport,
+ comparison: Comparison,
+ filters,
+ ):
+ return self.get_interactor(FetchImpactedFiles).execute(
+ comparison_report, comparison, filters
+ )
diff --git a/apps/codecov-api/compare/commands/compare/interactors/__init__.py b/apps/codecov-api/compare/commands/compare/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/compare/commands/compare/interactors/fetch_impacted_files.py b/apps/codecov-api/compare/commands/compare/interactors/fetch_impacted_files.py
new file mode 100644
index 0000000000..472a61a24f
--- /dev/null
+++ b/apps/codecov-api/compare/commands/compare/interactors/fetch_impacted_files.py
@@ -0,0 +1,142 @@
+import enum
+from typing import List, Optional
+
+from shared.utils.match import Matcher
+
+import services.components as components
+from codecov.commands.base import BaseInteractor
+from services.comparison import Comparison, ComparisonReport, ImpactedFile
+from services.report import files_belonging_to_flags
+
+
+class ImpactedFileParameter(enum.Enum):
+ FILE_NAME = "file_name"
+ CHANGE_COVERAGE = "change_coverage"
+ HEAD_COVERAGE = "head_coverage"
+ MISSES_COUNT = "misses_count"
+ PATCH_COVERAGE = "patch_coverage"
+
+
+class FetchImpactedFiles(BaseInteractor):
+ def _apply_filters(
+ self,
+ impacted_files: Optional[List[ImpactedFile]],
+ comparison: Comparison,
+ filters,
+ ):
+ parameter = filters.get("ordering", {}).get("parameter")
+ direction = filters.get("ordering", {}).get("direction")
+ if parameter and direction:
+ impacted_files = self.sort_impacted_files(
+ impacted_files, parameter, direction
+ )
+
+ if not comparison:
+ return impacted_files
+
+ flags_filter = filters.get("flags", [])
+ components_filter = filters.get("components", [])
+
+ components_paths = []
+ components_flags = []
+
+ head_commit_report = comparison.head_report_without_applied_diff
+ report_flags = head_commit_report and head_commit_report.get_flag_names()
+ if components_filter:
+ all_components = components.commit_components(
+ comparison.head_commit, comparison.user
+ )
+ filtered_components = components.filter_components_by_name_or_id(
+ all_components, components_filter
+ )
+ for component in filtered_components:
+ components_paths.extend(component.paths)
+ components_flags.extend(component.get_matching_flags(report_flags))
+
+ # Flags & Components intersection
+ if components_flags:
+ if flags_filter:
+ flags_filter = list(set(flags_filter) & set(components_flags))
+ else:
+ flags_filter = components_flags
+
+ if flags_filter:
+ if set(flags_filter) & set(report_flags):
+ files = files_belonging_to_flags(
+ commit_report=head_commit_report, flags=flags_filter
+ )
+
+ impacted_files = [
+ file for file in impacted_files if file.head_name in files
+ ]
+
+ res = impacted_files
+
+ if components_paths:
+ matcher = Matcher(components_paths)
+ res = [file for file in impacted_files if matcher.match(file.head_name)]
+ return res
+
+ def get_attribute(
+ self, impacted_file: ImpactedFile, parameter: ImpactedFileParameter
+ ):
+ if parameter == ImpactedFileParameter.FILE_NAME:
+ return impacted_file.file_name
+ elif parameter == ImpactedFileParameter.CHANGE_COVERAGE:
+ return impacted_file.change_coverage
+ elif parameter == ImpactedFileParameter.HEAD_COVERAGE:
+ if impacted_file.head_coverage is not None:
+ return impacted_file.head_coverage.coverage
+ elif parameter == ImpactedFileParameter.MISSES_COUNT:
+ if impacted_file.misses_count is not None:
+ return impacted_file.misses_count
+ elif parameter == ImpactedFileParameter.PATCH_COVERAGE:
+ if impacted_file.patch_coverage is not None:
+ return impacted_file.patch_coverage.coverage
+ else:
+ raise ValueError(f"invalid impacted file parameter: {parameter}")
+
+ def sort_impacted_files(self, impacted_files, parameter, direction):
+ """
+ Sorts the impacted files by any provided parameter and slides items with None values to the end
+ """
+ # Separate impacted files with None values for the specified parameter value
+ files_with_coverage = []
+ files_without_coverage = []
+ for file in impacted_files:
+ if self.get_attribute(file, parameter) is not None:
+ files_with_coverage.append(file)
+ else:
+ files_without_coverage.append(file)
+
+ # Sort impacted_files list based on parameter value
+ is_reversed = direction.value == "descending"
+ files_with_coverage = sorted(
+ files_with_coverage,
+ key=lambda x: self.get_attribute(x, parameter),
+ reverse=is_reversed,
+ )
+
+ # Merge both lists together
+ return files_with_coverage + files_without_coverage
+
+ def execute(
+ self,
+ comparison_report: ComparisonReport,
+ comparison: Comparison,
+ filters,
+ ):
+ if filters is None:
+ return comparison_report.impacted_files
+
+ has_unintended_changes = filters.get("has_unintended_changes")
+ if has_unintended_changes is not None:
+ impacted_files = (
+ comparison_report.impacted_files_with_unintended_changes
+ if has_unintended_changes
+ else comparison_report.impacted_files_with_direct_changes
+ )
+ else:
+ impacted_files = comparison_report.impacted_files
+
+ return self._apply_filters(impacted_files, comparison, filters)
diff --git a/apps/codecov-api/compare/commands/compare/interactors/tests/__init__.py b/apps/codecov-api/compare/commands/compare/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/compare/commands/compare/interactors/tests/test_fetch_impacted_files.py b/apps/codecov-api/compare/commands/compare/interactors/tests/test_fetch_impacted_files.py
new file mode 100644
index 0000000000..ec36e936e4
--- /dev/null
+++ b/apps/codecov-api/compare/commands/compare/interactors/tests/test_fetch_impacted_files.py
@@ -0,0 +1,800 @@
+import enum
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from compare.commands.compare.interactors.fetch_impacted_files import (
+ ImpactedFileParameter,
+)
+from compare.tests.factories import CommitComparisonFactory
+from services.comparison import Comparison, ComparisonReport, PullRequestComparison
+from services.components import Component
+
+from ..fetch_impacted_files import FetchImpactedFiles
+
+
+class OrderingDirection(enum.Enum):
+ ASC = "ascending"
+ DESC = "descending"
+
+
+mock_data_without_misses = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [],
+ "unexpected_line_changes": []
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": []
+ }]
+}
+"""
+
+
+mock_data_from_archive = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [2,"m"],
+ [3,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "m"]], [[2, "h"], [2, "m"]]]
+ }]
+}
+"""
+
+mocked_files_with_direct_and_indirect_changes = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [2,"m"],
+ [3,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": []
+ },
+ {
+ "head_name": "fileC",
+ "base_name": "fileC",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ }]
+}
+"""
+
+
+mocked_component_files_with_direct_and_indirect_changes = """
+{
+ "files": [{
+ "head_name": "fileA.py",
+ "base_name": "fileA.py",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [2,"m"],
+ [3,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileB.js",
+ "base_name": "fileB.js",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": []
+ }]
+}
+"""
+
+
+class FetchImpactedFilesTest(TestCase):
+ def setUp(self):
+ self.user = OwnerFactory(username="codecov-user")
+ self.parent_commit = CommitFactory()
+ self.commit = CommitFactory(
+ parent_commit_id=self.parent_commit.commitid,
+ repository=self.parent_commit.repository,
+ )
+ self.commit_comparison = CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ report_storage_path="v4/test.json",
+ )
+ self.comparison_report = ComparisonReport(self.commit_comparison)
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return FetchImpactedFiles(owner, service).execute(*args)
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_file_sort_function(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ parameter = ImpactedFileParameter.CHANGE_COVERAGE
+ direction = OrderingDirection.ASC
+ filters = {"ordering": {"parameter": parameter, "direction": direction}}
+ comparison = None
+ sorted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in sorted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_file_sort_function_no_misses(self, read_file):
+ read_file.return_value = mock_data_without_misses
+ parameter = ImpactedFileParameter.MISSES_COUNT
+ direction = OrderingDirection.ASC
+ filters = {"ordering": {"parameter": parameter, "direction": direction}}
+ comparison = None
+ sorted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in sorted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_file_sort_function_error(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ parameter = "something else"
+ direction = OrderingDirection.DESC
+ filters = {"ordering": {"parameter": parameter, "direction": direction}}
+ comparison = None
+
+ with self.assertRaises(ValueError) as ctx:
+ self.execute(None, self.comparison_report, comparison, filters)
+ self.assertEqual(
+ "invalid impacted file parameter: something else", str(ctx.exception)
+ )
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_change_coverage_ascending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.CHANGE_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_change_coverage_descending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.DESC,
+ "parameter": ImpactedFileParameter.CHANGE_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileB", "fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_head_coverage_ascending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.HEAD_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_patch_coverage_ascending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.PATCH_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_patch_coverage_descending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.DESC,
+ "parameter": ImpactedFileParameter.PATCH_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileB", "fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_head_coverage_descending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.DESC,
+ "parameter": ImpactedFileParameter.HEAD_COVERAGE,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileB", "fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_head_name_ascending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.FILE_NAME,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_head_name_descending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.DESC,
+ "parameter": ImpactedFileParameter.FILE_NAME,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileB", "fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_misses_count_ascending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.MISSES_COUNT,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_misses_count_descending(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "ordering": {
+ "direction": OrderingDirection.DESC,
+ "parameter": ImpactedFileParameter.MISSES_COUNT,
+ }
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileB", "fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_without_filters(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {}
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_unintended_changes(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ filters = {
+ "has_unintended_changes": True,
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.FILE_NAME,
+ },
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_unintended_changes_set_to_false(
+ self, read_file
+ ):
+ read_file.return_value = mocked_files_with_direct_and_indirect_changes
+ filters = {
+ "has_unintended_changes": False,
+ "ordering": {
+ "direction": OrderingDirection.ASC,
+ "parameter": ImpactedFileParameter.FILE_NAME,
+ },
+ }
+ comparison = None
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert [file.head_name for file in impacted_files] == ["fileA", "fileB"]
+
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_flags_and_commit_comparison_for_pull(
+ self, read_file, build_report_from_commit_mock
+ ):
+ read_file.return_value = mocked_files_with_direct_and_indirect_changes
+
+ commit_report = Report()
+ session_a_id, _ = commit_report.add_session(Session(flags=["flag-123"]))
+ session_b_id, _ = commit_report.add_session(Session(flags=["flag-456"]))
+ file_a = ReportFile("fileA")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ commit_report.append(file_a)
+ file_b = ReportFile("fileB")
+ file_b.append(1, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ commit_report.append(file_b)
+ build_report_from_commit_mock.return_value = commit_report
+
+ flags = ["flag-123"]
+ filters = {"flags": flags}
+
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head, compared_to = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+ pull = PullFactory(
+ pullid=256,
+ repository=repo,
+ base=base.commitid,
+ head=head.commitid,
+ compared_to=compared_to.commitid,
+ )
+ comparison = PullRequestComparison(user=owner, pull=pull)
+
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert len(impacted_files) == 1
+ assert impacted_files[0].head_name == "fileA"
+
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_flags_and_commit_comparison_for_parent_commit(
+ self, read_file, build_report_from_commit_mock
+ ):
+ read_file.return_value = mocked_files_with_direct_and_indirect_changes
+
+ commit_report = Report()
+ session_a_id, _ = commit_report.add_session(Session(flags=["flag-123"]))
+ session_b_id, _ = commit_report.add_session(Session(flags=["flag-456"]))
+ file_a = ReportFile("fileA")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ commit_report.append(file_a)
+ file_b = ReportFile("fileB")
+ file_b.append(1, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ commit_report.append(file_b)
+ build_report_from_commit_mock.return_value = commit_report
+
+ flags = ["flag-123"]
+ filters = {"flags": flags}
+
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+ comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert len(impacted_files) == 1
+ assert impacted_files[0].head_name == "fileA"
+
+ @patch("services.components.commit_components")
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_components_and_commit_comparison_for_parent_commit(
+ self, read_file, build_report_from_commit_mock, commit_components_mock
+ ):
+ read_file.return_value = mocked_component_files_with_direct_and_indirect_changes
+
+ commit_report = Report()
+ session_a_id, _ = commit_report.add_session(Session(flags=["flag-123"]))
+ session_b_id, _ = commit_report.add_session(Session(flags=["flag-456"]))
+ file_a = ReportFile("fileA.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ commit_report.append(file_a)
+ file_b = ReportFile("fileB.js")
+ file_b.append(1, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ commit_report.append(file_b)
+ build_report_from_commit_mock.return_value = commit_report
+
+ filters = {"components": ["PYThon"]}
+
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+
+ # components filter
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {"component_id": "python1.1", "paths": [".*/*.py"], "name": "PYThon"}
+ ),
+ Component.from_dict(
+ {"component_id": "golang1.2", "paths": [".*/*.go"], "name": "GOLang"}
+ ),
+ ]
+ comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert len(impacted_files) == 1
+ assert impacted_files[0].head_name == "fileA.py"
+
+ @patch("services.components.commit_components")
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_components_using_flags(
+ self, read_file, build_report_from_commit_mock, commit_components_mock
+ ):
+ read_file.return_value = mocked_component_files_with_direct_and_indirect_changes
+
+ commit_report = Report()
+ session_a_id, _ = commit_report.add_session(Session(flags=["flag-123"]))
+ session_b_id, _ = commit_report.add_session(Session(flags=["flag-456"]))
+ file_a = ReportFile("fileA.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ commit_report.append(file_a)
+ file_b = ReportFile("fileB.js")
+ file_b.append(1, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ commit_report.append(file_b)
+ build_report_from_commit_mock.return_value = commit_report
+
+ filters = {"components": ["javascript"]}
+
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+
+ # components filter
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {"component_id": "python1.1", "paths": [".*/*.py"], "name": "PYThon"}
+ ),
+ Component.from_dict(
+ {
+ "component_id": "javascript1.2",
+ "name": "javascript",
+ "flags_regexes": "flag-123",
+ }
+ ),
+ ]
+ comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+
+ assert len(impacted_files) == 2
+ assert impacted_files[0].head_name == "fileA.py"
+ assert impacted_files[1].head_name == "fileB.js"
+
+ @patch("services.components.commit_components")
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_components_and_flags_commit_comparison_for_parent_commit(
+ self, read_file, build_report_from_commit_mock, commit_components_mock
+ ):
+ read_file.return_value = mocked_component_files_with_direct_and_indirect_changes
+
+ commit_report = Report()
+ session_a_id, _ = commit_report.add_session(Session(flags=["flag-123"]))
+ session_b_id, _ = commit_report.add_session(Session(flags=["flag-456"]))
+ file_a = ReportFile("fileA.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ commit_report.append(file_a)
+ file_b = ReportFile("fileB.js")
+ file_b.append(1, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ commit_report.append(file_b)
+ build_report_from_commit_mock.return_value = commit_report
+
+ filters = {"components": ["PYThon"], "flags": ["flag-123"]}
+
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+
+ # components filter
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "python1.1",
+ "paths": [".*/*.py"],
+ "name": "PYThon",
+ "flag_regexes": "flag-123",
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "javascript.2",
+ "paths": [".*/*.js"],
+ "name": "javaScript",
+ "flag_regexes": "flag-456",
+ }
+ ),
+ ]
+ comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+
+ impacted_files = self.execute(None, self.comparison_report, comparison, filters)
+ assert len(impacted_files) == 1
+ assert impacted_files[0].head_name == "fileA.py"
diff --git a/apps/codecov-api/compare/models.py b/apps/codecov-api/compare/models.py
new file mode 100644
index 0000000000..1105eb8463
--- /dev/null
+++ b/apps/codecov-api/compare/models.py
@@ -0,0 +1 @@
+from shared.django_apps.compare.models import *
diff --git a/apps/codecov-api/compare/tests/__init__.py b/apps/codecov-api/compare/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/compare/tests/factories.py b/apps/codecov-api/compare/tests/factories.py
new file mode 100644
index 0000000000..d953e2ffed
--- /dev/null
+++ b/apps/codecov-api/compare/tests/factories.py
@@ -0,0 +1 @@
+from shared.django_apps.compare.tests.factories import *
diff --git a/apps/codecov-api/compare/tests/test_admin.py b/apps/codecov-api/compare/tests/test_admin.py
new file mode 100644
index 0000000000..ea4c58c304
--- /dev/null
+++ b/apps/codecov-api/compare/tests/test_admin.py
@@ -0,0 +1,18 @@
+from django.test import TestCase
+from django.urls import reverse
+from shared.django_apps.codecov_auth.tests.factories import UserFactory
+
+from .factories import CommitComparisonFactory
+
+
+class CompareAdminTest(TestCase):
+ def setUp(self):
+ # Create a couple of comparison so the list has something to display
+ CommitComparisonFactory()
+ CommitComparisonFactory()
+ self.staff_user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.staff_user)
+
+ def test_compare_admin_detail_page(self):
+ response = self.client.get(reverse("admin:compare_commitcomparison_changelist"))
+ self.assertEqual(response.status_code, 200)
diff --git a/apps/codecov-api/conftest.py b/apps/codecov-api/conftest.py
new file mode 100644
index 0000000000..d9d03c8396
--- /dev/null
+++ b/apps/codecov-api/conftest.py
@@ -0,0 +1,77 @@
+from pathlib import Path
+
+import fakeredis
+import pytest
+import vcr
+from django.conf import settings
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+# we need to enable this in the test environment since we're often creating
+# timeseries data and then asserting something about the aggregates all in
+# a single transaction. calling `refresh_continuous_aggregate` doesn't work
+# either since it cannot be called in a transaction.
+settings.TIMESERIES_REAL_TIME_AGGREGATES = True
+
+
+def pytest_configure(config):
+ """
+ pytest_configure is the canonical way to configure test server for entire testing suite
+ """
+ pass
+
+
+@pytest.fixture
+def codecov_vcr(request):
+ current_path = Path(request.node.fspath)
+ current_path_name = current_path.name.replace(".py", "")
+ cassette_path = current_path.parent / "cassetes" / current_path_name
+ if request.node.cls:
+ cls_name = request.node.cls.__name__
+ cassette_path = cassette_path / cls_name
+ current_name = request.node.name
+ cassette_file_path = str(cassette_path / f"{current_name}.yaml")
+ with vcr.use_cassette(
+ cassette_file_path,
+ filter_headers=["authorization"],
+ match_on=["method", "scheme", "host", "port", "path"],
+ ) as cassette_maker:
+ yield cassette_maker
+
+
+@pytest.fixture
+def mock_redis(mocker):
+ m = mocker.patch("shared.helpers.redis._get_redis_instance_from_url")
+ redis_server = fakeredis.FakeStrictRedis()
+ m.return_value = redis_server
+ yield redis_server
+
+
+@pytest.fixture(scope="class")
+def sample_report(request):
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ third_file = ReportFile("file3.py")
+ third_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.append(third_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+
+ request.cls.sample_report = report
diff --git a/apps/codecov-api/core/__init__.py b/apps/codecov-api/core/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/admin.py b/apps/codecov-api/core/admin.py
new file mode 100644
index 0000000000..2a1908e826
--- /dev/null
+++ b/apps/codecov-api/core/admin.py
@@ -0,0 +1,139 @@
+from django import forms
+from django.contrib import admin
+from django.core.paginator import Paginator
+from django.db import connections
+from django.utils.functional import cached_property
+
+from codecov.admin import AdminMixin
+from codecov_auth.models import RepositoryToken
+from core.models import Pull, Repository
+from services.task.task import TaskService
+
+
+class RepositoryTokenInline(admin.TabularInline):
+ model = RepositoryToken
+ readonly_fields = ["key"]
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ class Meta:
+ readonly_fields = ("key",)
+
+
+class EstimatedCountPaginator(Paginator):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.object_list.count = self.count
+
+ @cached_property
+ def count(self):
+ # Inspired by https://code.djangoproject.com/ticket/8408
+ if self.object_list.query.where:
+ return self.object_list.count()
+
+ db_table = self.object_list.model._meta.db_table
+ cursor = connections[self.object_list.db].cursor()
+ cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s", (db_table,))
+ result = cursor.fetchone()
+ if not result:
+ return 0
+ return int(result[0])
+
+
+class RepositoryAdminForm(forms.ModelForm):
+ # the model field has null=True but not blank=True, so we have to add a workaround
+ # to be able to clear out this field through the django admin
+ webhook_secret = forms.CharField(required=False, empty_value=None)
+ yaml = forms.JSONField(required=False)
+ using_integration = forms.BooleanField(required=False)
+ hookid = forms.CharField(required=False, empty_value=None)
+
+ class Meta:
+ model = Repository
+ fields = "__all__"
+
+
+@admin.register(Repository)
+class RepositoryAdmin(AdminMixin, admin.ModelAdmin):
+ inlines = [RepositoryTokenInline]
+ list_display = ("name", "service_id", "author")
+ search_fields = ("author__username__exact",)
+ show_full_result_count = False
+ autocomplete_fields = ("bot",)
+ form = RepositoryAdminForm
+
+ paginator = EstimatedCountPaginator
+
+ readonly_fields = (
+ "name",
+ "author",
+ "service_id",
+ "updatestamp",
+ "active",
+ "language",
+ "fork",
+ "upload_token",
+ "yaml",
+ "image_token",
+ "hookid",
+ "activated",
+ "deleted",
+ )
+ fields = readonly_fields + (
+ "bot",
+ "using_integration",
+ "branch",
+ "private",
+ "webhook_secret",
+ )
+
+ def has_add_permission(self, _, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return bool(request.user and request.user.is_superuser)
+
+ def delete_queryset(self, request, queryset) -> None:
+ for repo in queryset:
+ TaskService().flush_repo(repository_id=repo.repoid)
+
+ def delete_model(self, request, obj) -> None:
+ TaskService().flush_repo(repository_id=obj.repoid)
+
+
+@admin.register(Pull)
+class PullsAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("pullid", "repository", "author")
+ show_full_result_count = False
+ paginator = EstimatedCountPaginator
+ readonly_fields = (
+ "repository",
+ "id",
+ "pullid",
+ "issueid",
+ "title",
+ "base",
+ "head",
+ "user_provided_base_sha",
+ "compared_to",
+ "commentid",
+ "author",
+ "updatestamp",
+ "diff",
+ "flare",
+ )
+ fields = readonly_fields + ("state",)
+
+ @admin.display(description="flare")
+ def flare(self, instance):
+ return instance.flare
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_add_permission(self, _, obj=None):
+ return False
diff --git a/apps/codecov-api/core/apps.py b/apps/codecov-api/core/apps.py
new file mode 100644
index 0000000000..97b655b18b
--- /dev/null
+++ b/apps/codecov-api/core/apps.py
@@ -0,0 +1,20 @@
+import logging
+
+from django.apps import AppConfig
+from shared.helpers.cache import RedisBackend, cache
+from shared.helpers.redis import get_redis_connection
+
+from utils.config import RUN_ENV
+
+logger = logging.getLogger(__name__)
+
+
+class CoreConfig(AppConfig):
+ name = "core"
+
+ def ready(self):
+ import core.signals # noqa: F401
+
+ if RUN_ENV not in ["DEV", "TESTING"]:
+ cache_backend = RedisBackend(get_redis_connection())
+ cache.configure(cache_backend)
diff --git a/apps/codecov-api/core/commands/__init__.py b/apps/codecov-api/core/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/branch/__init__.py b/apps/codecov-api/core/commands/branch/__init__.py
new file mode 100644
index 0000000000..62e21450e3
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/__init__.py
@@ -0,0 +1,3 @@
+from .branch import BranchCommands
+
+__all__ = ["BranchCommands"]
diff --git a/apps/codecov-api/core/commands/branch/branch.py b/apps/codecov-api/core/commands/branch/branch.py
new file mode 100644
index 0000000000..8678fdcdd9
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/branch.py
@@ -0,0 +1,16 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.fetch_branch import FetchBranchInteractor
+from .interactors.fetch_branches import FetchRepoBranchesInteractor
+
+
+class BranchCommands(BaseCommand):
+ def fetch_branch(self, repository, branch_name):
+ return self.get_interactor(FetchBranchInteractor).execute(
+ repository, branch_name
+ )
+
+ def fetch_branches(self, repository, filters):
+ return self.get_interactor(FetchRepoBranchesInteractor).execute(
+ repository, filters
+ )
diff --git a/apps/codecov-api/core/commands/branch/interactors/__init__.py b/apps/codecov-api/core/commands/branch/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/branch/interactors/fetch_branch.py b/apps/codecov-api/core/commands/branch/interactors/fetch_branch.py
new file mode 100644
index 0000000000..60410484d9
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/interactors/fetch_branch.py
@@ -0,0 +1,9 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+
+
+class FetchBranchInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, repository, branch_name):
+ return repository.branches.filter(name=branch_name).first()
diff --git a/apps/codecov-api/core/commands/branch/interactors/fetch_branches.py b/apps/codecov-api/core/commands/branch/interactors/fetch_branches.py
new file mode 100644
index 0000000000..e19e7ae4d7
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/interactors/fetch_branches.py
@@ -0,0 +1,39 @@
+from typing import Any
+
+from asgiref.sync import sync_to_async
+from django.db.models import OuterRef, Q, QuerySet, Subquery
+from shared.django_apps.core.models import Repository
+
+from codecov.commands.base import BaseInteractor
+from core.models import Commit
+
+
+class FetchRepoBranchesInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, repository: Repository, filters: dict[str, Any]) -> QuerySet:
+ queryset = repository.branches.all()
+
+ filters = filters or {}
+ search_value = filters.get("search_value")
+ if search_value:
+ # force use of ILIKE to optimize search; django icontains doesn't work
+ # see https://github.com/codecov/engineering-team/issues/2537
+ queryset = queryset.extra(
+ where=['"branches"."branch" ILIKE %s'], params=[f"%{search_value}%"]
+ )
+
+ merged = filters.get("merged_branches", False)
+ if not merged:
+ queryset = queryset.annotate(
+ merged=Subquery(
+ Commit.objects.filter(
+ commitid=OuterRef("head"),
+ repository_id=OuterRef("repository__repoid"),
+ ).values("merged")[:1]
+ )
+ ).filter(
+ Q(merged__isnot=True) # exclude merged branches
+ | Q(name=repository.branch) # but always include the default branch
+ )
+
+ return queryset
diff --git a/apps/codecov-api/core/commands/branch/interactors/tests/__init__.py b/apps/codecov-api/core/commands/branch/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branch.py b/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branch.py
new file mode 100644
index 0000000000..7169ab7a3a
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branch.py
@@ -0,0 +1,24 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import BranchFactory, OwnerFactory
+
+from ..fetch_branch import FetchBranchInteractor
+
+
+class FetchBranchInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.branch = BranchFactory()
+ self.repo = self.branch.repository
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return FetchBranchInteractor(owner, service).execute(*args)
+
+ async def test_fetch_branch(self):
+ branch = await self.execute(None, self.repo, self.branch.name)
+ assert branch == self.branch
+
+ async def test_fetch_branch_doesnt_exist(self):
+ branch = await self.execute(None, self.repo, "do not exist")
+ assert branch is None
diff --git a/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branches.py b/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branches.py
new file mode 100644
index 0000000000..5b5a3589b7
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/interactors/tests/test_fetch_branches.py
@@ -0,0 +1,81 @@
+from typing import Any
+
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from ..fetch_branches import FetchRepoBranchesInteractor
+
+
+class FetchRepoBranchesInteractorTest(TestCase):
+ def setUp(self) -> None:
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(
+ author=self.org, name="gazebo", private=False, branch="main"
+ )
+ self.head = CommitFactory(repository=self.repo)
+ self.commit = CommitFactory(repository=self.repo)
+ self.branches = [
+ BranchFactory(repository=self.repo, head=self.head.commitid, name="test1"),
+ BranchFactory(repository=self.repo, head=self.head.commitid, name="test2"),
+ ]
+
+ def execute(self, owner, repository, filters) -> FetchRepoBranchesInteractor:
+ service = owner.service if owner else "github"
+ return FetchRepoBranchesInteractor(owner, service).execute(repository, filters)
+
+ def test_fetch_branches(self) -> None:
+ repository = self.repo
+ filters: dict[str, Any] = {}
+ branches = async_to_sync(self.execute)(None, repository, filters)
+ assert any(branch.name == "main" for branch in branches)
+ assert any(branch.name == "test1" for branch in branches)
+ assert any(branch.name == "test2" for branch in branches)
+ assert len(branches) == 3
+
+ def test_fetch_branches_unmerged(self) -> None:
+ merged = CommitFactory(repository=self.repo, merged=True)
+ BranchFactory(repository=self.repo, head=merged.commitid, name="merged")
+ branches = [
+ branch.name for branch in async_to_sync(self.execute)(None, self.repo, {})
+ ]
+ assert "merged" not in branches
+ branches = [
+ branch.name
+ for branch in async_to_sync(self.execute)(
+ None, self.repo, {"merged_branches": True}
+ )
+ ]
+ assert "merged" in branches
+
+ def test_fetch_branches_filtered_by_name(self) -> None:
+ repository = self.repo
+ filters = {"search_value": "tESt", "merged_branches": True}
+ branches = async_to_sync(self.execute)(None, repository, filters)
+ assert not any(branch.name == "main" for branch in branches)
+ assert any(branch.name == "test1" for branch in branches)
+ assert any(branch.name == "test2" for branch in branches)
+ assert len(branches) == 2
+
+ def test_fetch_branches_filtered_by_name_no_sql_injection(self) -> None:
+ repository = self.repo
+ malicious_filters = {
+ "search_value": "'; DROP TABLE branches; --",
+ "merged_branches": True,
+ }
+ find_branches_sql_injection_attempt = async_to_sync(self.execute)(
+ None, repository, malicious_filters
+ )
+ assert (
+ # assert no branches found with that branch name
+ len(find_branches_sql_injection_attempt) == 0
+ )
+
+ # confirm data is unaltered after sql injection attempt
+ find_branches = async_to_sync(self.execute)(None, repository, {})
+ assert len(find_branches) == 3
diff --git a/apps/codecov-api/core/commands/branch/tests/__init__.py b/apps/codecov-api/core/commands/branch/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/branch/tests/test_branch.py b/apps/codecov-api/core/commands/branch/tests/test_branch.py
new file mode 100644
index 0000000000..c661127fcf
--- /dev/null
+++ b/apps/codecov-api/core/commands/branch/tests/test_branch.py
@@ -0,0 +1,25 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from ..branch import BranchCommands
+
+
+class BranchCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.repository = RepositoryFactory()
+ self.command = BranchCommands(self.owner, "github")
+
+ @patch("core.commands.branch.branch.FetchBranchInteractor.execute")
+ def test_fetch_branch_delegate_to_interactor(self, interactor_mock):
+ branch_name = "main"
+ self.command.fetch_branch(self.repository, branch_name)
+ interactor_mock.assert_called_once_with(self.repository, branch_name)
+
+ @patch("core.commands.branch.branch.FetchRepoBranchesInteractor.execute")
+ def test_fetch_branches_delegate_to_interactor(self, interactor_mock):
+ filters = {}
+ self.command.fetch_branches(self.repository, filters)
+ interactor_mock.assert_called_once_with(self.repository, filters)
diff --git a/apps/codecov-api/core/commands/commit/__init__.py b/apps/codecov-api/core/commands/commit/__init__.py
new file mode 100644
index 0000000000..38bf294c84
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/__init__.py
@@ -0,0 +1,3 @@
+from .commit import CommitCommands
+
+__all__ = ["CommitCommands"]
diff --git a/apps/codecov-api/core/commands/commit/commit.py b/apps/codecov-api/core/commands/commit/commit.py
new file mode 100644
index 0000000000..ea88dc8aeb
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/commit.py
@@ -0,0 +1,30 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.fetch_totals import FetchTotalsInteractor
+from .interactors.get_commit_errors import GetCommitErrorsInteractor
+from .interactors.get_file_content import GetFileContentInteractor
+from .interactors.get_final_yaml import GetFinalYamlInteractor
+from .interactors.get_latest_upload_error import GetLatestUploadErrorInteractor
+from .interactors.get_uploads_number import GetUploadsNumberInteractor
+
+
+class CommitCommands(BaseCommand):
+ def get_file_content(self, commit, path):
+ return self.get_interactor(GetFileContentInteractor).execute(commit, path)
+
+ def fetch_totals(self, commit):
+ return self.get_interactor(FetchTotalsInteractor).execute(commit)
+
+ def get_final_yaml(self, commit):
+ return self.get_interactor(GetFinalYamlInteractor).execute(commit)
+
+ def get_commit_errors(self, commit, error_type):
+ return self.get_interactor(GetCommitErrorsInteractor).execute(
+ commit, error_type
+ )
+
+ def get_uploads_number(self, commit):
+ return self.get_interactor(GetUploadsNumberInteractor).execute(commit)
+
+ def get_latest_upload_error(self, commit):
+ return self.get_interactor(GetLatestUploadErrorInteractor).execute(commit)
diff --git a/apps/codecov-api/core/commands/commit/interactors/__init__.py b/apps/codecov-api/core/commands/commit/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/commit/interactors/fetch_totals.py b/apps/codecov-api/core/commands/commit/interactors/fetch_totals.py
new file mode 100644
index 0000000000..6d46b61bf2
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/fetch_totals.py
@@ -0,0 +1,10 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+
+
+class FetchTotalsInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, commit):
+ if commit.commitreport and hasattr(commit.commitreport, "reportleveltotals"):
+ return commit.commitreport.reportleveltotals
diff --git a/apps/codecov-api/core/commands/commit/interactors/get_commit_errors.py b/apps/codecov-api/core/commands/commit/interactors/get_commit_errors.py
new file mode 100644
index 0000000000..83414615ba
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/get_commit_errors.py
@@ -0,0 +1,18 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from graphql_api.types.enums import CommitErrorCode, CommitErrorGeneralType
+
+
+def errors_by_type(commit, error_type_str):
+ error_type = CommitErrorGeneralType(error_type_str)
+ error_codes = CommitErrorCode.get_codes_from_type(error_type)
+ error_codes_strings = [x.db_string for x in error_codes]
+ errors_by_type = commit.errors.filter(error_code__in=error_codes_strings)
+ return errors_by_type
+
+
+class GetCommitErrorsInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, commit, error_type):
+ return errors_by_type(commit, error_type_str=error_type)
diff --git a/apps/codecov-api/core/commands/commit/interactors/get_file_content.py b/apps/codecov-api/core/commands/commit/interactors/get_file_content.py
new file mode 100644
index 0000000000..f256dc42c5
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/get_file_content.py
@@ -0,0 +1,38 @@
+import logging
+from typing import Any, Coroutine
+
+from codecov.commands.base import BaseInteractor
+from core.models import Commit
+from services.repo_providers import RepoProviderService
+
+log = logging.getLogger(__name__)
+
+
+class GetFileContentInteractor(BaseInteractor):
+ async def get_file_from_service(self, commit: Commit, path: str) -> str | None:
+ try:
+ repository_service = await RepoProviderService().async_get_adapter(
+ owner=self.current_owner, repo=commit.repository
+ )
+ content = await repository_service.get_source(path, commit.commitid)
+
+ # When a file received from GH that is larger than 1MB the result will be
+ # pre-decoded and of string type; no need to decode again in that case
+ if isinstance(content.get("content"), str):
+ return content.get("content")
+ return content.get("content").decode("utf-8")
+ # TODO raise this to the API so we can handle it.
+ except Exception as e:
+ log.warning(
+ "GetFileContentInteractor - exception raised",
+ extra=dict(
+ commitid=commit.commitid,
+ path=path,
+ error_name=type(e).__name__,
+ error_message=str(e),
+ ),
+ )
+ return None
+
+ def execute(self, commit: Commit, path: str) -> Coroutine[Any, Any, str | None]:
+ return self.get_file_from_service(commit, path)
diff --git a/apps/codecov-api/core/commands/commit/interactors/get_final_yaml.py b/apps/codecov-api/core/commands/commit/interactors/get_final_yaml.py
new file mode 100644
index 0000000000..059f55f125
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/get_final_yaml.py
@@ -0,0 +1,10 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from services.yaml import final_commit_yaml
+
+
+class GetFinalYamlInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, commit):
+ return final_commit_yaml(commit, self.current_owner).to_dict()
diff --git a/apps/codecov-api/core/commands/commit/interactors/get_latest_upload_error.py b/apps/codecov-api/core/commands/commit/interactors/get_latest_upload_error.py
new file mode 100644
index 0000000000..46482a71c5
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/get_latest_upload_error.py
@@ -0,0 +1,41 @@
+import logging
+from typing import Optional
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from core.models import Commit
+from reports.models import CommitReport, UploadError
+
+log = logging.getLogger(__name__)
+
+
+class GetLatestUploadErrorInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, commit: Commit) -> Optional[dict]:
+ try:
+ return self._get_latest_error(commit)
+ except Exception as e:
+ log.error(f"Error fetching upload error: {e}")
+ return None
+
+ def _get_latest_error(self, commit: Commit) -> Optional[dict]:
+ latest_error = self._fetch_latest_error(commit)
+ if not latest_error:
+ return None
+
+ return {
+ "error_code": latest_error.error_code,
+ "error_message": latest_error.error_params.get("error_message"),
+ }
+
+ def _fetch_latest_error(self, commit: Commit) -> Optional[UploadError]:
+ return (
+ UploadError.objects.filter(
+ report_session__report__commit=commit,
+ report_session__report__report_type=CommitReport.ReportType.TEST_RESULTS,
+ )
+ .only("error_code", "error_params")
+ .order_by("-created_at")
+ .first()
+ )
diff --git a/apps/codecov-api/core/commands/commit/interactors/get_uploads_number.py b/apps/codecov-api/core/commands/commit/interactors/get_uploads_number.py
new file mode 100644
index 0000000000..f8c0fe8563
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/get_uploads_number.py
@@ -0,0 +1,11 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+
+
+class GetUploadsNumberInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, commit):
+ if not commit.commitreport:
+ return 0
+ return len(commit.commitreport.sessions.all())
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/__init__.py b/apps/codecov-api/core/commands/commit/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/test_get_commits_errors.py b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_commits_errors.py
new file mode 100644
index 0000000000..6d17b3766f
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_commits_errors.py
@@ -0,0 +1,47 @@
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitErrorFactory,
+ CommitFactory,
+ OwnerFactory,
+)
+
+from graphql_api.types.enums import CommitErrorGeneralType
+
+from ..get_commit_errors import GetCommitErrorsInteractor
+
+
+class GetCommitErrorsInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+ self.commit = CommitFactory()
+ self.yaml_commit_error = CommitErrorFactory(
+ commit=self.commit, error_code="invalid_yaml"
+ )
+ self.yaml_commit_error_2 = CommitErrorFactory(
+ commit=self.commit, error_code="yaml_client_error"
+ )
+ self.bot_commit_error = CommitErrorFactory(
+ commit=self.commit, error_code="repo_bot_invalid"
+ )
+
+ # helper to execute the interactor
+ def execute(self, owner, commit, error_type):
+ service = owner.service if owner else "github"
+ return GetCommitErrorsInteractor(owner, service).execute(commit, error_type)
+
+ def test_fetch_yaml_error(self):
+ errors = async_to_sync(self.execute)(
+ owner=self.owner,
+ commit=self.commit,
+ error_type=CommitErrorGeneralType.yaml_error.value,
+ )
+ assert len(errors) == 2
+
+ def test_fetch_bot_error(self):
+ errors = async_to_sync(self.execute)(
+ owner=self.owner,
+ commit=self.commit,
+ error_type=CommitErrorGeneralType.bot_error.value,
+ )
+ assert len(errors) == 1
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/test_get_file_content.py b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_file_content.py
new file mode 100644
index 0000000000..1da49c7b6c
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_file_content.py
@@ -0,0 +1,81 @@
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.torngit.exceptions import TorngitObjectNotFoundError
+
+from ..get_file_content import GetFileContentInteractor
+
+
+class MockedProviderAdapter:
+ async def get_source(self, commit, path):
+ return {
+ "content": b"""
+ def function_1:
+ pass
+ """
+ }
+
+
+class MockedStringProviderAdapter:
+ async def get_source(self, commit, path):
+ return {
+ "content": """
+ def function_1:
+ pass
+ """
+ }
+
+
+class GetFileContentInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.repository = RepositoryFactory()
+ self.commit = CommitFactory()
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return GetFileContentInteractor(owner, service).execute(*args)
+
+ @patch("services.repo_providers.RepoProviderService.async_get_adapter")
+ @pytest.mark.asyncio
+ async def test_when_path_has_file(self, mock_provider_adapter):
+ mock_provider_adapter.return_value = MockedProviderAdapter()
+
+ file_content = await self.execute(None, self.commit, "path/to/file")
+ assert (
+ file_content
+ == """
+ def function_1:
+ pass
+ """
+ )
+
+ @patch("services.repo_providers.RepoProviderService.async_get_adapter")
+ @pytest.mark.asyncio
+ async def test_when_path_has_no_file(self, mock_provider_adapter):
+ mock_provider_adapter.side_effect = TorngitObjectNotFoundError(
+ response_data=404, message="not found"
+ )
+ file_content = await self.execute(None, self.commit, "path")
+ assert file_content is None
+
+ @patch("services.repo_providers.RepoProviderService.async_get_adapter")
+ @pytest.mark.asyncio
+ async def test_when_path_has_file_string_response(self, mock_provider_adapter):
+ mock_provider_adapter.return_value = MockedStringProviderAdapter()
+
+ file_content = await self.execute(None, self.commit, "path/to/file")
+ assert (
+ file_content
+ == """
+ def function_1:
+ pass
+ """
+ )
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/test_get_final_yaml.py b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_final_yaml.py
new file mode 100644
index 0000000000..3a6b5bfe55
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_final_yaml.py
@@ -0,0 +1,46 @@
+import asyncio
+from unittest.mock import patch
+
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.torngit.exceptions import TorngitObjectNotFoundError
+
+from ..get_final_yaml import GetFinalYamlInteractor
+
+
+class GetFinalYamlInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.commit = CommitFactory(repository=self.repo)
+ asyncio.set_event_loop(asyncio.new_event_loop())
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return GetFinalYamlInteractor(owner, service).execute(*args)
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ @async_to_sync
+ async def test_when_commit_has_yaml(self, mock_fetch_yaml):
+ mock_fetch_yaml.return_value = """
+ codecov:
+ notify:
+ require_ci_to_pass: no
+ """
+ config = await self.execute(None, self.commit)
+ assert config["codecov"]["require_ci_to_pass"] is False
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ @async_to_sync
+ async def test_when_commit_has_no_yaml(self, mock_fetch_yaml):
+ mock_fetch_yaml.side_effect = TorngitObjectNotFoundError(
+ response_data=404, message="not found"
+ )
+ config = await self.execute(None, self.commit)
+ assert config["codecov"]["require_ci_to_pass"] is True
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/test_get_latest_upload_error.py b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_latest_upload_error.py
new file mode 100644
index 0000000000..401148bb47
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_latest_upload_error.py
@@ -0,0 +1,94 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from core.commands.commit.interactors.get_latest_upload_error import (
+ GetLatestUploadErrorInteractor,
+)
+from graphql_api.types.enums import UploadErrorEnum
+from reports.models import CommitReport
+from reports.tests.factories import (
+ CommitReportFactory,
+ UploadErrorFactory,
+ UploadFactory,
+)
+
+
+class GetLatestUploadErrorInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org)
+ self.commit = self._create_commit_with_errors()
+ self.commit_with_no_errors = CommitFactory(repository=self.repo)
+ self.single_error_commit = self._create_commit_with_single_error()
+
+ def _create_commit_with_errors(self):
+ commit = CommitFactory(repository=self.repo)
+ report = CommitReportFactory(
+ commit=commit, report_type=CommitReport.ReportType.TEST_RESULTS
+ )
+ upload = UploadFactory(report=report)
+
+ # Create two errors with different timestamps
+ UploadErrorFactory(
+ report_session=upload,
+ created_at="2024-01-01T10:00:00Z",
+ error_code=UploadErrorEnum.FILE_NOT_IN_STORAGE,
+ error_params={"error_message": "First error"},
+ )
+ UploadErrorFactory(
+ report_session=upload,
+ created_at="2024-01-01T11:00:00Z",
+ error_code=UploadErrorEnum.REPORT_EMPTY,
+ error_params={"error_message": "Latest error"},
+ )
+ return commit
+
+ def _create_commit_with_single_error(self):
+ commit = CommitFactory(repository=self.repo)
+ report = CommitReportFactory(
+ commit=commit,
+ report_type=CommitReport.ReportType.TEST_RESULTS,
+ )
+ upload = UploadFactory(report=report)
+ UploadErrorFactory(
+ report_session=upload,
+ error_code=UploadErrorEnum.UNKNOWN_PROCESSING,
+ error_params={"error_message": "Some other error"},
+ )
+ return commit
+
+ def execute(self, commit, owner=None):
+ service = owner.service if owner else "github"
+ return GetLatestUploadErrorInteractor(owner, service).execute(commit)
+
+ async def test_when_no_errors_then_returns_none(self):
+ result = await self.execute(commit=self.commit_with_no_errors, owner=self.org)
+ assert result is None
+
+ async def test_when_multiple_errors_then_returns_most_recent(self):
+ result = await self.execute(commit=self.commit, owner=self.org)
+ assert result == {
+ "error_code": UploadErrorEnum.REPORT_EMPTY,
+ "error_message": "Latest error",
+ }
+
+ async def test_when_single_error_then_returns_error(self):
+ result = await self.execute(commit=self.single_error_commit, owner=self.org)
+ assert result == {
+ "error_code": UploadErrorEnum.UNKNOWN_PROCESSING,
+ "error_message": "Some other error",
+ }
+
+ async def test_return_none_on_raised_error(self):
+ with patch(
+ "core.commands.commit.interactors.get_latest_upload_error.GetLatestUploadErrorInteractor._get_latest_error",
+ side_effect=Exception("Test error"),
+ ):
+ result = await self.execute(commit=self.commit, owner=self.org)
+ assert result is None
diff --git a/apps/codecov-api/core/commands/commit/interactors/tests/test_get_uploads_number.py b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_uploads_number.py
new file mode 100644
index 0000000000..c19fcc5b18
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/interactors/tests/test_get_uploads_number.py
@@ -0,0 +1,36 @@
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory
+
+from reports.tests.factories import UploadFactory
+
+from ..get_uploads_number import GetUploadsNumberInteractor
+
+
+class GetUploadsNumberInteractorTest(TestCase):
+ def setUp(self):
+ self.commit_with_no_upload = CommitFactory()
+ self.upload_one = UploadFactory()
+ self.upload_two = UploadFactory(report=self.upload_one.report)
+ self.commit_with_upload = self.upload_two.report.commit
+
+ # making sure everything is public
+ self.commit_with_no_upload.repository.private = False
+ self.commit_with_no_upload.repository.save()
+ self.commit_with_upload.repository.private = False
+ self.commit_with_upload.repository.save()
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return GetUploadsNumberInteractor(owner, service).execute(*args)
+
+ async def test_fetch_when_no_reports(self):
+ uploads_number = await self.execute(None, self.commit_with_no_upload)
+ assert uploads_number == 0
+
+ def test_fetch_when_reports(self):
+ # self.execute returns a lazy queryset so we need to wrap it with
+ # async_to_sync
+ uploads_number = async_to_sync(self.execute)(None, self.commit_with_upload)
+ assert uploads_number == 2
diff --git a/apps/codecov-api/core/commands/commit/tests/__init__.py b/apps/codecov-api/core/commands/commit/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/commit/tests/test_commit.py b/apps/codecov-api/core/commands/commit/tests/test_commit.py
new file mode 100644
index 0000000000..2e0f11f090
--- /dev/null
+++ b/apps/codecov-api/core/commands/commit/tests/test_commit.py
@@ -0,0 +1,45 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from ..commit import CommitCommands
+
+
+class CommitCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.repository = RepositoryFactory()
+ self.commit = CommitFactory()
+ self.pull = PullFactory(repository_id=self.repository.repoid)
+ self.command = CommitCommands(self.owner, "github")
+
+ @patch("core.commands.commit.commit.GetFinalYamlInteractor.execute")
+ def test_get_final_yaml_delegate_to_interactor(self, interactor_mock):
+ self.command.get_final_yaml(self.commit)
+ interactor_mock.assert_called_once_with(self.commit)
+
+ @patch("core.commands.commit.commit.GetFileContentInteractor.execute")
+ def test_get_file_content_delegate_to_interactor(self, interactor_mock):
+ self.command.get_file_content(self.commit, "path/to/file")
+ interactor_mock.assert_called_once_with(self.commit, "path/to/file")
+
+ @patch("core.commands.commit.commit.GetCommitErrorsInteractor.execute")
+ def test_get_commit_errors_delegate_to_interactor(self, interactor_mock):
+ self.command.get_commit_errors(self.commit, "YAML_ERROR")
+ interactor_mock.assert_called_once_with(self.commit, "YAML_ERROR")
+
+ @patch("core.commands.commit.commit.GetUploadsNumberInteractor.execute")
+ def test_get_uploads_number_delegate_to_interactor(self, interactor_mock):
+ self.command.get_uploads_number(self.commit)
+ interactor_mock.assert_called_once_with(self.commit)
+
+ @patch("core.commands.commit.commit.GetLatestUploadErrorInteractor.execute")
+ def test_get_latest_upload_error_delegate_to_interactor(self, interactor_mock):
+ self.command.get_latest_upload_error(self.commit)
+ interactor_mock.assert_called_once_with(self.commit)
diff --git a/apps/codecov-api/core/commands/component/__init__.py b/apps/codecov-api/core/commands/component/__init__.py
new file mode 100644
index 0000000000..f26cd80a77
--- /dev/null
+++ b/apps/codecov-api/core/commands/component/__init__.py
@@ -0,0 +1,3 @@
+from .component import ComponentCommands
+
+__all__ = ["ComponentCommands"]
diff --git a/apps/codecov-api/core/commands/component/component.py b/apps/codecov-api/core/commands/component/component.py
new file mode 100644
index 0000000000..632bc8658a
--- /dev/null
+++ b/apps/codecov-api/core/commands/component/component.py
@@ -0,0 +1,12 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.delete_component_measurements import (
+ DeleteComponentMeasurementsInteractor,
+)
+
+
+class ComponentCommands(BaseCommand):
+ def delete_component_measurements(self, *args, **kwargs):
+ return self.get_interactor(DeleteComponentMeasurementsInteractor).execute(
+ *args, **kwargs
+ )
diff --git a/apps/codecov-api/core/commands/component/interactors/__init__.py b/apps/codecov-api/core/commands/component/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/component/interactors/delete_component_measurements.py b/apps/codecov-api/core/commands/component/interactors/delete_component_measurements.py
new file mode 100644
index 0000000000..0b48aafefe
--- /dev/null
+++ b/apps/codecov-api/core/commands/component/interactors/delete_component_measurements.py
@@ -0,0 +1,14 @@
+from codecov.commands.base import BaseInteractor
+from services.task import TaskService
+
+
+class DeleteComponentMeasurementsInteractor(BaseInteractor):
+ def execute(self, owner_username: str, repo_name: str, component_id: str):
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, ensure_is_admin=True
+ )
+
+ TaskService().delete_component_measurements(
+ repo.repoid,
+ component_id,
+ )
diff --git a/apps/codecov-api/core/commands/component/tests/__init__.py b/apps/codecov-api/core/commands/component/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/component/tests/test_component.py b/apps/codecov-api/core/commands/component/tests/test_component.py
new file mode 100644
index 0000000000..c2605dc862
--- /dev/null
+++ b/apps/codecov-api/core/commands/component/tests/test_component.py
@@ -0,0 +1,114 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+
+from ..component import ComponentCommands
+
+
+class MockSignature:
+ def apply_async(self):
+ pass
+
+
+class ComponentCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="test-user")
+ self.org = OwnerFactory(username="test-org", admins=[self.owner.pk])
+ self.owner.organizations = [self.org.pk]
+ self.repo = RepositoryFactory(author=self.org)
+ self.command = ComponentCommands(self.owner, "github")
+
+ @patch("services.task.TaskService.delete_component_measurements")
+ def test_delete_component_measurements(self, mocked_delete_timeseries):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ mocked_delete_timeseries.assert_called_once_with(self.repo.pk, "component1")
+
+ def test_delete_component_measurements_unauthenticated(self):
+ self.command = ComponentCommands(None, "github")
+
+ with self.assertRaises(Unauthenticated):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ def test_delete_component_measurements_owner_not_found(self):
+ with self.assertRaises(ValidationError):
+ self.command.delete_component_measurements(
+ owner_username="nonexistent",
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ def test_delete_component_measurements_repo_not_found(self):
+ with self.assertRaises(ValidationError):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name="nonexistent",
+ component_id="component1",
+ )
+
+ def test_delete_component_measurements_not_admin(self):
+ self.org.admins = []
+ self.org.save()
+
+ with self.assertRaises(Unauthorized):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_config")
+ @patch("services.task.TaskService.delete_component_measurements")
+ def test_delete_component_measurements_self_hosted_admin(
+ self, mocked_delete_timeseries, get_config_mock
+ ):
+ get_config_mock.return_value = [
+ {"service": "github", "username": self.owner.username},
+ ]
+
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ mocked_delete_timeseries.assert_called_once_with(self.repo.pk, "component1")
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_config")
+ def test_delete_component_measurements_self_hosted_non_admin(self, get_config_mock):
+ get_config_mock.return_value = [
+ {"service": "github", "username": "someone-else"},
+ ]
+
+ with self.assertRaises(Unauthorized):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ @patch("services.task.TaskService._create_signature")
+ def test_delete_component_measurements_signature_created(
+ self, mocked_create_signature
+ ):
+ self.command.delete_component_measurements(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ component_id="component1",
+ )
+
+ mocked_create_signature.return_value = MockSignature()
+ mocked_create_signature.assert_called()
diff --git a/apps/codecov-api/core/commands/flag/__init__.py b/apps/codecov-api/core/commands/flag/__init__.py
new file mode 100644
index 0000000000..56918fd20f
--- /dev/null
+++ b/apps/codecov-api/core/commands/flag/__init__.py
@@ -0,0 +1,3 @@
+from .flag import FlagCommands
+
+__all__ = ["FlagCommands"]
diff --git a/apps/codecov-api/core/commands/flag/flag.py b/apps/codecov-api/core/commands/flag/flag.py
new file mode 100644
index 0000000000..3f2703479d
--- /dev/null
+++ b/apps/codecov-api/core/commands/flag/flag.py
@@ -0,0 +1,8 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.delete_flag import DeleteFlagInteractor
+
+
+class FlagCommands(BaseCommand):
+ def delete_flag(self, *args, **kwargs):
+ return self.get_interactor(DeleteFlagInteractor).execute(*args, **kwargs)
diff --git a/apps/codecov-api/core/commands/flag/interactors/__init__.py b/apps/codecov-api/core/commands/flag/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/flag/interactors/delete_flag.py b/apps/codecov-api/core/commands/flag/interactors/delete_flag.py
new file mode 100644
index 0000000000..e029e987c3
--- /dev/null
+++ b/apps/codecov-api/core/commands/flag/interactors/delete_flag.py
@@ -0,0 +1,21 @@
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import (
+ NotFound,
+)
+from reports.models import RepositoryFlag
+
+
+class DeleteFlagInteractor(BaseInteractor):
+ def execute(self, owner_username: str, repo_name: str, flag_name: str):
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, ensure_is_admin=True
+ )
+
+ flag = RepositoryFlag.objects.filter(
+ repository_id=repo.pk, flag_name=flag_name
+ ).first()
+ if not flag:
+ raise NotFound()
+
+ flag.deleted = True
+ flag.save()
diff --git a/apps/codecov-api/core/commands/flag/tests/__init__.py b/apps/codecov-api/core/commands/flag/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/flag/tests/test_flag.py b/apps/codecov-api/core/commands/flag/tests/test_flag.py
new file mode 100644
index 0000000000..66f12ac43f
--- /dev/null
+++ b/apps/codecov-api/core/commands/flag/tests/test_flag.py
@@ -0,0 +1,109 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+from reports.tests.factories import RepositoryFlagFactory
+
+from ..flag import FlagCommands
+
+
+class FlagCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="test-user")
+ self.org = OwnerFactory(username="test-org", admins=[self.owner.pk])
+ self.owner.organizations = [self.org.pk]
+ self.repo = RepositoryFactory(author=self.org)
+ self.command = FlagCommands(self.owner, "github")
+ self.flag = RepositoryFlagFactory(repository=self.repo, flag_name="test-flag")
+
+ def test_delete_flag(self):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
+
+ self.flag.refresh_from_db()
+ assert self.flag.deleted is True
+
+ def test_delete_flag_unauthenticated(self):
+ self.command = FlagCommands(None, "github")
+
+ with self.assertRaises(Unauthenticated):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
+
+ def test_delete_flag_owner_not_found(self):
+ with self.assertRaises(ValidationError):
+ self.command.delete_flag(
+ owner_username="nonexistent",
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
+
+ def test_delete_flag_repo_not_found(self):
+ with self.assertRaises(ValidationError):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name="nonexistent",
+ flag_name=self.flag.flag_name,
+ )
+
+ def test_delete_flag_not_admin(self):
+ self.org.admins = []
+ self.org.save()
+
+ with self.assertRaises(Unauthorized):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
+
+ def test_delete_flag_not_found(self):
+ with self.assertRaises(NotFound):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name="nonexistent",
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_config")
+ def test_delete_flag_self_hosted_admin(self, get_config_mock):
+ get_config_mock.return_value = [
+ {"service": "github", "username": self.owner.username},
+ ]
+
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
+
+ self.flag.refresh_from_db()
+ assert self.flag.deleted is True
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_config")
+ def test_delete_flag_self_hosted_non_admin(self, get_config_mock):
+ get_config_mock.return_value = [
+ {"service": "github", "username": "someone-else"},
+ ]
+
+ with self.assertRaises(Unauthorized):
+ self.command.delete_flag(
+ owner_username=self.org.username,
+ repo_name=self.repo.name,
+ flag_name=self.flag.flag_name,
+ )
diff --git a/apps/codecov-api/core/commands/pull/__init__.py b/apps/codecov-api/core/commands/pull/__init__.py
new file mode 100644
index 0000000000..f4c4878ad0
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/__init__.py
@@ -0,0 +1,3 @@
+from .pull import PullCommands
+
+__all__ = ["PullCommands"]
diff --git a/apps/codecov-api/core/commands/pull/interactors/__init__.py b/apps/codecov-api/core/commands/pull/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/pull/interactors/fetch_pull_request.py b/apps/codecov-api/core/commands/pull/interactors/fetch_pull_request.py
new file mode 100644
index 0000000000..b60a310f0f
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/interactors/fetch_pull_request.py
@@ -0,0 +1,25 @@
+from datetime import datetime, timedelta
+
+from asgiref.sync import sync_to_async
+from shared.django_apps.core.models import Pull, Repository
+
+from codecov.commands.base import BaseInteractor
+from services.task.task import TaskService
+
+
+class FetchPullRequestInteractor(BaseInteractor):
+ def _should_sync_pull(self, pull: Pull | None) -> bool:
+ return (
+ pull is not None
+ and pull.state == "open"
+ and pull.updatestamp is not None
+ and (datetime.now(tz=None) - pull.updatestamp) > timedelta(hours=1)
+ )
+
+ @sync_to_async
+ def execute(self, repository: Repository, id: int) -> Pull:
+ pull = repository.pull_requests.filter(pullid=id).first()
+ if self._should_sync_pull(pull):
+ TaskService().pulls_sync(repository.repoid, id)
+
+ return pull
diff --git a/apps/codecov-api/core/commands/pull/interactors/fetch_pull_requests.py b/apps/codecov-api/core/commands/pull/interactors/fetch_pull_requests.py
new file mode 100644
index 0000000000..bc67abcf51
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/interactors/fetch_pull_requests.py
@@ -0,0 +1,19 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+
+
+class FetchPullRequestsInteractor(BaseInteractor):
+ def apply_filters_to_pulls_queryset(self, queryset, filters):
+ filters = filters or {}
+ state = filters.get("state")
+ if state and len(state) > 0:
+ state_values = [s.value for s in state]
+ queryset = queryset.filter(state__in=state_values)
+ return queryset
+
+ @sync_to_async
+ def execute(self, repository, filters):
+ queryset = repository.pull_requests.all()
+ queryset = self.apply_filters_to_pulls_queryset(queryset, filters)
+ return queryset
diff --git a/apps/codecov-api/core/commands/pull/interactors/tests/__init__.py b/apps/codecov-api/core/commands/pull/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_request.py b/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_request.py
new file mode 100644
index 0000000000..18bbb860b7
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_request.py
@@ -0,0 +1,74 @@
+from datetime import datetime
+
+import pytest
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from ..fetch_pull_request import FetchPullRequestInteractor
+
+
+class FetchPullRequestInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.pr = PullFactory(repository_id=self.repo.repoid)
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return FetchPullRequestInteractor(owner, service).execute(*args)
+
+ async def test_fetch_when_pull_request_doesnt_exist(self):
+ pr = await self.execute(None, self.repo, -12)
+ assert pr is None
+
+ async def test_fetch_pull_request(self):
+ pr = await self.execute(None, self.repo, self.pr.pullid)
+ assert pr == self.pr
+
+
+@freeze_time("2024-07-01 12:00:00")
+@pytest.mark.parametrize(
+ "pr_state, updatestamp, expected",
+ [
+ pytest.param(
+ "open", "2024-07-01 11:50:00", False, id="pr_open_recently_updated"
+ ),
+ pytest.param("merged", "2024-07-01 01:00:00", False, id="pr_merged"),
+ pytest.param(
+ "closed", "2024-07-01 11:50:00", False, id="pr_closed_recently_updated"
+ ),
+ pytest.param(
+ "open", "2024-07-01 01:00:00", True, id="pr_open_not_recently_updated"
+ ),
+ ],
+)
+def test_fetch_pull_should_sync(pr_state, updatestamp, expected, db):
+ repo = RepositoryFactory(private=False)
+ pr = PullFactory(repository_id=repo.repoid, state=pr_state)
+ repo.save()
+ pr.save() # This will change the updatestamp, so we need to set it again
+ pr.updatestamp = datetime.fromisoformat(updatestamp).replace(tzinfo=None)
+ should_sync = FetchPullRequestInteractor(
+ repo.author, repo.service
+ )._should_sync_pull(pr)
+ assert pr.updatestamp == datetime.fromisoformat(updatestamp).replace(tzinfo=None)
+ assert should_sync == expected
+
+
+def test_fetch_pull_updatestamp_is_none(db):
+ repo = RepositoryFactory(private=False)
+ pr = PullFactory(repository_id=repo.repoid, state="open")
+ repo.save()
+ pr.save()
+ pr.updatestamp = None
+ should_sync = FetchPullRequestInteractor(
+ repo.author, repo.service
+ )._should_sync_pull(pr)
+ assert pr.updatestamp is None
+ assert should_sync == False
diff --git a/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_requests.py b/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_requests.py
new file mode 100644
index 0000000000..f511566fbc
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/interactors/tests/test_fetch_pull_requests.py
@@ -0,0 +1,128 @@
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from core.models import PullStates
+
+from ..fetch_pull_requests import FetchPullRequestsInteractor
+
+
+class FetchPullRequestsInteractorTest(TestCase):
+ def setUp(self):
+ self.pull_id = 10
+ self.pull_title = "test-open-pr-1"
+ self.org = OwnerFactory()
+ self.repository_no_pull_requests = RepositoryFactory(
+ author=self.org, private=False
+ )
+ self.repository_with_pull_requests = RepositoryFactory(
+ author=self.org, private=False
+ )
+ PullFactory(
+ pullid=self.pull_id,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title=self.pull_title,
+ state=PullStates.OPEN.value,
+ )
+
+ # helper to execute the interactor
+ def execute(self, owner, *args):
+ service = owner.service if owner else "github"
+ return FetchPullRequestsInteractor(owner, service).execute(*args)
+
+ def test_fetch_when_repository_has_no_pulls(self):
+ self.filters = None
+ no_pull = async_to_sync(self.execute)(
+ None, self.repository_no_pull_requests, self.filters
+ )
+ assert len(no_pull) == 0
+
+ def test_fetch_when_repository_has_pulls(self):
+ self.filters = None
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 1
+ assert pull_request[0].pullid == self.pull_id
+ assert pull_request[0].title == self.pull_title
+ assert (
+ pull_request[0].repository_id == self.repository_with_pull_requests.repoid
+ )
+
+ def test_fetch_when_repository_has_pulls_with_filters(self):
+ # Add more pull requests with different states
+ # 3 open, 2 closed, 1 merged
+ PullFactory(
+ pullid=20,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title="test-open-pr-2",
+ state=PullStates.OPEN.value,
+ )
+ PullFactory(
+ pullid=21,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title="test-open-pr-3",
+ state=PullStates.OPEN.value,
+ )
+ PullFactory(
+ pullid=30,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title="test-closed-pr-1",
+ state=PullStates.CLOSED.value,
+ )
+ PullFactory(
+ pullid=31,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title="test-closed-pr-2",
+ state=PullStates.CLOSED.value,
+ )
+ PullFactory(
+ pullid=40,
+ repository_id=self.repository_with_pull_requests.repoid,
+ title="test-merged-pr-1",
+ state=PullStates.MERGED.value,
+ )
+ # Execute without filters
+ self.filters = None
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 6
+
+ # Execute without open filter
+ self.filters = {"state": [PullStates.OPEN]}
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 3
+ for pull in pull_request:
+ assert pull.state == PullStates.OPEN.value
+
+ # Execute without closed filter
+ self.filters = {"state": [PullStates.CLOSED]}
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 2
+ for pull in pull_request:
+ assert pull.state == PullStates.CLOSED.value
+
+ # Execute without merged filter
+ self.filters = {"state": [PullStates.MERGED]}
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 1
+ for pull in pull_request:
+ assert pull.state == PullStates.MERGED.value
+
+ # Execute without merged filter
+ self.filters = {"state": [PullStates.MERGED, PullStates.OPEN]}
+ pull_request = async_to_sync(self.execute)(
+ None, self.repository_with_pull_requests, self.filters
+ )
+ assert len(pull_request) == 4
diff --git a/apps/codecov-api/core/commands/pull/pull.py b/apps/codecov-api/core/commands/pull/pull.py
new file mode 100644
index 0000000000..0d22cc9b1b
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/pull.py
@@ -0,0 +1,14 @@
+from codecov.commands.base import BaseCommand
+
+from .interactors.fetch_pull_request import FetchPullRequestInteractor
+from .interactors.fetch_pull_requests import FetchPullRequestsInteractor
+
+
+class PullCommands(BaseCommand):
+ def fetch_pull_request(self, repository, id):
+ return self.get_interactor(FetchPullRequestInteractor).execute(repository, id)
+
+ def fetch_pull_requests(self, repository, filters):
+ return self.get_interactor(FetchPullRequestsInteractor).execute(
+ repository, filters
+ )
diff --git a/apps/codecov-api/core/commands/pull/tests/__init__.py b/apps/codecov-api/core/commands/pull/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/pull/tests/test_pull.py b/apps/codecov-api/core/commands/pull/tests/test_pull.py
new file mode 100644
index 0000000000..5caf12873d
--- /dev/null
+++ b/apps/codecov-api/core/commands/pull/tests/test_pull.py
@@ -0,0 +1,26 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from ..pull import PullCommands
+
+
+class PullCommandsTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.repository = RepositoryFactory()
+ self.command = PullCommands(self.owner, "github")
+
+ @patch("core.commands.pull.pull.FetchPullRequestsInteractor.execute")
+ def test_fetch_pull_requests_delegate_to_interactor(self, interactor_mock):
+ self.filters = None
+ repo = RepositoryFactory()
+ self.command.fetch_pull_requests(repo, self.filters)
+ interactor_mock.assert_called_once_with(repo, self.filters)
+
+ @patch("core.commands.pull.pull.FetchPullRequestInteractor.execute")
+ def test_fetch_pull_request_delegate_to_interactor(self, interactor_mock):
+ repo = RepositoryFactory()
+ self.command.fetch_pull_request(repo, 12)
+ interactor_mock.assert_called_once_with(repo, 12)
diff --git a/apps/codecov-api/core/commands/repository/__init__.py b/apps/codecov-api/core/commands/repository/__init__.py
new file mode 100644
index 0000000000..099c43bceb
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/__init__.py
@@ -0,0 +1,3 @@
+from .repository import RepositoryCommands
+
+__all__ = ["RepositoryCommands"]
diff --git a/apps/codecov-api/core/commands/repository/interactors/__init__.py b/apps/codecov-api/core/commands/repository/interactors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/repository/interactors/activate_measurements.py b/apps/codecov-api/core/commands/repository/interactors/activate_measurements.py
new file mode 100644
index 0000000000..5049750d9d
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/activate_measurements.py
@@ -0,0 +1,28 @@
+from asgiref.sync import sync_to_async
+from django.conf import settings
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import ValidationError
+from timeseries.helpers import trigger_backfill
+from timeseries.models import Dataset, MeasurementName
+
+
+class ActivateMeasurementsInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(
+ self, repo_name: str, owner_name: str, measurement_type: MeasurementName
+ ) -> Dataset:
+ if not settings.TIMESERIES_ENABLED:
+ raise ValidationError("Timeseries storage not enabled")
+
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_name, repo_name, only_viewable=True, only_active=True
+ )
+
+ dataset, created = Dataset.objects.get_or_create(
+ name=measurement_type.value, repository_id=repo.pk
+ )
+ if created:
+ trigger_backfill([dataset])
+
+ return dataset
diff --git a/apps/codecov-api/core/commands/repository/interactors/encode_secret_string.py b/apps/codecov-api/core/commands/repository/interactors/encode_secret_string.py
new file mode 100644
index 0000000000..94bceed919
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/encode_secret_string.py
@@ -0,0 +1,27 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+from codecov_auth.models import Owner
+from core.commands.repository.interactors.utils import encode_secret_string
+from core.models import Repository
+
+
+class EncodeSecretStringInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, owner: Owner, repo_name: str, value: str) -> str:
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ repo = Repository.objects.viewable_repos(owner).filter(name=repo_name).first()
+ if not repo:
+ raise ValidationError("Repo not found")
+ to_encode = "/".join(
+ (
+ owner.service,
+ owner.service_id,
+ repo.service_id,
+ value,
+ )
+ )
+ return encode_secret_string(to_encode)
diff --git a/apps/codecov-api/core/commands/repository/interactors/erase_repository.py b/apps/codecov-api/core/commands/repository/interactors/erase_repository.py
new file mode 100644
index 0000000000..804580bed8
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/erase_repository.py
@@ -0,0 +1,15 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from services.task.task import TaskService
+
+
+class EraseRepositoryInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, owner_username: str, repo_name: str) -> None:
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, ensure_is_admin=True
+ )
+
+ TaskService().delete_timeseries(repository_id=repo.repoid)
+ TaskService().flush_repo(repository_id=repo.repoid)
diff --git a/apps/codecov-api/core/commands/repository/interactors/fetch_repository.py b/apps/codecov-api/core/commands/repository/interactors/fetch_repository.py
new file mode 100644
index 0000000000..37292f7bd0
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/fetch_repository.py
@@ -0,0 +1,34 @@
+import sentry_sdk
+from asgiref.sync import sync_to_async
+from shared.django_apps.codecov_auth.models import Owner
+from shared.django_apps.core.models import Repository
+
+from codecov.commands.base import BaseInteractor
+
+
+class FetchRepositoryInteractor(BaseInteractor):
+ @sync_to_async
+ @sentry_sdk.trace
+ def execute(
+ self,
+ owner: Owner,
+ name: str,
+ okta_authenticated_accounts: list[int],
+ exclude_okta_enforced_repos: bool = True,
+ needs_coverage: bool = True,
+ needs_commits: bool = True,
+ ) -> Repository | None:
+ queryset = Repository.objects.viewable_repos(self.current_owner)
+ if exclude_okta_enforced_repos:
+ queryset = queryset.exclude_accounts_enforced_okta(
+ okta_authenticated_accounts
+ )
+
+ if needs_coverage:
+ queryset = queryset.with_recent_coverage()
+ if needs_commits:
+ queryset = queryset.with_oldest_commit_at()
+
+ repo = queryset.filter(author=owner, name=name).select_related("author").first()
+
+ return repo
diff --git a/apps/codecov-api/core/commands/repository/interactors/get_repository_token.py b/apps/codecov-api/core/commands/repository/interactors/get_repository_token.py
new file mode 100644
index 0000000000..361b83b914
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/get_repository_token.py
@@ -0,0 +1,29 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import RepositoryToken
+
+
+class GetRepositoryTokenInteractor(BaseInteractor):
+ def validate(self, repository):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ @sync_to_async
+ def execute(self, repository, token_type):
+ self.validate(repository)
+ if not repository.active:
+ return None
+
+ if current_user_part_of_org(self.current_owner, repository.author):
+ token = RepositoryToken.objects.filter(
+ repository_id=repository.repoid, token_type=token_type
+ ).first()
+ if not token:
+ token = RepositoryToken(
+ repository_id=repository.repoid, token_type=token_type
+ )
+ token.save()
+ return token.key
diff --git a/apps/codecov-api/core/commands/repository/interactors/get_upload_token.py b/apps/codecov-api/core/commands/repository/interactors/get_upload_token.py
new file mode 100644
index 0000000000..80ef954d4a
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/get_upload_token.py
@@ -0,0 +1,9 @@
+from codecov.commands.base import BaseInteractor
+from codecov_auth.helpers import current_user_part_of_org
+
+
+class GetUploadTokenInteractor(BaseInteractor):
+ async def execute(self, repository):
+ if not current_user_part_of_org(self.current_owner, repository.author):
+ return None
+ return repository.upload_token
diff --git a/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_token.py b/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_token.py
new file mode 100644
index 0000000000..9d02738e2c
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_token.py
@@ -0,0 +1,20 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov_auth.models import RepositoryToken
+
+
+class RegenerateRepositoryTokenInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, repo_name: str, owner_username: str, token_type: str):
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, only_viewable=True, only_active=True
+ )
+
+ token, created = RepositoryToken.objects.get_or_create(
+ repository_id=repo.repoid, token_type=token_type
+ )
+ if not created:
+ token.key = token.generate_key()
+ token.save()
+ return token.key
diff --git a/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_upload_token.py b/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_upload_token.py
new file mode 100644
index 0000000000..885f3955b6
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/regenerate_repository_upload_token.py
@@ -0,0 +1,17 @@
+import uuid
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+
+
+class RegenerateRepositoryUploadTokenInteractor(BaseInteractor):
+ @sync_to_async
+ def execute(self, repo_name: str, owner_username: str) -> uuid.UUID:
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, only_viewable=True
+ )
+
+ repo.upload_token = uuid.uuid4()
+ repo.save()
+ return repo.upload_token
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/__init__.py b/apps/codecov-api/core/commands/repository/interactors/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_activate_measurements.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_activate_measurements.py
new file mode 100644
index 0000000000..f3eb9b1224
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_activate_measurements.py
@@ -0,0 +1,116 @@
+from datetime import datetime
+from unittest.mock import patch
+
+import pytest
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from codecov.commands.exceptions import ValidationError
+from timeseries.models import Dataset, MeasurementName
+
+from ..activate_measurements import ActivateMeasurementsInteractor
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class ActivateMeasurementsInteractorTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory(username="test-org")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.user = OwnerFactory(permission=[self.repo.pk])
+
+ @async_to_sync
+ def execute(self, owner, repo_name=None, measurement_type=None):
+ return ActivateMeasurementsInteractor(owner, "github").execute(
+ repo_name=repo_name or "test-repo",
+ owner_name="test-org",
+ measurement_type=measurement_type or MeasurementName.FLAG_COVERAGE,
+ )
+
+ def test_repo_not_found(self):
+ with pytest.raises(ValidationError):
+ self.execute(owner=self.user, repo_name="wrong")
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_timeseries_not_enabled(self):
+ with pytest.raises(ValidationError):
+ self.execute(owner=self.user)
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_creates_flag_dataset(self, backfill_dataset):
+ assert not Dataset.objects.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ self.execute(owner=self.user)
+
+ assert Dataset.objects.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_creates_component_dataset(self, backfill_dataset):
+ assert not Dataset.objects.filter(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ self.execute(
+ owner=self.user,
+ repo_name="test-repo",
+ measurement_type=MeasurementName.COMPONENT_COVERAGE,
+ )
+
+ assert Dataset.objects.filter(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_creates_coverage_dataset(self, backfill_dataset):
+ assert not Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ self.execute(
+ owner=self.user,
+ repo_name="test-repo",
+ measurement_type=MeasurementName.COVERAGE,
+ )
+
+ assert Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).exists()
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_triggers_task(self, backfill_dataset):
+ CommitFactory(repository=self.repo, timestamp=datetime(2000, 1, 1, 1, 1, 1))
+ CommitFactory(repository=self.repo, timestamp=datetime(2021, 12, 31, 1, 1, 1))
+ self.execute(owner=self.user)
+ dataset = Dataset.objects.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).first()
+ backfill_dataset.assert_called_once_with(
+ dataset,
+ start_date=datetime(2000, 1, 1, 1, 1, 1),
+ end_date=datetime(2021, 12, 31, 1, 1, 1),
+ )
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_no_commits(self, backfill_dataset):
+ self.execute(owner=self.user)
+ assert backfill_dataset.call_count == 0
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_encode_secret_string.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_encode_secret_string.py
new file mode 100644
index 0000000000..2b5f8e1b63
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_encode_secret_string.py
@@ -0,0 +1,33 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.encryption.yaml_secret import yaml_secret_encryptor
+
+from codecov.commands.exceptions import Unauthenticated, ValidationError
+
+from ..encode_secret_string import EncodeSecretStringInteractor
+
+
+class EncodeSecretStringInteractorTest(TestCase):
+ @async_to_sync
+ def execute(self, owner, repo_name, value):
+ return EncodeSecretStringInteractor(owner, "github").execute(
+ owner, repo_name, value
+ )
+
+ def test_encode_secret_string(self):
+ owner = OwnerFactory()
+ RepositoryFactory(author=owner, name="repo-1")
+ res = self.execute(owner, repo_name="repo-1", value="token-1")
+ check_encryptor = yaml_secret_encryptor
+ assert "token-1" in check_encryptor.decode(res[7:])
+
+ def test_validation_error_when_repo_not_found(self):
+ owner = OwnerFactory()
+ with pytest.raises(ValidationError):
+ self.execute(owner, repo_name=None, value="token-1")
+
+ def test_user_is_not_authenticated(self):
+ with pytest.raises(Unauthenticated):
+ self.execute(None, repo_name=None, value="test")
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_erase_repository.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_erase_repository.py
new file mode 100644
index 0000000000..aed9fde9a8
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_erase_repository.py
@@ -0,0 +1,32 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthorized
+
+from ..erase_repository import EraseRepositoryInteractor
+
+
+class UpdateRepositoryInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov")
+ self.random_user = OwnerFactory(organizations=[])
+ self.non_admin_user = OwnerFactory(organizations=[self.owner.ownerid])
+
+ def execute_unauthorized_owner(self):
+ return EraseRepositoryInteractor(self.owner, "github").execute(
+ self.random_user.username, "repo-1"
+ )
+
+ def execute_user_not_admin(self):
+ return EraseRepositoryInteractor(self.non_admin_user, "github").execute(
+ self.owner.username, "repo-1"
+ )
+
+ async def test_when_validation_error_unauthorized_owner_not_part_of_org(self):
+ with pytest.raises(Unauthorized):
+ await self.execute_unauthorized_owner()
+
+ async def test_when_validation_error_unauthorized_owner_not_admin(self):
+ with pytest.raises(Unauthorized):
+ await self.execute_user_not_admin()
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_fetch_repository.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_fetch_repository.py
new file mode 100644
index 0000000000..e250ba98d3
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_fetch_repository.py
@@ -0,0 +1,87 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ OktaSettingsFactory,
+)
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from ..fetch_repository import FetchRepositoryInteractor
+
+
+class FetchRepositoryInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+
+ self.okta_account = AccountFactory()
+ self.okta_settings = OktaSettingsFactory(
+ account=self.okta_account, enforced=True
+ )
+ self.okta_org = OwnerFactory(account=self.okta_account)
+
+ self.public_repo = RepositoryFactory(author=self.org, private=False)
+ self.hidden_private_repo = RepositoryFactory(author=self.org, private=True)
+ self.private_repo = RepositoryFactory(author=self.org, private=True)
+ self.okta_private_repo = RepositoryFactory(author=self.okta_org, private=True)
+ self.current_user = OwnerFactory(
+ permission=[self.private_repo.repoid, self.okta_private_repo.repoid],
+ organizations=[self.org.ownerid, self.okta_org.ownerid],
+ )
+
+ # helper to execute the interactor
+ def execute(self, owner, *args, **kwargs):
+ service = owner.service if owner else "github"
+ return FetchRepositoryInteractor(owner, service).execute(*args, **kwargs)
+
+ async def test_fetch_public_repo_unauthenticated(self):
+ repo = await self.execute(None, self.org, self.public_repo.name, [])
+ assert repo == self.public_repo
+
+ async def test_fetch_public_repo_authenticated(self):
+ repo = await self.execute(
+ self.current_user, self.org, self.public_repo.name, []
+ )
+ assert repo == self.public_repo
+
+ async def test_fetch_private_repo_unauthenticated(self):
+ repo = await self.execute(None, self.org, self.private_repo.name, [])
+ assert repo is None
+
+ async def test_fetch_private_repo_authenticated_but_no_permissions(self):
+ repo = await self.execute(
+ self.current_user, self.org, self.hidden_private_repo.name, []
+ )
+ assert repo is None
+
+ async def test_fetch_private_repo_authenticated_with_permissions(self):
+ repo = await self.execute(
+ self.current_user, self.org, self.private_repo.name, []
+ )
+ assert repo == self.private_repo
+
+ async def test_fetch_okta_private_repo_authenticated(self):
+ repo = await self.execute(
+ self.current_user,
+ self.okta_org,
+ self.okta_private_repo.name,
+ [self.okta_account.id],
+ )
+ assert repo == self.okta_private_repo
+
+ async def test_fetch_okta_private_repo_unauthenticated(self):
+ repo = await self.execute(
+ self.current_user,
+ self.okta_org,
+ self.okta_private_repo.name,
+ [],
+ )
+ assert repo is None
+
+ async def test_fetch_okta_private_repo_do_not_exclude_unauthenticated(self):
+ repo = await self.execute(
+ self.current_user,
+ self.okta_org,
+ self.okta_private_repo.name,
+ [],
+ exclude_okta_enforced_repos=False,
+ )
+ assert repo == self.okta_private_repo
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_get_repository_token.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_get_repository_token.py
new file mode 100644
index 0000000000..f634667dd3
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_get_repository_token.py
@@ -0,0 +1,52 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from codecov.commands.exceptions import Unauthenticated
+
+from ..get_repository_token import GetRepositoryTokenInteractor
+
+
+class GetRepositoryTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(name="codecov")
+ self.active_repo = RepositoryFactory(
+ author=self.org, name="gazebo", active=True
+ )
+ self.inactive_repo = RepositoryFactory(
+ author=self.org, name="backend", active=False
+ )
+ self.repo_with_no_token = RepositoryFactory(
+ author=self.org, name="frontend", active=True
+ )
+ self.user = OwnerFactory(organizations=[self.org.ownerid])
+ RepositoryTokenFactory(repository=self.active_repo, key="random")
+
+ def execute(self, owner, repo, token_type="upload"):
+ return GetRepositoryTokenInteractor(owner, "github").execute(
+ repository=repo, token_type=token_type
+ )
+
+ async def test_when_unauthenticated_raise(self):
+ with pytest.raises(Unauthenticated):
+ await self.execute(owner="", repo=self.active_repo)
+
+ async def test_when_repo_inactive(self):
+ token = await self.execute(owner=self.user, repo=self.inactive_repo)
+ assert token is None
+
+ async def test_when_repo_has_no_token(self):
+ token = await self.execute(owner=self.user, repo=self.repo_with_no_token)
+ assert token is not None
+ assert len(token) == 40
+
+ async def test_get_static_analysis_token(self):
+ token = await self.execute(
+ owner=self.user, repo=self.active_repo, token_type="static_analysis"
+ )
+ assert token is not None
+ assert len(token) == 40
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_get_upload_token.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_get_upload_token.py
new file mode 100644
index 0000000000..2f165461d6
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_get_upload_token.py
@@ -0,0 +1,24 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from ..get_upload_token import GetUploadTokenInteractor
+
+
+class GetUploadTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo_in_org = RepositoryFactory(author=self.org)
+ self.random_repo = RepositoryFactory()
+ self.owner = OwnerFactory(organizations=[self.org.ownerid])
+
+ # helper to execute the interactor
+ def execute(self, *args):
+ return GetUploadTokenInteractor(self.owner, self.owner.service).execute(*args)
+
+ async def test_fetch_upload_token_random_repo(self):
+ token = await self.execute(self.random_repo)
+ assert token is None
+
+ async def test_fetch_upload_token_repo_in_my_org(self):
+ token = await self.execute(self.repo_in_org)
+ assert token is self.repo_in_org.upload_token
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_regenerate_repository_token.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_regenerate_repository_token.py
new file mode 100644
index 0000000000..6519e6aaf4
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_regenerate_repository_token.py
@@ -0,0 +1,46 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from codecov.commands.exceptions import ValidationError
+
+from ..regenerate_repository_token import RegenerateRepositoryTokenInteractor
+
+
+class RegenerateRepositoryTokenInteractorTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.active_repo = RepositoryFactory(
+ author=self.org, name="gazebo", active=True
+ )
+ self.inactive_repo = RepositoryFactory(
+ author=self.org, name="backend", active=False
+ )
+ self.repo_with_no_token = RepositoryFactory(
+ author=self.org, name="frontend", active=True
+ )
+ RepositoryTokenFactory(repository=self.active_repo, key="random")
+ self.user = OwnerFactory(
+ organizations=[self.org.ownerid],
+ permission=[self.active_repo.repoid, self.repo_with_no_token.repoid],
+ )
+ self.random_user = OwnerFactory(organizations=[self.org.ownerid])
+
+ def execute(self, owner, repo):
+ return RegenerateRepositoryTokenInteractor(owner, "github").execute(
+ repo_name=repo.name,
+ owner_username=self.org.username,
+ token_type="upload",
+ )
+
+ async def test_when_validation_error_repo_not_active(self):
+ with pytest.raises(ValidationError):
+ await self.execute(owner=self.random_user, repo=self.inactive_repo)
+
+ async def test_when_validation_error_repo_not_viewable(self):
+ with pytest.raises(ValidationError):
+ await self.execute(owner=self.random_user, repo=self.active_repo)
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py
new file mode 100644
index 0000000000..50cd14c90e
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py
@@ -0,0 +1,91 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.bundle_analysis.models import CacheConfig
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from codecov.commands.exceptions import ValidationError
+
+from ..update_bundle_cache_config import UpdateBundleCacheConfigInteractor
+
+
+class UpdateBundleCacheConfigInteractorTest(TestCase):
+ databases = {"default"}
+
+ def setUp(self):
+ self.org = OwnerFactory(username="test-org")
+ self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True)
+ self.user = OwnerFactory(permission=[self.repo.pk])
+
+ @async_to_sync
+ def execute(self, owner, repo_name=None, cache_config=[]):
+ return UpdateBundleCacheConfigInteractor(owner, "github").execute(
+ repo_name=repo_name,
+ owner_username="test-org",
+ cache_config=cache_config,
+ )
+
+ def test_repo_not_found(self):
+ with pytest.raises(ValidationError):
+ self.execute(owner=self.user, repo_name="wrong")
+
+ def test_bundle_not_found(self):
+ with pytest.raises(
+ ValidationError, match="The following bundle names do not exist: wrong"
+ ):
+ self.execute(
+ owner=self.user,
+ repo_name="test-repo",
+ cache_config=[{"bundle_name": "wrong", "toggle_caching": True}],
+ )
+
+ def test_some_bundles_not_found(self):
+ CacheConfig.objects.create(
+ repo_id=self.repo.pk, bundle_name="bundle1", is_caching=True
+ )
+ with pytest.raises(
+ ValidationError, match="The following bundle names do not exist: bundle2"
+ ):
+ self.execute(
+ owner=self.user,
+ repo_name="test-repo",
+ cache_config=[
+ {"bundle_name": "bundle1", "toggle_caching": False},
+ {"bundle_name": "bundle2", "toggle_caching": True},
+ ],
+ )
+
+ def test_update_bundles_successfully(self):
+ CacheConfig.objects.create(
+ repo_id=self.repo.pk, bundle_name="bundle1", is_caching=True
+ )
+ CacheConfig.objects.create(
+ repo_id=self.repo.pk, bundle_name="bundle2", is_caching=True
+ )
+
+ res = self.execute(
+ owner=self.user,
+ repo_name="test-repo",
+ cache_config=[
+ {"bundle_name": "bundle1", "toggle_caching": False},
+ {"bundle_name": "bundle2", "toggle_caching": True},
+ ],
+ )
+
+ assert res == [
+ {"bundle_name": "bundle1", "is_cached": False, "cache_config": False},
+ {"bundle_name": "bundle2", "is_cached": True, "cache_config": True},
+ ]
+
+ assert len(CacheConfig.objects.all()) == 2
+
+ query = CacheConfig.objects.filter(repo_id=self.repo.pk, bundle_name="bundle1")
+ assert len(query) == 1
+ assert query[0].is_caching == False
+
+ query = CacheConfig.objects.filter(repo_id=self.repo.pk, bundle_name="bundle2")
+ assert len(query) == 1
+ assert query[0].is_caching == True
diff --git a/apps/codecov-api/core/commands/repository/interactors/tests/test_update_repository.py b/apps/codecov-api/core/commands/repository/interactors/tests/test_update_repository.py
new file mode 100644
index 0000000000..df8bf7db44
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/tests/test_update_repository.py
@@ -0,0 +1,25 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov.commands.exceptions import Unauthorized
+
+from ..update_repository import UpdateRepositoryInteractor
+
+
+class UpdateRepositoryInteractorTest(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov")
+ self.random_user = OwnerFactory(organizations=[])
+
+ def execute_unauthorized_owner(self):
+ return UpdateRepositoryInteractor(self.owner, "github").execute(
+ repo_name="repo-1",
+ owner=self.random_user,
+ default_branch=None,
+ activated=None,
+ )
+
+ async def test_when_validation_error_unauthorized_owner(self):
+ with pytest.raises(Unauthorized):
+ await self.execute_unauthorized_owner()
diff --git a/apps/codecov-api/core/commands/repository/interactors/update_bundle_cache_config.py b/apps/codecov-api/core/commands/repository/interactors/update_bundle_cache_config.py
new file mode 100644
index 0000000000..812d6ffa32
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/update_bundle_cache_config.py
@@ -0,0 +1,63 @@
+from typing import Dict, List
+
+from asgiref.sync import sync_to_async
+from shared.django_apps.bundle_analysis.models import CacheConfig
+from shared.django_apps.bundle_analysis.service.bundle_analysis import (
+ BundleAnalysisCacheConfigService,
+)
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import ValidationError
+from core.models import Repository
+
+
+class UpdateBundleCacheConfigInteractor(BaseInteractor):
+ def validate(
+ self, repo: Repository, cache_config: List[Dict[str, str | bool]]
+ ) -> None:
+ # Find any missing bundle names
+ bundle_names = [
+ bundle["bundle_name"]
+ for bundle in cache_config
+ # the value of bundle_name is always a string, just do this check to appease mypy
+ if isinstance(bundle["bundle_name"], str)
+ ]
+ existing_bundle_names = set(
+ CacheConfig.objects.filter(
+ repo_id=repo.pk, bundle_name__in=bundle_names
+ ).values_list("bundle_name", flat=True)
+ )
+ missing_bundles = set(bundle_names) - existing_bundle_names
+ if missing_bundles:
+ raise ValidationError(
+ f"The following bundle names do not exist: {', '.join(missing_bundles)}"
+ )
+
+ @sync_to_async
+ def execute(
+ self,
+ owner_username: str,
+ repo_name: str,
+ cache_config: List[Dict[str, str | bool]],
+ ) -> List[Dict[str, str | bool]]:
+ _owner, repo = self.resolve_owner_and_repo(
+ owner_username, repo_name, only_viewable=True
+ )
+
+ self.validate(repo, cache_config)
+
+ results = []
+ for bundle in cache_config:
+ bundle_name = bundle["bundle_name"]
+ is_caching = bundle["toggle_caching"]
+ BundleAnalysisCacheConfigService.update_cache_option(
+ repo.pk, bundle_name, is_caching
+ )
+ results.append(
+ {
+ "bundle_name": bundle_name,
+ "is_cached": is_caching,
+ "cache_config": is_caching,
+ }
+ )
+ return results
diff --git a/apps/codecov-api/core/commands/repository/interactors/update_repository.py b/apps/codecov-api/core/commands/repository/interactors/update_repository.py
new file mode 100644
index 0000000000..4fbe12d9a0
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/update_repository.py
@@ -0,0 +1,43 @@
+from typing import Optional
+
+from asgiref.sync import sync_to_async
+
+from codecov.commands.base import BaseInteractor
+from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import Owner
+from core.models import Repository
+
+
+class UpdateRepositoryInteractor(BaseInteractor):
+ def validate_owner(self, owner: Owner):
+ if not self.current_user.is_authenticated:
+ raise Unauthenticated()
+
+ if not current_user_part_of_org(self.current_owner, owner):
+ raise Unauthorized()
+
+ @sync_to_async
+ def execute(
+ self,
+ repo_name: str,
+ owner: Owner,
+ default_branch: Optional[str],
+ activated: Optional[bool],
+ ):
+ self.validate_owner(owner)
+ repo = Repository.objects.filter(author_id=owner.pk, name=repo_name).first()
+ if not repo:
+ raise ValidationError("Repo not found")
+
+ if default_branch:
+ branch = repo.branches.filter(name=default_branch).first()
+ if branch is None:
+ raise ValidationError(
+ f"The branch '{default_branch}' is not in our records. Please provide a valid branch name.",
+ )
+
+ repo.branch = default_branch
+ if activated:
+ repo.activated = activated
+ repo.save()
diff --git a/apps/codecov-api/core/commands/repository/interactors/utils.py b/apps/codecov-api/core/commands/repository/interactors/utils.py
new file mode 100644
index 0000000000..2f81abefb9
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/interactors/utils.py
@@ -0,0 +1,6 @@
+from shared.encryption.yaml_secret import yaml_secret_encryptor
+
+
+def encode_secret_string(value) -> str:
+ encryptor = yaml_secret_encryptor
+ return "secret:%s" % encryptor.encode(value).decode()
diff --git a/apps/codecov-api/core/commands/repository/repository.py b/apps/codecov-api/core/commands/repository/repository.py
new file mode 100644
index 0000000000..26cd73bde6
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/repository.py
@@ -0,0 +1,89 @@
+import uuid
+from typing import Awaitable, Dict, List, Optional
+
+from codecov.commands.base import BaseCommand
+from codecov_auth.models import Owner, RepositoryToken
+from core.models import Repository
+from timeseries.models import Dataset, MeasurementName
+
+from .interactors.activate_measurements import ActivateMeasurementsInteractor
+from .interactors.encode_secret_string import EncodeSecretStringInteractor
+from .interactors.erase_repository import EraseRepositoryInteractor
+from .interactors.fetch_repository import FetchRepositoryInteractor
+from .interactors.get_repository_token import GetRepositoryTokenInteractor
+from .interactors.get_upload_token import GetUploadTokenInteractor
+from .interactors.regenerate_repository_token import RegenerateRepositoryTokenInteractor
+from .interactors.regenerate_repository_upload_token import (
+ RegenerateRepositoryUploadTokenInteractor,
+)
+from .interactors.update_bundle_cache_config import UpdateBundleCacheConfigInteractor
+from .interactors.update_repository import UpdateRepositoryInteractor
+
+
+class RepositoryCommands(BaseCommand):
+ def fetch_repository(self, *args, **kwargs) -> Repository | None:
+ return self.get_interactor(FetchRepositoryInteractor).execute(*args, **kwargs)
+
+ def regenerate_repository_upload_token(
+ self,
+ repo_name: str,
+ owner_username: str,
+ ) -> Awaitable[uuid.UUID]:
+ return self.get_interactor(RegenerateRepositoryUploadTokenInteractor).execute(
+ repo_name, owner_username
+ )
+
+ def update_repository(
+ self,
+ repo_name: str,
+ owner: Owner,
+ default_branch: Optional[str],
+ activated: Optional[bool],
+ ) -> None:
+ return self.get_interactor(UpdateRepositoryInteractor).execute(
+ repo_name, owner, default_branch, activated
+ )
+
+ def get_upload_token(self, repository: Repository) -> uuid.UUID:
+ return self.get_interactor(GetUploadTokenInteractor).execute(repository)
+
+ def get_repository_token(
+ self, repository: Repository, token_type: RepositoryToken.TokenType
+ ) -> str:
+ return self.get_interactor(GetRepositoryTokenInteractor).execute(
+ repository, token_type
+ )
+
+ def regenerate_repository_token(
+ self, repo_name: str, owner_username: str, token_type: RepositoryToken.TokenType
+ ) -> str:
+ return self.get_interactor(RegenerateRepositoryTokenInteractor).execute(
+ repo_name, owner_username, token_type
+ )
+
+ def activate_measurements(
+ self, repo_name: str, owner_name: str, measurement_type: MeasurementName
+ ) -> Dataset:
+ return self.get_interactor(ActivateMeasurementsInteractor).execute(
+ repo_name, owner_name, measurement_type
+ )
+
+ def erase_repository(self, owner_username: str, repo_name: str) -> None:
+ return self.get_interactor(EraseRepositoryInteractor).execute(
+ owner_username, repo_name
+ )
+
+ def encode_secret_string(self, owner: Owner, repo_name: str, value: str) -> str:
+ return self.get_interactor(EncodeSecretStringInteractor).execute(
+ owner, repo_name, value
+ )
+
+ def update_bundle_cache_config(
+ self,
+ owner_username: str,
+ repo_name: str,
+ cache_config: List[Dict[str, str | bool]],
+ ) -> Awaitable[List[Dict[str, str | bool]]]:
+ return self.get_interactor(UpdateBundleCacheConfigInteractor).execute(
+ owner_username, repo_name, cache_config
+ )
diff --git a/apps/codecov-api/core/commands/repository/tests/__init__.py b/apps/codecov-api/core/commands/repository/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/commands/repository/tests/test_repository.py b/apps/codecov-api/core/commands/repository/tests/test_repository.py
new file mode 100644
index 0000000000..bd956358a7
--- /dev/null
+++ b/apps/codecov-api/core/commands/repository/tests/test_repository.py
@@ -0,0 +1,34 @@
+from unittest.mock import patch
+
+from django.contrib.auth.models import AnonymousUser
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from ..repository import RepositoryCommands
+
+
+class RepositoryCommandsTest(TestCase):
+ def setUp(self):
+ self.user = AnonymousUser()
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org)
+ self.command = RepositoryCommands(None, "github")
+
+ @patch("core.commands.repository.repository.FetchRepositoryInteractor.execute")
+ def test_fetch_repository_to_interactor(self, interactor_mock):
+ self.command.fetch_repository(self.org, self.repo.name, [])
+ interactor_mock.assert_called_once_with(self.org, self.repo.name, [])
+
+ @patch("core.commands.repository.repository.FetchRepositoryInteractor.execute")
+ def test_fetch_repository_to_interactor_with_enforcing_okta(self, interactor_mock):
+ self.command.fetch_repository(
+ self.org, self.repo.name, [], exclude_okta_enforced_repos=False
+ )
+ interactor_mock.assert_called_once_with(
+ self.org, self.repo.name, [], exclude_okta_enforced_repos=False
+ )
+
+ @patch("core.commands.repository.repository.GetUploadTokenInteractor.execute")
+ def test_get_upload_token_to_interactor(self, interactor_mock):
+ self.command.get_upload_token(self.repo)
+ interactor_mock.assert_called_once_with(self.repo)
diff --git a/apps/codecov-api/core/encoders.py b/apps/codecov-api/core/encoders.py
new file mode 100644
index 0000000000..2665a63418
--- /dev/null
+++ b/apps/codecov-api/core/encoders.py
@@ -0,0 +1,10 @@
+from dataclasses import astuple, is_dataclass
+
+from django.core.serializers.json import DjangoJSONEncoder
+
+
+class ReportJSONEncoder(DjangoJSONEncoder):
+ def default(self, obj):
+ if is_dataclass(obj):
+ return astuple(obj)
+ return super().default(self, obj)
diff --git a/apps/codecov-api/core/management/commands/check_for_migration_conflicts.py b/apps/codecov-api/core/management/commands/check_for_migration_conflicts.py
new file mode 100644
index 0000000000..29e77ac14f
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/check_for_migration_conflicts.py
@@ -0,0 +1,37 @@
+import os
+
+from django.apps import apps
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ conflicts_found = False
+ for app in apps.get_app_configs():
+ try:
+ migrations = os.listdir(f"{app.name}/migrations")
+ migrations_by_prefix = {}
+ for migration in migrations:
+ # If it doesn't end in .py it's not a migration file
+ if not migration.endswith(".py"):
+ continue
+
+ prefix = migration[0:4]
+ migrations_by_prefix.setdefault(prefix, []).append(migration)
+
+ for prefix, grouped_migrations in migrations_by_prefix.items():
+ if len(grouped_migrations) > 1:
+ conflicts_found = True
+ print( # noqa: T201
+ f"Conflict found in migrations for {app.name} with prefix {prefix}:"
+ )
+ for grouped_migration in grouped_migrations:
+ print(grouped_migration) # noqa: T201
+ # It's expected to not find migration folders for Django/3rd party apps
+ except FileNotFoundError:
+ pass
+
+ if conflicts_found:
+ raise Exception("Found conflicts in migrations.")
+ else:
+ print("No conflicts found!") # noqa: T201
diff --git a/apps/codecov-api/core/management/commands/codecovPlans-Jan25.csv b/apps/codecov-api/core/management/commands/codecovPlans-Jan25.csv
new file mode 100644
index 0000000000..9e3eae9597
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/codecovPlans-Jan25.csv
@@ -0,0 +1,14 @@
+"id","created_at","updated_at","base_unit_price","benefits","billing_rate","is_active","marketing_name","max_seats","monthly_uploads_limit","paid_plan","name","tier_id","stripe_id"
+10,2025-01-16 04:40:55.162 -0800,2025-01-24 11:33:46.043 -0800,0,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,,,false,users-trial,6,
+9,2025-01-16 04:39:59.759 -0800,2025-01-24 11:34:10.038 -0800,10,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Enterprise Cloud,,,true,users-enterprisey,4,price_1LmjzwGlVGuVgOrkIwlM46EU
+8,2025-01-16 04:39:15.877 -0800,2025-01-24 11:34:31.904 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Enterprise Cloud,,,true,users-enterprisem,4,price_1LmjypGlVGuVgOrkzKtNqhwW
+7,2025-01-16 04:38:12.544 -0800,2025-01-24 11:34:53.935 -0800,4,"{""Up to 10 users"",""Unlimited repositories"",""2500 private repo uploads"",""Patch coverage analysis""}",annually,true,Team,10,2500,true,users-teamy,2,price_1OCM2cGlVGuVgOrkMWUFjPFz
+6,2025-01-16 04:37:08.918 -0800,2025-01-24 11:35:15.346 -0800,5,"{""Up to 10 users"",""Unlimited repositories"",""2500 private repo uploads"",""Patch coverage analysis""}",monthly,true,Team,10,2500,true,users-teamm,2,price_1OCM0gGlVGuVgOrkWDYEBtSL
+5,2025-01-16 04:35:34.152 -0800,2025-01-24 11:35:42.724 -0800,10,"{""Includes 5 seats"",""$10 per additional seat"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Sentry Pro,5,,true,users-sentryy,5,price_1Mj1mMGlVGuVgOrkC0ORc6iW
+4,2025-01-16 04:34:33.867 -0800,2025-01-24 11:35:48.218 -0800,12,"{""Includes 5 seats"",""$10 per additional seat"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Sentry Pro,5,,true,users-sentrym,5,price_1Mj1kYGlVGuVgOrk7jucaZAa
+3,2025-01-16 04:32:44.655 -0800,2025-01-24 11:36:09.660 -0800,10,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Pro,,,true,users-pr-inappy,3,plan_H6P16wij3lUuxg
+2,2025-01-16 04:30:42.897 -0800,2025-01-24 11:36:14.651 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Pro,,,true,users-pr-inappm,3,plan_H6P3KZXwmAbqPS
+13,2025-01-23 14:25:04.793 -0800,2025-01-23 14:25:04.793 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Github Marketplace,,,false,users,3,
+12,2025-01-16 04:44:51.064 -0800,2025-01-24 11:33:14.405 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,1,250,false,users-developer,2,
+11,2025-01-16 04:44:01.249 -0800,2025-01-24 11:33:28.532 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,1,250,false,users-basic,1,
+1,2025-01-16 04:26:44.776 -0800,2025-01-24 11:36:32.387 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,false,Developer,1,,false,users-free,1,
diff --git a/apps/codecov-api/core/management/commands/codecovTiers-Jan25.csv b/apps/codecov-api/core/management/commands/codecovTiers-Jan25.csv
new file mode 100644
index 0000000000..a72a03924b
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/codecovTiers-Jan25.csv
@@ -0,0 +1,7 @@
+"id","created_at","updated_at","tier_name","bundle_analysis","test_analytics","flaky_test_detection","project_coverage","private_repo_support"
+2,2025-01-16 04:22:06.838 -0800,2025-01-16 04:22:06.838 -0800,team,true,true,false,false,true
+3,2025-01-16 04:22:47.221 -0800,2025-01-16 04:22:47.221 -0800,pro,true,true,true,true,true
+4,2025-01-16 04:22:59.652 -0800,2025-01-16 04:22:59.652 -0800,enterprise,true,true,true,true,true
+5,2025-01-16 04:23:08.585 -0800,2025-01-16 04:23:08.585 -0800,sentry,true,true,true,true,true
+1,2025-01-16 04:21:40.374 -0800,2025-01-16 07:34:49.139 -0800,basic,true,true,false,true,true
+6,2025-01-23 14:23:21.504 -0800,2025-01-23 14:23:21.504 -0800,trial,true,true,true,true,true
diff --git a/apps/codecov-api/core/management/commands/delete_rate_limit_keys.py b/apps/codecov-api/core/management/commands/delete_rate_limit_keys.py
new file mode 100644
index 0000000000..dcdfb4f363
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/delete_rate_limit_keys.py
@@ -0,0 +1,27 @@
+from django.core.management.base import BaseCommand, CommandParser
+from shared.helpers.redis import get_redis_connection
+
+
+class Command(BaseCommand):
+ help = "This command is meant to delete all rate limit redis keys for either userId or ip."
+
+ def add_arguments(self, parser: CommandParser) -> None:
+ # This argument switches the command to "anonymous mode" deleting all the ip based keys
+ parser.add_argument("--ip", type=bool)
+
+ def handle(self, *args, **options):
+ redis = get_redis_connection()
+
+ path = "rl-user:*"
+ if options["ip"]:
+ path = "rl-ip:*"
+
+ try:
+ for key in redis.scan_iter(path):
+ # -1 means the key has no expiry
+ if redis.ttl(key) == -1:
+ print(f"Deleting key: {key.decode('utf-8')}") # noqa: T201
+ redis.delete(key)
+ except Exception as e:
+ print("Error occurred when deleting redis keys") # noqa: T201
+ print(e) # noqa: T201
diff --git a/apps/codecov-api/core/management/commands/insert_data_to_db_from_csv.py b/apps/codecov-api/core/management/commands/insert_data_to_db_from_csv.py
new file mode 100644
index 0000000000..122f714a43
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/insert_data_to_db_from_csv.py
@@ -0,0 +1,78 @@
+import csv
+
+from django.core.management.base import BaseCommand
+from shared.django_apps.codecov_auth.models import Plan, Tier
+
+
+class Command(BaseCommand):
+ help = "Insert data from a CSV file into the database for either plans or tiers"
+
+ def add_arguments(self, parser):
+ parser.add_argument("csv_file", type=str, help="The path to the CSV file")
+ parser.add_argument(
+ "--model",
+ type=str,
+ choices=["plans", "tiers"],
+ required=True,
+ help="Specify the model to insert data into: plans or tiers",
+ )
+
+ def handle(self, *args, **kwargs):
+ csv_file_path = kwargs["csv_file"]
+ model_choice = kwargs["model"]
+
+ # Determine which model to use
+ if model_choice == "plans":
+ Model = Plan
+ elif model_choice == "tiers":
+ Model = Tier
+ else:
+ self.stdout.write(self.style.ERROR("Invalid model choice"))
+ return
+
+ with open(csv_file_path, newline="") as csvfile:
+ reader = csv.DictReader(csvfile)
+ for row in reader:
+ model_data = {
+ field: self.convert_value(value)
+ for field, value in row.items()
+ if field in [f.name for f in Model._meta.fields]
+ }
+
+ # Handle ForeignKey for tier
+ if "tier_id" in row and model_choice == "plans":
+ try:
+ model_data["tier"] = Tier.objects.get(id=row["tier_id"])
+ except Tier.DoesNotExist:
+ self.stdout.write(
+ self.style.ERROR(
+ f"Tier with id {row['tier_id']} does not exist. Skipping row."
+ )
+ )
+ continue
+
+ try:
+ Model.objects.update_or_create(
+ defaults=model_data,
+ id=row.get("id"),
+ )
+ self.stdout.write(self.style.SUCCESS(f"Inserted row: {row}"))
+ except Exception as e:
+ self.stdout.write(self.style.ERROR(f"Error inserting row: {e}"))
+ continue
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Successfully inserted all data into {model_choice} from CSV"
+ )
+ )
+
+ def convert_value(self, value):
+ """Convert CSV string values to appropriate Python types."""
+ if value == "":
+ return None
+ if value.lower() == "true":
+ return True
+ if value.lower() == "false":
+ return False
+ return value
diff --git a/apps/codecov-api/core/management/commands/update_gitlab_webhooks.py b/apps/codecov-api/core/management/commands/update_gitlab_webhooks.py
new file mode 100644
index 0000000000..c97a919546
--- /dev/null
+++ b/apps/codecov-api/core/management/commands/update_gitlab_webhooks.py
@@ -0,0 +1,66 @@
+import uuid
+
+from asgiref.sync import async_to_sync
+from django.core.management.base import BaseCommand, CommandParser
+from django.db.models import Q
+from shared.config import get_config
+from shared.torngit.exceptions import TorngitClientError, TorngitRefreshTokenFailedError
+from shared.torngit.gitlab import Gitlab
+
+from core.models import Repository
+from services.repo_providers import RepoProviderService
+from utils.repos import get_bot_user
+
+
+class Command(BaseCommand):
+ def add_arguments(self, parser: CommandParser) -> None:
+ # this can be used to retry if there's an error - restart the command
+ # from the last ID printed before failure
+ parser.add_argument("--starting-repoid", type=int)
+
+ def handle(self, *args, **options):
+ repos = Repository.objects.filter(
+ Q(author__service="gitlab") & ~Q(hookid=None) & Q(webhook_secret=None),
+ ).order_by("repoid")
+
+ if options["starting_repoid"]:
+ repos = repos.filter(pk__gte=options["starting_repoid"])
+
+ webhook_url = get_config("setup", "webhook_url") or get_config(
+ "setup", "codecov_url"
+ )
+
+ for repo in repos:
+ user = get_bot_user(repo)
+ if user is None:
+ continue
+
+ webhook_secret = str(uuid.uuid4())
+ gitlab: Gitlab = RepoProviderService().get_adapter(user, repo)
+
+ try:
+ async_to_sync(gitlab.edit_webhook)(
+ hookid=repo.hookid,
+ name=None,
+ url=f"{webhook_url}/webhooks/gitlab",
+ events={
+ "push_events": True,
+ "issues_events": False,
+ "merge_requests_events": True,
+ "tag_push_events": False,
+ "note_events": False,
+ "job_events": False,
+ "build_events": True,
+ "pipeline_events": True,
+ "wiki_events": False,
+ },
+ secret=webhook_secret,
+ )
+
+ repo.webhook_secret = webhook_secret
+ repo.save()
+ except TorngitClientError as e:
+ print("error making GitLab API call") # noqa: T201
+ print(e) # noqa: T201
+ except TorngitRefreshTokenFailedError:
+ print("refresh token failed") # noqa: T201
diff --git a/apps/codecov-api/core/middleware.py b/apps/codecov-api/core/middleware.py
new file mode 100644
index 0000000000..eb72ed2dab
--- /dev/null
+++ b/apps/codecov-api/core/middleware.py
@@ -0,0 +1,89 @@
+from typing import Optional
+
+from django.http import HttpRequest
+from django.urls import resolve
+from django.utils.deprecation import MiddlewareMixin
+from django_prometheus.middleware import (
+ Metrics,
+ PrometheusAfterMiddleware,
+ PrometheusBeforeMiddleware,
+)
+
+from utils.services import get_long_service_name
+
+# Prometheus metrics that will be annotated with User-Agent http header as label
+USER_AGENT_METRICS = [
+ "django_http_requests_unknown_latency_including_middlewares_total",
+ "django_http_requests_latency_seconds_by_view_method",
+ "django_http_requests_unknown_latency_total",
+ "django_http_requests_total_by_method",
+ "django_http_requests_total_by_transport",
+ "django_http_requests_total_by_view_transport_method",
+ "django_http_requests_body_total_bytes",
+ "django_http_responses_total_by_templatename",
+ "django_http_responses_total_by_status",
+ "django_http_responses_total_by_status_view_method",
+ "django_http_responses_body_total_bytes",
+ "django_http_responses_total_by_charset",
+ "django_http_responses_streaming_total",
+ "django_http_exceptions_total_by_type",
+ "django_http_exceptions_total_by_view",
+]
+
+
+def get_service_long_name(request: HttpRequest) -> Optional[str]:
+ resolver_match = resolve(request.path_info)
+ service = resolver_match.kwargs.get("service")
+ if service is not None:
+ resolver_match.kwargs["service"] = get_long_service_name(service.lower())
+ service = get_long_service_name(service.lower())
+ return service
+ return None
+
+
+class ServiceMiddleware(MiddlewareMixin):
+ def process_view(self, request, view_func, view_args, view_kwargs):
+ service = get_service_long_name(request)
+ if service:
+ view_kwargs["service"] = service
+ return None
+
+
+class CustomMetricsWithUA(Metrics):
+ """
+ django_prometheus Metrics class but with extra user_agent label for applicable metrics
+ """
+
+ def register_metric(self, metric_cls, name, documentation, labelnames=(), **kwargs):
+ # TODO: Re-enable a cheaper form of user-agent logging
+ # https://github.com/codecov/engineering-team/issues/1654
+ # if name in USER_AGENT_METRICS:
+ # labelnames = list(labelnames) + ["user_agent"]
+ return super().register_metric(
+ metric_cls, name, documentation, labelnames=labelnames, **kwargs
+ )
+
+
+class AppMetricsBeforeMiddlewareWithUA(PrometheusBeforeMiddleware):
+ """
+ django_prometheus monitoring middleware using custom Metrics class
+ """
+
+ metrics_cls = CustomMetricsWithUA
+
+
+class AppMetricsAfterMiddlewareWithUA(PrometheusAfterMiddleware):
+ """
+ django_prometheus monitoring middleware using custom Metrics class that injects User-Agent label when possible
+ """
+
+ metrics_cls = CustomMetricsWithUA
+
+ def label_metric(self, metric, request, response=None, **labels):
+ new_labels = labels
+ # TODO: Re-enable a cheaper form of user-agent logging
+ # https://github.com/codecov/engineering-team/issues/1654
+ # if metric._name in USER_AGENT_METRICS:
+ # new_labels = {"user_agent": request.headers.get("User-Agent", "none")}
+ # new_labels.update(labels)
+ return super().label_metric(metric, request, response=response, **new_labels)
diff --git a/apps/codecov-api/core/models.py b/apps/codecov-api/core/models.py
new file mode 100644
index 0000000000..3d4dfa9e16
--- /dev/null
+++ b/apps/codecov-api/core/models.py
@@ -0,0 +1 @@
+from shared.django_apps.core.models import *
diff --git a/apps/codecov-api/core/permissions.py b/apps/codecov-api/core/permissions.py
new file mode 100644
index 0000000000..906d828c7e
--- /dev/null
+++ b/apps/codecov-api/core/permissions.py
@@ -0,0 +1,9 @@
+from rest_framework.permissions import SAFE_METHODS
+
+
+class IsRepoOwner(object):
+ def is_user_owner_of_repo(self, user, repository):
+ pass
+
+ def has_permission(self, request, view):
+ return request.method in SAFE_METHODS
diff --git a/apps/codecov-api/core/signals.py b/apps/codecov-api/core/signals.py
new file mode 100644
index 0000000000..92743ce072
--- /dev/null
+++ b/apps/codecov-api/core/signals.py
@@ -0,0 +1,43 @@
+import logging
+from typing import Any, Dict, List, Type, cast
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from shared.django_apps.core.models import Commit
+
+from core.models import Repository
+from utils.shelter import ShelterPubsub
+
+log = logging.getLogger(__name__)
+
+
+@receiver(post_save, sender=Repository, dispatch_uid="shelter_sync_repo")
+def update_repository(
+ sender: Type[Repository], instance: Repository, **kwargs: Dict[str, Any]
+) -> None:
+ log.info(f"Signal triggered for repository {instance.repoid}")
+ created: bool = cast(bool, kwargs["created"])
+ changes: Dict[str, Any] = instance.tracker.changed()
+ tracked_fields: List[str] = ["name", "upload_token", "author_id", "private"]
+
+ if created or any(field in changes for field in tracked_fields):
+ data = {
+ "type": "repo",
+ "sync": "one",
+ "id": instance.repoid,
+ }
+ ShelterPubsub.get_instance().publish(data)
+
+
+@receiver(post_save, sender=Commit, dispatch_uid="shelter_sync_commit")
+def update_commit(
+ sender: Type[Commit], instance: Commit, **kwargs: Dict[str, Any]
+) -> None:
+ branch: str = instance.branch
+ if branch and ":" in branch:
+ data = {
+ "type": "commit",
+ "sync": "one",
+ "id": instance.id,
+ }
+ ShelterPubsub.get_instance().publish(data)
diff --git a/apps/codecov-api/core/tests/__init__.py b/apps/codecov-api/core/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/core/tests/test_admin.py b/apps/codecov-api/core/tests/test_admin.py
new file mode 100644
index 0000000000..0dd1b36c45
--- /dev/null
+++ b/apps/codecov-api/core/tests/test_admin.py
@@ -0,0 +1,81 @@
+import uuid
+from unittest.mock import MagicMock, patch
+
+from django.contrib.admin.sites import AdminSite
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import UserFactory
+from shared.django_apps.core.tests.factories import RepositoryFactory
+
+from core.admin import RepositoryAdmin, RepositoryAdminForm
+from core.models import Repository
+from utils.test_utils import Client
+
+
+class AdminTest(TestCase):
+ def setUp(self):
+ self.user = UserFactory()
+ self.repo_admin = RepositoryAdmin(Repository, AdminSite)
+ self.client = Client()
+
+ def test_staff_can_access_admin(self):
+ self.user.is_staff = True
+ self.user.save()
+
+ self.client.force_login(self.user)
+ response = self.client.get("/admin/")
+ self.assertEqual(response.status_code, 200)
+
+ def test_non_staff_cannot_access_admin(self):
+ self.client.force_login(self.user)
+ response = self.client.get("/admin/")
+ self.assertEqual(response.status_code, 302)
+
+ @patch("core.admin.admin.ModelAdmin.log_change")
+ def test_prev_and_new_values_in_log_entry(self, mocked_super_log_change):
+ repo = RepositoryFactory(using_integration=True)
+ repo.save()
+ repo.using_integration = False
+ form = MagicMock()
+ form.changed_data = ["using_integration"]
+ self.repo_admin.save_model(
+ request=MagicMock, new_obj=repo, form=form, change=True
+ )
+ assert (
+ repo.changed_fields["using_integration"]
+ == "prev value: True, new value: False"
+ )
+
+ message = []
+ message.append({"changed": {"fields": ["using_integration"]}})
+ self.repo_admin.log_change(MagicMock, repo, message)
+ mocked_super_log_change.assert_called_once()
+ assert message == [
+ {"changed": {"fields": ["using_integration"]}},
+ {"using_integration": "prev value: True, new value: False"},
+ ]
+
+
+class RepositoryAdminTests(AdminTest):
+ def test_webhook_secret_nullable(self):
+ repo = RepositoryFactory(
+ webhook_secret=str(uuid.uuid4()),
+ )
+ self.assertIsNotNone(repo.webhook_secret)
+ data = {
+ "webhook_secret": "",
+ # all the required fields have to be filled out in the form even though they aren't changed
+ "name": repo.name,
+ "author": repo.author,
+ "service_id": repo.service_id,
+ "upload_token": repo.upload_token,
+ "image_token": repo.image_token,
+ "branch": repo.branch,
+ }
+
+ form = RepositoryAdminForm(data=data, instance=repo)
+ self.assertTrue(form.is_valid())
+ updated_instance = form.save()
+ self.assertIsNone(updated_instance.webhook_secret)
+
+ repo.refresh_from_db()
+ self.assertIsNone(repo.webhook_secret)
diff --git a/apps/codecov-api/core/tests/test_management_commands.py b/apps/codecov-api/core/tests/test_management_commands.py
new file mode 100644
index 0000000000..fa307d97c4
--- /dev/null
+++ b/apps/codecov-api/core/tests/test_management_commands.py
@@ -0,0 +1,169 @@
+import csv
+import os
+import tempfile
+import unittest.mock as mock
+from io import StringIO
+
+import pytest
+from django.core.management import call_command
+from shared.config import ConfigHelper
+from shared.django_apps.codecov_auth.models import Plan, Tier
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.helpers.redis import get_redis_connection
+
+
+@pytest.mark.django_db
+def test_update_gitlab_webhook_command(mocker):
+ edit_webhook = mocker.patch("shared.torngit.gitlab.Gitlab.edit_webhook")
+
+ get_config = mocker.patch("shared.config._get_config_instance")
+ config_helper = ConfigHelper()
+ config_helper.set_params(
+ {
+ "setup": {
+ "webhook_url": "http://example.com",
+ },
+ }
+ )
+ get_config.return_value = config_helper
+
+ author = OwnerFactory(service="gitlab")
+ repo1 = RepositoryFactory(hookid=123, author=author, webhook_secret=None)
+ repo2 = RepositoryFactory(hookid=234, author=author, webhook_secret=None)
+ repo3 = RepositoryFactory(hookid=345, author=author, webhook_secret=None)
+
+ call_command(
+ "update_gitlab_webhooks",
+ stdout=StringIO(),
+ stderr=StringIO(),
+ starting_repoid=repo2.pk,
+ )
+
+ repo1.refresh_from_db()
+ assert repo1.webhook_secret is None
+ repo2.refresh_from_db()
+ assert repo2.webhook_secret is not None
+ repo3.refresh_from_db()
+ assert repo3.webhook_secret is not None
+
+ assert edit_webhook.mock_calls == [
+ mock.call(
+ hookid="234",
+ name=None,
+ url="http://example.com/webhooks/gitlab",
+ events={
+ "push_events": True,
+ "issues_events": False,
+ "merge_requests_events": True,
+ "tag_push_events": False,
+ "note_events": False,
+ "job_events": False,
+ "build_events": True,
+ "pipeline_events": True,
+ "wiki_events": False,
+ },
+ secret=repo2.webhook_secret,
+ ),
+ mock.call(
+ hookid="345",
+ name=None,
+ url="http://example.com/webhooks/gitlab",
+ events={
+ "push_events": True,
+ "issues_events": False,
+ "merge_requests_events": True,
+ "tag_push_events": False,
+ "note_events": False,
+ "job_events": False,
+ "build_events": True,
+ "pipeline_events": True,
+ "wiki_events": False,
+ },
+ secret=repo3.webhook_secret,
+ ),
+ ]
+
+
+@pytest.mark.django_db
+def test_delete_rate_limit_keys_user_id():
+ redis = get_redis_connection()
+ redis.set("rl-user:1", 1)
+ redis.set("rl-user:2", 1, ex=5000)
+ redis.set("rl-ip:1", 1)
+
+ call_command(
+ "delete_rate_limit_keys",
+ stdout=StringIO(),
+ stderr=StringIO(),
+ )
+
+ assert redis.get("rl-user:1") is None
+ assert redis.get("rl-user:2") is not None
+ assert redis.get("rl-ip:1") is not None
+
+ # Get rid of lingering keys
+ redis.delete("rl-ip:1")
+ redis.delete("rl-user:2")
+
+
+@pytest.mark.django_db
+def test_delete_rate_limit_keys_ip_option():
+ redis = get_redis_connection()
+ redis.set("rl-ip:1", 1)
+ redis.set("rl-ip:2", 1, ex=5000)
+ redis.set("rl-user:1", 1)
+
+ call_command(
+ "delete_rate_limit_keys", stdout=StringIO(), stderr=StringIO(), ip=True
+ )
+
+ assert redis.get("rl-ip:1") is None
+ assert redis.get("rl-ip:2") is not None
+ assert redis.get("rl-user:1") is not None
+
+ # Get rid of lingering keys
+ redis.delete("rl-user:1")
+ redis.delete("rl-ip:2")
+
+
+@pytest.mark.django_db
+def test_insert_data_to_db_from_csv_for_plans_and_tiers():
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, newline="") as temp_csv:
+ writer = csv.writer(temp_csv)
+ writer.writerow(["id", "tier_name"])
+ writer.writerow([1, "Tier 1"])
+ writer.writerow([2, "Tier 2"])
+ csv_path = temp_csv.name
+
+ out = StringIO()
+ call_command("insert_data_to_db_from_csv", csv_path, "--model", "tiers", stdout=out)
+
+ # Check the output
+ assert "Successfully inserted all data into tiers from CSV" in out.getvalue()
+
+ # Verify the data was inserted correctly
+ assert Tier.objects.filter(tier_name="Tier 1").exists()
+ assert Tier.objects.filter(tier_name="Tier 2").exists()
+
+ # Create a temporary CSV file
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, newline="") as temp_csv:
+ writer = csv.writer(temp_csv)
+ writer.writerow(
+ ["name", "marketing_name", "base_unit_price", "tier_id", "is_active"]
+ )
+ writer.writerow(["Plan A", "Marketing A", 100, 1, "true"])
+ writer.writerow(["Plan B", "Marketing B", 200, 2, "false"])
+ csv_path = temp_csv.name
+
+ out = StringIO()
+ call_command("insert_data_to_db_from_csv", csv_path, "--model", "plans", stdout=out)
+
+ # Check the output
+ assert "Successfully inserted all data into plans from CSV" in out.getvalue()
+
+ # Verify the data was inserted correctly
+ assert Plan.objects.filter(name="Plan A").exists()
+ assert Plan.objects.filter(name="Plan B").exists()
+
+ # Clean up the temporary file
+ os.remove(csv_path)
diff --git a/apps/codecov-api/core/tests/test_managers.py b/apps/codecov-api/core/tests/test_managers.py
new file mode 100644
index 0000000000..da69d5ebd3
--- /dev/null
+++ b/apps/codecov-api/core/tests/test_managers.py
@@ -0,0 +1,118 @@
+from datetime import datetime
+
+from django.test import TestCase
+from django.utils import timezone
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from core.models import Repository
+
+
+class RepositoryQuerySetTests(TestCase):
+ def setUp(self):
+ self.repo1 = RepositoryFactory()
+ self.repo2 = RepositoryFactory()
+
+ def test_with_latest_commit_totals_before(self):
+ totals = {"n": 10, "h": 5, "m": 3, "p": 2, "c": 100.0, "C": 80.0}
+ CommitFactory(totals=totals, repository=self.repo1)
+
+ repo = Repository.objects.filter(
+ repoid=self.repo1.repoid
+ ).with_latest_commit_totals_before(datetime.now().isoformat(), None)[0]
+ assert repo.latest_commit_totals == totals
+
+ def test_with_latest_coverage_change(self):
+ CommitFactory(totals={"c": 99}, repository=self.repo1)
+ CommitFactory(totals={"c": 98}, repository=self.repo1)
+ assert (
+ Repository.objects.filter(repoid=self.repo1.repoid)
+ .with_latest_commit_totals_before(timezone.now().isoformat(), None, True)
+ .with_latest_coverage_change()[0]
+ .latest_coverage_change
+ == -1
+ )
+
+ def test_get_or_create_from_github_repo_data(self):
+ owner = OwnerFactory()
+
+ with self.subTest("doesnt crash when fork but no parent"):
+ repo_data = {
+ "id": 45,
+ "default_branch": "main",
+ "private": True,
+ "name": "test",
+ "fork": True,
+ }
+
+ repo, created = Repository.objects.get_or_create_from_git_repo(
+ repo_data, owner
+ )
+ assert created
+ assert repo.service_id == 45
+ assert repo.branch == "main"
+ assert repo.private
+ assert repo.name == "test"
+
+ def test_viewable_repos(self):
+ private_repo = RepositoryFactory(private=True)
+ public_repo = RepositoryFactory(private=False)
+ deleted_repo = RepositoryFactory(deleted=True)
+
+ with self.subTest("when owner permission is none doesnt crash"):
+ owner = OwnerFactory(permission=None)
+ owned_repo = RepositoryFactory(author=owner)
+
+ repos = Repository.objects.viewable_repos(owner)
+ assert repos.count() == 2
+
+ repoids = repos.values_list("repoid", flat=True)
+ assert public_repo.repoid in repoids
+ assert owned_repo.repoid in repoids
+ assert deleted_repo.repoid not in repoids
+
+ with self.subTest("when repository do not have a name doesnt return it"):
+ owner = OwnerFactory(permission=None)
+ RepositoryFactory(author=owner, name=None)
+ RepositoryFactory(author=owner, name=None)
+ RepositoryFactory(author=owner, name=None)
+
+ repos = Repository.objects.viewable_repos(owner)
+ assert repos.count() == 1
+ # only public repo created above
+ repoids = repos.values_list("repoid", flat=True)
+ assert public_repo.repoid in repoids
+ assert deleted_repo.repoid not in repoids
+
+ with self.subTest("when owner permission is not none, returns repos"):
+ owner = OwnerFactory(permission=[private_repo.repoid])
+ owned_repo = RepositoryFactory(author=owner)
+
+ repos = Repository.objects.viewable_repos(owner)
+ assert repos.count() == 3
+
+ repoids = repos.values_list("repoid", flat=True)
+ assert public_repo.repoid in repoids
+ assert owned_repo.repoid in repoids
+ assert private_repo.repoid in repoids
+ assert deleted_repo.repoid not in repoids
+
+ with self.subTest("when user not authed, returns only public"):
+ repos = Repository.objects.viewable_repos(None)
+ assert repos.count() == 1
+
+ repoids = repos.values_list("repoid", flat=True)
+ assert public_repo.repoid in repoids
+ assert deleted_repo.repoid not in repoids
+
+ with self.subTest("when repository is deleted, don't return it"):
+ owner = OwnerFactory()
+ deleted_owned_repo = RepositoryFactory(author=owner, deleted=True)
+
+ repos = Repository.objects.viewable_repos(owner)
+ assert public_repo.repoid in repoids
+ assert deleted_repo not in repoids
+ assert deleted_owned_repo not in repoids
diff --git a/apps/codecov-api/core/tests/test_middleware.py b/apps/codecov-api/core/tests/test_middleware.py
new file mode 100644
index 0000000000..c8d43d865a
--- /dev/null
+++ b/apps/codecov-api/core/tests/test_middleware.py
@@ -0,0 +1,132 @@
+from prometheus_client import REGISTRY
+
+
+# TODO: consolidate with worker/helpers/tests/unit/test_checkpoint_logger.py into shared repo
+class CounterAssertion:
+ def __init__(self, metric, labels, expected_value):
+ self.metric = metric
+ self.labels = labels
+ self.expected_value = expected_value
+
+ self.before_value = None
+ self.after_value = None
+
+ def __repr__(self):
+ return f""
+
+
+# TODO: consolidate with worker/helpers/tests/unit/test_checkpoint_logger.py into shared repo
+class CounterAssertionSet:
+ def __init__(self, counter_assertions):
+ self.counter_assertions = counter_assertions
+
+ def __enter__(self):
+ for assertion in self.counter_assertions:
+ assertion.before_value = (
+ REGISTRY.get_sample_value(assertion.metric, labels=assertion.labels)
+ or 0
+ )
+
+ def __exit__(self, exc_type, exc_value, exc_tb):
+ for assertion in self.counter_assertions:
+ assertion.after_value = (
+ REGISTRY.get_sample_value(assertion.metric, labels=assertion.labels)
+ or 0
+ )
+ assert (
+ assertion.after_value - assertion.before_value
+ == assertion.expected_value
+ )
+
+
+# TODO: Re-enable some cheaper form of user-agent logging
+# https://github.com/codecov/engineering-team/issues/1654
+"""
+class PrometheusUserAgentLabelTest(TestCase):
+ def test_user_agent_label_added(self):
+ user_agent = "iphone"
+
+ counter_assertions = [
+ CounterAssertion(
+ "django_http_requests_latency_seconds_by_view_method_count",
+ {
+ "view": "codecov.views.health",
+ "method": "GET",
+ "user_agent": user_agent,
+ },
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_requests_total_by_method_total",
+ {"user_agent": user_agent, "method": "GET"},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_requests_total_by_transport_total",
+ {"transport": "http", "user_agent": user_agent},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_requests_total_by_view_transport_method_total",
+ {
+ "view": "codecov.views.health",
+ "transport": "http",
+ "method": "GET",
+ "user_agent": user_agent,
+ },
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_requests_body_total_bytes_count",
+ {"user_agent": user_agent},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_requests_total_by_transport_total",
+ {"transport": "http", "user_agent": user_agent},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_responses_total_by_status_total",
+ {"status": "200", "user_agent": user_agent},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_responses_total_by_status_view_method_total",
+ {
+ "status": "200",
+ "view": "codecov.views.health",
+ "method": "GET",
+ "user_agent": user_agent,
+ },
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_responses_body_total_bytes_count",
+ {"user_agent": user_agent},
+ 1,
+ ),
+ CounterAssertion(
+ "django_http_responses_total_by_charset_total",
+ {"charset": "utf-8", "user_agent": user_agent},
+ 1,
+ ),
+ ]
+
+ with CounterAssertionSet(counter_assertions):
+ self.client.get(
+ "/",
+ headers={
+ "User-Agent": user_agent,
+ },
+ )
+
+ for metric in REGISTRY.collect():
+ if metric.name in USER_AGENT_METRICS:
+ for sample in metric.samples:
+ assert (
+ sample.labels["user_agent"]
+ == "none" # not all requests have User-Agent header defined
+ or sample.labels["user_agent"] == user_agent
+ )
+"""
diff --git a/apps/codecov-api/core/tests/test_signals.py b/apps/codecov-api/core/tests/test_signals.py
new file mode 100644
index 0000000000..b133bbc971
--- /dev/null
+++ b/apps/codecov-api/core/tests/test_signals.py
@@ -0,0 +1,103 @@
+from unittest.mock import call
+
+import pytest
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+
+@pytest.mark.django_db
+def test_shelter_repo_sync(mocker):
+ publish = mocker.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+
+ # this triggers the publish via Django signals
+ repo = RepositoryFactory(
+ repoid=91728376, author=OwnerFactory(ownerid=555), private=False
+ )
+
+ # triggers publish on create
+ publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 555}',
+ ),
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "repo", "sync": "one", "id": 91728376}',
+ ),
+ ]
+ )
+
+ repo.upload_token = "b69cf44c-89d8-48c2-80c9-5508610d3ced"
+ repo.save()
+
+ publish_calls = publish.call_args_list
+ assert len(publish_calls) == 3
+
+ # triggers publish on update
+ assert publish_calls[2] == call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "repo", "sync": "one", "id": 91728376}',
+ )
+
+ # Does not trigger another publish with untracked field
+ repo.message = "foo"
+ repo.save()
+
+ publish_calls = publish.call_args_list
+ assert len(publish_calls) == 3
+
+ # Triggers call when owner is changed
+ repo.author = OwnerFactory(ownerid=888)
+ repo.save()
+
+ publish_calls = publish.call_args_list
+ # 1 is for the new owner created
+ assert len(publish_calls) == 5
+ publish.assert_has_calls(
+ [
+ call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "owner", "sync": "one", "id": 888}',
+ ),
+ ]
+ )
+
+ # Triggers call when private is changed
+ repo.private = True
+ repo.save()
+
+ # publish_calls = publish.call_args_list
+ assert len(publish_calls) == 6
+
+
+@pytest.mark.django_db
+def test_shelter_commit_sync(mocker):
+ publish = mocker.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+
+ # this triggers the publish via Django signals - has to have this format
+ owner = OwnerFactory(ownerid=555)
+ commit = CommitFactory(
+ id=167829367,
+ branch="random:branch",
+ author=owner,
+ repository=RepositoryFactory(author=owner),
+ )
+
+ publish_calls = publish.call_args_list
+ # 3x cause there's a signal triggered when the commit factory requires a Repository and Owner
+ # which can't be null
+ assert len(publish_calls) == 3
+
+ # triggers publish on update
+ assert publish_calls[2] == call(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "commit", "sync": "one", "id": 167829367}',
+ )
+
+ commit.branch = "normal-incompatible-branch"
+ commit.save()
+
+ publish_calls = publish.call_args_list
+ # does not trigger another publish since unchanged length
+ assert len(publish_calls) == 3
diff --git a/apps/codecov-api/dev.sh b/apps/codecov-api/dev.sh
new file mode 100755
index 0000000000..af6a527afd
--- /dev/null
+++ b/apps/codecov-api/dev.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# starts the development server using gunicorn
+# NEVER run production with the --reload option command
+echo "API: Starting gunicorn in dev mode"
+
+_start_gunicorn() {
+ if [ -n "$PROMETHEUS_MULTIPROC_DIR" ]; then
+ rm -r ${PROMETHEUS_MULTIPROC_DIR?}/* 2> /dev/null
+ mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
+ fi
+
+ export PYTHONWARNINGS=always
+ suffix=""
+ if [[ "$STATSD_HOST" ]]; then
+ suffix="--statsd-host ${STATSD_HOST}:${STATSD_PORT}"
+ fi
+ if [ "$RUN_ENV" == "ENTERPRISE" ] || [ "$RUN_ENV" == "DEV" ]; then
+ python manage.py migrate
+ python manage.py migrate --database "timeseries" timeseries
+ python manage.py pgpartition --yes --skip-delete
+ python manage.py insert_data_to_db_from_csv core/management/commands/codecovTiers-Jan25.csv --model tiers
+ python manage.py insert_data_to_db_from_csv core/management/commands/codecovPlans-Jan25.csv --model plans
+ fi
+ if [[ "$DEBUGPY" ]]; then
+ uv add debugpy
+ uv sync
+ python -m debugpy --listen 0.0.0.0:12345 -m gunicorn codecov.wsgi:application --reload --bind 0.0.0.0:8000 --access-logfile '-' --timeout "${GUNICORN_TIMEOUT:-600}" $suffix
+ fi
+ gunicorn codecov.wsgi:application --reload --bind 0.0.0.0:8000 --access-logfile '-' --timeout "${GUNICORN_TIMEOUT:-600}" $suffix
+}
+
+if [ -z "$1" ];
+then
+ _start_gunicorn
+else
+ exec "$@"
+fi
diff --git a/apps/codecov-api/development.yml b/apps/codecov-api/development.yml
new file mode 100644
index 0000000000..3fe036145f
--- /dev/null
+++ b/apps/codecov-api/development.yml
@@ -0,0 +1,60 @@
+github:
+ bot:
+ username: codecov-io
+ integration:
+ id: 254
+ pem: src/certs/github.pem
+
+bitbucket:
+ bot:
+ username: codecov-io
+
+gitlab:
+ bot:
+ username: codecov-io
+
+site:
+ codecov:
+ require_ci_to_pass: yes
+
+ coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project: yes
+ patch: yes
+ changes: no
+
+ parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+ javascript:
+ enable_partials: no
+
+ comment:
+ layout: "reach, diff, flags, files, footer"
+ behavior: default
+ require_changes: no
+ require_base: no
+ require_head: yes
+
+
+services:
+ minio:
+ hash_key: testixik8qdauiab1yiffydimvi72ekq
+ verify_ssl: false
+ host: 'minio'
+ port: 9000
+ # bucket:
+ # region:
+ access_key_id: codecov-default-key
+ secret_access_key: codecov-default-secret
+ client_uploads: true
+ dsn: https://stage-web.codecov.dev
diff --git a/apps/codecov-api/docker-compose.yml b/apps/codecov-api/docker-compose.yml
new file mode 100644
index 0000000000..7145edf0db
--- /dev/null
+++ b/apps/codecov-api/docker-compose.yml
@@ -0,0 +1,44 @@
+version: "3"
+
+services:
+ api:
+ image: ${API_DOCKER_REPO}:${API_DOCKER_VERSION}
+ depends_on:
+ - postgres
+ - redis
+ - timescale
+ volumes:
+ - ./:/app/apps/codecov-api
+ - ./docker/test.yml:/config/codecov.yml
+ entrypoint:
+ - ./dev.sh
+ environment:
+ - RUN_ENV=DEV
+ # Improves pytest-cov performance in python 3.12
+ # https://github.com/nedbat/coveragepy/issues/1665#issuecomment-1937075835
+ - COVERAGE_CORE=sysmon
+ env_file:
+ - .testenv
+
+ postgres:
+ image: postgres:14-alpine
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_HOST_AUTH_METHOD=trust
+ - POSTGRES_PASSWORD=password
+ volumes:
+ - type: tmpfs
+ target: /var/lib/postgresql/data
+ tmpfs:
+ size: 2048M
+
+ timescale:
+ image: timescale/timescaledb-ha:pg14-latest
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_HOST_AUTH_METHOD=trust
+ - POSTGRES_PASSWORD=password
+
+ redis:
+ image: redis:6-alpine
+
diff --git a/apps/codecov-api/docker/Dockerfile b/apps/codecov-api/docker/Dockerfile
new file mode 100644
index 0000000000..2c8ad34f89
--- /dev/null
+++ b/apps/codecov-api/docker/Dockerfile
@@ -0,0 +1,64 @@
+# syntax=docker/dockerfile:1.4
+ARG REQUIREMENTS_IMAGE
+ARG BUILD_ENV=self-hosted
+ARG BERGLAS_VERSION=2.0.6
+
+FROM us-docker.pkg.dev/berglas/berglas/berglas:$BERGLAS_VERSION as berglas
+
+FROM $REQUIREMENTS_IMAGE as app
+
+COPY . /app/apps/codecov-api
+WORKDIR /app/apps/codecov-api
+RUN python manage.py collectstatic --no-input
+
+
+FROM app as local
+
+FROM app as cloud
+ARG RELEASE_VERSION
+ENV RELEASE_VERSION=$RELEASE_VERSION
+COPY --chmod=755 --from=berglas /bin/berglas /usr/local/bin/berglas
+
+FROM app as self-hosted
+
+# set settings module
+ENV DJANGO_SETTINGS_MODULE="codecov.settings_enterprise"
+# Remove the settings dev and enterprise files.
+# These should *never* make it to enterprise.
+RUN rm /app/apps/codecov-api/codecov/settings_dev.py && \
+ rm /app/apps/codecov-api/codecov/settings_prod.py && \
+ rm /app/apps/codecov-api/codecov/settings_test.py && \
+ rm /app/apps/codecov-api/codecov/settings_staging.py && \
+ rm /app/apps/codecov-api/dev.sh && \
+ rm /app/apps/codecov-api/migrate.sh && \
+ rm /app/apps/codecov-api/prod.sh && \
+ rm /app/apps/codecov-api/staging.sh && \
+ rm /app/apps/codecov-api/production.yml && \
+ rm /app/apps/codecov-api/development.yml
+# Remove unneeded folders
+RUN rm -rf /app/apps/codecov-api/.github
+RUN rm -rf /app/apps/codecov-api/.circleci
+# Create the codecov user to run the container as
+RUN addgroup --system application \
+ && adduser --system codecov --ingroup application --home /home/codecov
+RUN mkdir -p /config && chown codecov:application /config
+# copy the enterprise settings module.
+WORKDIR /app/apps/codecov-api
+RUN chmod +x enterprise.sh && \
+ chown codecov:application /app/apps/codecov-api
+ARG RELEASE_VERSION
+ENV RUN_ENV="ENTERPRISE"
+ENV RELEASE_VERSION=$RELEASE_VERSION
+ENV DJANGO_SETTINGS_MODULE="codecov.settings_enterprise"
+ENV CODECOV_API_PORT=8000
+ENTRYPOINT ["./enterprise.sh"]
+
+FROM self-hosted as self-hosted-runtime
+USER root
+ARG EXTERNAL_DEPS_FOLDER=./external_deps
+RUN mkdir $EXTERNAL_DEPS_FOLDER
+RUN pip install --target $EXTERNAL_DEPS_FOLDER psycopg2-binary tlslite-ng
+RUN chown codecov:application $EXTERNAL_DEPS_FOLDER
+USER codecov
+
+FROM ${BUILD_ENV}
diff --git a/apps/codecov-api/docker/Dockerfile-proxy b/apps/codecov-api/docker/Dockerfile-proxy
new file mode 100644
index 0000000000..ba84fca69b
--- /dev/null
+++ b/apps/codecov-api/docker/Dockerfile-proxy
@@ -0,0 +1,29 @@
+# syntax=docker/dockerfile:1.3
+FROM python:3.13-slim-bookworm
+
+ENV FRP_VERSION=v0.51.3
+
+# RUN apk add --no-cache curl
+RUN apt-get update
+RUN apt-get install -y curl
+
+SHELL ["/bin/bash", "-c"]
+
+RUN addgroup --system frp \
+&& adduser --debug --system --home /var/frp --shell /sbin/nologin --ingroup frp frp \
+&& curl -fSL https://github.com/fatedier/frp/releases/download/${FRP_VERSION}/frp_${FRP_VERSION:1}_linux_amd64.tar.gz -o frp.tar.gz \
+&& tar -zxv -f frp.tar.gz \
+&& rm -rf frp.tar.gz \
+&& mv frp_*_linux_amd64 /frp \
+&& chown -R frp:frp /frp
+
+COPY --chown=frp:frp docker/frpc-entrypoint.sh /frp/entrypoint.sh
+RUN chmod 755 /frp/entrypoint.sh
+USER frp
+
+WORKDIR /frp
+ADD docker/frpc.ini /frp/frpc.ini
+
+EXPOSE 6000 7000
+
+CMD ["/frp/entrypoint.sh"]
diff --git a/apps/codecov-api/docker/Dockerfile.requirements b/apps/codecov-api/docker/Dockerfile.requirements
new file mode 100644
index 0000000000..2472c848a7
--- /dev/null
+++ b/apps/codecov-api/docker/Dockerfile.requirements
@@ -0,0 +1,56 @@
+# syntax=docker/dockerfile:1.4
+ARG PYTHON_IMAGE=ghcr.io/astral-sh/uv:python3.13-bookworm-slim
+
+# BUILD STAGE - Download dependencies from GitHub that require SSH access
+FROM $PYTHON_IMAGE as build
+
+RUN apt-get update
+RUN apt-get install -y \
+ curl \
+ git \
+ build-essential \
+ libffi-dev \
+ libpq-dev
+
+ENV UV_LINK_MODE=copy \
+ UV_COMPILE_BYTECODE=1 \
+ UV_PYTHON_DOWNLOADS=never \
+ UV_PYTHON=python \
+ UV_PROJECT_ENVIRONMENT=/api
+
+# Then, add the rest of the project source code and install it
+# Installing separately from its dependencies allows optimal layer caching
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv export --no-hashes --frozen --format requirements-txt > requirements.txt
+
+
+RUN grep -v '^-e ' requirements.txt > requirements.remote.txt
+# build all remote wheels
+RUN pip wheel -w wheels --find-links wheels -r requirements.remote.txt
+
+# build all local packages to wheels
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv build --all-packages --wheel -o wheels
+
+# RUNTIME STAGE - Copy packages from build stage and install runtime dependencies
+FROM $PYTHON_IMAGE
+
+# Our postgres driver psycopg2 requires libpq-dev as a runtime dependency
+RUN apt-get update
+RUN apt-get install -y \
+ libpq-dev \
+ make \
+ curl \
+ libexpat1
+
+COPY --from=build /wheels/ /wheels/
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv pip install --no-deps --no-index --find-links=wheels wheels/* --system
+
diff --git a/apps/codecov-api/docker/codecov.yml b/apps/codecov-api/docker/codecov.yml
new file mode 100644
index 0000000000..b4f96582ae
--- /dev/null
+++ b/apps/codecov-api/docker/codecov.yml
@@ -0,0 +1,11 @@
+setup:
+ loglvl: DEBUG
+ codecov_url: "http://localhost:9000"
+ codecov_api_url: "http://localhost:9000"
+ enterprise_license: ""
+ http:
+ cookie_secret: "secret"
+services:
+ database_url: "postgres://postgres:postgres@postgres/postgres"
+ minio:
+ hash_key: "testixik8qdauiab1yiffydimvi72ekq" #do not edit
diff --git a/apps/codecov-api/docker/frpc-entrypoint.sh b/apps/codecov-api/docker/frpc-entrypoint.sh
new file mode 100644
index 0000000000..a6afb3c6d5
--- /dev/null
+++ b/apps/codecov-api/docker/frpc-entrypoint.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+if [ $SERVER_ADDR ]; then
+ sed -i "s|server_addr = 127.0.0.1|server_addr = $SERVER_ADDR|g" /frp/frpc.ini
+fi
+if [ $FRP_USER ]; then
+ sed -i "1 a user = $FRP_USER" /frp/frpc.ini
+fi
+if [ $PROXY_NAME ]; then
+ sed -i "s|ssh|$PROXY_NAME|g" /frp/frpc.ini
+fi
+if [ $SERVER_PORT ]; then
+ sed -i "s|server_port = 7000|server_port = $SERVER_PORT|g" /frp/frpc.ini
+fi
+if [ $PROTO ]; then
+ sed -i "s|type = tcp|type = $PROTO|g" /frp/frpc.ini
+fi
+if [ $LOCAL_IP ]; then
+ sed -i "s|local_ip = 127.0.0.1|local_ip = $LOCAL_IP|g" /frp/frpc.ini
+fi
+if [ $LOCAL_PORT ]; then
+ sed -i "s|local_port = 22|local_port = $LOCAL_PORT|g" /frp/frpc.ini
+fi
+if [ $REMOTE_PORT ]; then
+ sed -i "s|remote_port = 6000|remote_port = $REMOTE_PORT|g" /frp/frpc.ini
+fi
+if [ $DOMAIN ]; then
+ sed -i "s|subdomain = api|subdomain = $DOMAIN|g" /frp/frpc.ini
+ sed -i "s|\[api\]|\[$DOMAIN\]|g" /frp/frpc.ini
+fi
+/frp/frpc -c /frp/frpc.ini
\ No newline at end of file
diff --git a/apps/codecov-api/docker/frpc.ini b/apps/codecov-api/docker/frpc.ini
new file mode 100644
index 0000000000..96dd8ee485
--- /dev/null
+++ b/apps/codecov-api/docker/frpc.ini
@@ -0,0 +1,10 @@
+[common]
+server_addr = lt.codecov.dev
+server_port = 7000
+authentication_method = token
+token = {{ .Envs.FRP_TOKEN }}
+[api]
+type = http
+local_port = 8000
+local_ip = api
+subdomain = api
\ No newline at end of file
diff --git a/apps/codecov-api/docker/test.yml b/apps/codecov-api/docker/test.yml
new file mode 100644
index 0000000000..2e0c4481aa
--- /dev/null
+++ b/apps/codecov-api/docker/test.yml
@@ -0,0 +1,72 @@
+setup:
+ codecov_url: https://codecov.io
+ debug: no
+ loglvl: INFO
+ encryption_secret: "zp^P9*i8aR3"
+ timeseries:
+ enabled: true
+ okta:
+ iss: "https://example.okta.com"
+ http:
+ force_https: yes
+ cookie_secret: abc123
+ timeouts:
+ connect: 10
+ receive: 15
+
+services:
+ database_url: postgres://postgres:password@postgres:5432/postgres
+ timeseries_database_url: postgres://postgres:password@timescale:5432/postgres
+ redis_url: redis://redis:6379
+ minio:
+ hash_key: testixik8qdauiab1yiffydimvi72ekq # never change this
+ access_key_id: codecov-default-key
+ secret_access_key: codecov-default-secret
+ verify_ssl: false
+
+github:
+ bot:
+ username: codecov-io
+ integration:
+ id: 254
+ pem: src/certs/github.pem
+
+bitbucket:
+ bot:
+ username: codecov-io
+
+gitlab:
+ bot:
+ username: codecov-io
+
+site:
+ codecov:
+ require_ci_to_pass: yes
+
+ coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project: yes
+ patch: yes
+ changes: no
+
+ parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+ javascript:
+ enable_partials: no
+
+ comment:
+ layout: "reach, diff, flags, files, footer"
+ behavior: default
+ require_changes: no
+ require_base: no
+ require_head: yes
diff --git a/apps/codecov-api/enterprise.sh b/apps/codecov-api/enterprise.sh
new file mode 100755
index 0000000000..c3e78fa813
--- /dev/null
+++ b/apps/codecov-api/enterprise.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+
+# Starts the enterprise gunicorn server (no --reload)
+echo "Starting api"
+
+GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
+if [ "$GUNICORN_WORKERS" -gt 1 ];
+then
+ export PROMETHEUS_MULTIPROC_DIR="${PROMETHEUS_MULTIPROC_DIR:-$HOME/.prometheus}"
+ rm -r ${PROMETHEUS_MULTIPROC_DIR?}/* 2> /dev/null
+ mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
+fi
+
+if [[ "$CODECOV_WRAPPER" ]]; then
+SUB="$CODECOV_WRAPPER"
+else
+SUB=""
+fi
+if [[ "$CODECOV_WRAPPER_POST" ]]; then
+POST="$CODECOV_WRAPPER_POST"
+else
+POST=""
+fi
+statsd=""
+if [[ "$STATSD_HOST" ]]; then
+ statsd="--statsd-host ${STATSD_HOST}:${STATSD_PORT} "
+fi
+if [[ "$1" = "api" || -z "$1" ]];
+then
+ # Migrate
+ python manage.py migrate
+ python manage.py migrate --database "timeseries" timeseries
+ # Start api
+ ${SUB}$prefix gunicorn codecov.wsgi:application --workers=$GUNICORN_WORKERS --threads=${GUNICORN_THREADS:-1} --worker-connections=${GUNICORN_WORKER_CONNECTIONS:-1000} --bind ${CODECOV_API_BIND:-0.0.0.0}:${CODECOV_API_PORT:-8000} --access-logfile '-' ${statsd}--timeout "${GUNICORN_TIMEOUT:-600}"${POST}
+elif [[ "$1" = "rti" ]];
+then
+ # Start api
+ ${SUB}$prefix gunicorn codecov.wsgi:application --workers=$GUNICORN_WORKERS --bind ${CODECOV_API_BIND:-0.0.0.0}:${CODECOV_API_PORT:-8000} --access-logfile '-' ${statsd}--timeout "${GUNICORN_TIMEOUT:-600}"${POST}
+elif [[ "$1" = "migrate" ]];
+then
+ python manage.py migrate
+ python manage.py migrate --database "timeseries" timeseries
+else
+ exec "$@"
+fi
diff --git a/apps/codecov-api/graphql_api/__init__.py b/apps/codecov-api/graphql_api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphql_api/actions/commits.py b/apps/codecov-api/graphql_api/actions/commits.py
new file mode 100644
index 0000000000..533e905dbf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/commits.py
@@ -0,0 +1,137 @@
+from collections import defaultdict
+from typing import Optional
+
+from django.db.models import Count, Prefetch, Q, QuerySet
+from django.db.models.functions import Lower, Substr
+from graphql import GraphQLResolveInfo
+
+from core.models import Commit, Pull, Repository
+from graphql_api.types.enums import CommitStatus
+from reports.models import CommitReport
+
+
+def pull_commits(pull: Pull) -> QuerySet[Commit]:
+ subquery = (
+ Commit.objects.filter(
+ pullid=pull.pullid,
+ repository_id=pull.repository_id,
+ )
+ # Can't use `exclude(deleted=True)` here since that results in a
+ # `not (deleted and deleted is not null)` condition.
+ # We need `deleted is not true` in order for the query to use the right index.
+ .filter(deleted__isnot=True)
+ )
+
+ return Commit.objects.filter(id__in=subquery).defer("_report")
+
+
+def load_commit_statuses(
+ commit_ids: list[int],
+) -> dict[int, dict[CommitReport.ReportType, str]]:
+ qs = (
+ CommitReport.objects.filter(commit__in=commit_ids)
+ .values_list("commit_id", "report_type", "sessions__state")
+ .annotate(sessions_count=Count("sessions"))
+ )
+
+ grouped: dict[tuple[int, CommitReport.ReportType], dict[str, int]] = defaultdict(
+ dict
+ )
+ for id, report_type, state, count in qs:
+ # The above query generates a `LEFT OUTER JOIN` with a proper `GROUP BY`.
+ # However, it is also yielding rows with a `NULL` state in case a report does not have any uploads.
+ if not report_type or not state:
+ continue
+ grouped[(id, report_type)][state] = count
+
+ results: dict[int, dict[CommitReport.ReportType, str]] = {
+ id: {} for id in commit_ids
+ }
+ for (id, report_type), states in grouped.items():
+ status = CommitStatus.COMPLETED.value
+ if states.get("error", 0) > 0:
+ status = CommitStatus.ERROR.value
+ elif states.get("uploaded", 0) > 0:
+ status = CommitStatus.PENDING.value
+
+ results[id][report_type] = status
+
+ return results
+
+
+def commit_status(
+ info: GraphQLResolveInfo, commit: Commit, report_type: CommitReport.ReportType
+) -> str | None:
+ commit_statuses = info.context.setdefault("commit_statuses", {})
+ commit_status = commit_statuses.get(commit.id)
+ if commit_status is None:
+ updated_statuses = load_commit_statuses([commit.id])
+ commit_statuses.update(updated_statuses)
+ commit_status = updated_statuses[commit.id]
+
+ return commit_status.get(report_type)
+
+
+def repo_commits(
+ repository: Repository, filters: Optional[dict] = None
+) -> QuerySet[Commit]:
+ # prefetch the CommitReport with the ReportLevelTotals
+ prefetch = Prefetch(
+ "reports",
+ queryset=CommitReport.objects.coverage_reports()
+ .filter(code=None)
+ .select_related("reportleveltotals"),
+ )
+
+ # We don't select the `report` column here b/c it can be many MBs of JSON
+ # and can cause performance issues
+ queryset = repository.commits.defer("_report").prefetch_related(prefetch).all()
+
+ # queryset filtering
+ filters = filters or {}
+
+ hide_failed_ci = filters.get("hide_failed_ci")
+ if hide_failed_ci is True:
+ queryset = queryset.filter(ci_passed=True)
+
+ branch_name = filters.get("branch_name")
+ if branch_name:
+ queryset = queryset.filter(branch=branch_name)
+
+ pull_id = filters.get("pull_id")
+ if pull_id:
+ queryset = queryset.filter(pullid=pull_id)
+
+ search = filters.get("search")
+ if search:
+ # search against long sha, short sha and commit message substring
+ queryset = queryset.annotate(short_sha=Substr(Lower("commitid"), 1, 7)).filter(
+ Q(commitid=search.lower())
+ | Q(short_sha=search.lower())
+ | Q(message__icontains=search)
+ )
+
+ states = filters.get("states")
+ if states:
+ queryset = queryset.filter(state__in=states)
+
+ coverage_status = filters.get("coverage_status")
+
+ if coverage_status:
+ # FIXME(swatinem):
+ # This filter here is insane, it resolves *all* the results in the unbounded queryset,
+ # just to check the status, and to then add it as another restricting filter.
+ # I’m pretty sure this will completely break the server if anyone actually uses this filter, lol.
+ commit_ids = [commit.id for commit in queryset]
+ commit_statuses = load_commit_statuses(commit_ids)
+
+ to_be_included = [
+ id
+ for id, statuses in commit_statuses.items()
+ if statuses.get(CommitReport.ReportType.COVERAGE) in coverage_status
+ ]
+ queryset = queryset.filter(id__in=to_be_included)
+
+ # We need `deleted is not true` in order for the query to use the right index.
+ queryset = queryset.filter(deleted__isnot=True)
+ return queryset
diff --git a/apps/codecov-api/graphql_api/actions/comparison.py b/apps/codecov-api/graphql_api/actions/comparison.py
new file mode 100644
index 0000000000..4ad267a7b4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/comparison.py
@@ -0,0 +1,30 @@
+from typing import Optional, Union
+
+from compare.models import CommitComparison
+from graphql_api.types.comparison.comparison import (
+ MissingBaseReport,
+ MissingComparison,
+ MissingHeadReport,
+)
+
+
+def validate_commit_comparison(
+ commit_comparison: Optional[CommitComparison],
+) -> Union[MissingBaseReport, MissingHeadReport, MissingComparison]:
+ if not commit_comparison:
+ return MissingComparison()
+
+ if (
+ commit_comparison.error
+ == CommitComparison.CommitComparisonErrors.MISSING_BASE_REPORT.value
+ ):
+ return MissingBaseReport()
+
+ if (
+ commit_comparison.error
+ == CommitComparison.CommitComparisonErrors.MISSING_HEAD_REPORT.value
+ ):
+ return MissingHeadReport()
+
+ if commit_comparison.state == CommitComparison.CommitComparisonStates.ERROR:
+ return MissingComparison()
diff --git a/apps/codecov-api/graphql_api/actions/components.py b/apps/codecov-api/graphql_api/actions/components.py
new file mode 100644
index 0000000000..096689dba9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/components.py
@@ -0,0 +1,45 @@
+from datetime import datetime
+from typing import Iterable, Mapping, Optional
+
+from django.db.models import QuerySet
+
+from core.models import Repository
+from graphql_api.actions.measurements import (
+ measurements_by_ids,
+ measurements_last_uploaded_by_ids,
+)
+from timeseries.models import Interval, MeasurementName
+
+
+def component_measurements(
+ repository: Repository,
+ component_ids: Iterable[str],
+ interval: Interval,
+ after: datetime,
+ before: datetime,
+ branch: Optional[str] = None,
+) -> Mapping[int, Iterable[dict]]:
+ return measurements_by_ids(
+ repository=repository,
+ measurable_name=MeasurementName.COMPONENT_COVERAGE.value,
+ measurable_ids=component_ids,
+ interval=interval,
+ after=after,
+ before=before,
+ branch=branch,
+ )
+
+
+def component_measurements_last_uploaded(
+ owner_id: int,
+ repo_id: int,
+ measurable_ids: str,
+ branch: Optional[str] = None,
+) -> QuerySet:
+ return measurements_last_uploaded_by_ids(
+ owner_id=owner_id,
+ repo_id=repo_id,
+ measurable_name=MeasurementName.COMPONENT_COVERAGE.value,
+ measurable_ids=measurable_ids,
+ branch=branch,
+ )
diff --git a/apps/codecov-api/graphql_api/actions/flags.py b/apps/codecov-api/graphql_api/actions/flags.py
new file mode 100644
index 0000000000..de7f18981e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/flags.py
@@ -0,0 +1,65 @@
+from datetime import datetime
+from typing import Iterable, Mapping
+
+from django.db.models import QuerySet
+
+from compare.models import CommitComparison, FlagComparison
+from core.models import Repository
+from graphql_api.actions.measurements import measurements_by_ids
+from reports.models import RepositoryFlag
+from timeseries.models import Interval, MeasurementName
+
+
+def flags_for_repo(repository: Repository, filters: Mapping = {}) -> QuerySet:
+ queryset = RepositoryFlag.objects.filter(
+ repository=repository,
+ deleted__isnot=True,
+ )
+ queryset = _apply_filters(queryset, filters or {})
+ return queryset
+
+
+def _apply_filters(queryset: QuerySet, filters: Mapping) -> QuerySet:
+ term = filters.get("term")
+ flags_names = filters.get("flags_names")
+ if flags_names:
+ queryset = queryset.filter(flag_name__in=flags_names)
+ if term:
+ queryset = queryset.filter(flag_name__contains=term)
+
+ return queryset
+
+
+def get_flag_comparisons(
+ commit_comparison: CommitComparison,
+) -> Iterable[FlagComparison]:
+ queryset = (
+ FlagComparison.objects.select_related("repositoryflag")
+ .filter(commit_comparison=commit_comparison.id)
+ .all()
+ )
+ return queryset
+
+
+def flag_measurements(
+ repository: Repository,
+ flag_ids: Iterable[int],
+ interval: Interval,
+ after: datetime,
+ before: datetime,
+) -> Mapping[int, Iterable[dict]]:
+ measurements = measurements_by_ids(
+ repository=repository,
+ measurable_name=MeasurementName.FLAG_COVERAGE.value,
+ measurable_ids=[str(flag_id) for flag_id in flag_ids],
+ interval=interval,
+ after=after,
+ before=before,
+ )
+
+ # By default the measurable_id is str type,
+ # however for flags we need to convert it to an int
+ return {
+ int(measurable_id): measurement
+ for (measurable_id, measurement) in measurements.items()
+ }
diff --git a/apps/codecov-api/graphql_api/actions/measurements.py b/apps/codecov-api/graphql_api/actions/measurements.py
new file mode 100644
index 0000000000..f53abe9d14
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/measurements.py
@@ -0,0 +1,92 @@
+from datetime import datetime
+from typing import Any, Dict, Iterable, List, Optional
+
+from django.db.models import Max, QuerySet
+
+from core.models import Repository
+from timeseries.helpers import aggregate_measurements, aligned_start_date
+from timeseries.models import Interval, Measurement, MeasurementSummary
+
+
+def measurements_by_ids(
+ repository: Repository,
+ measurable_name: str,
+ measurable_ids: Iterable[str],
+ interval: Interval,
+ before: datetime,
+ after: Optional[datetime] = None,
+ branch: Optional[str] = None,
+) -> Dict[Any, List[Dict[str, Any]]]:
+ queryset = MeasurementSummary.agg_by(interval).filter(
+ name=measurable_name,
+ owner_id=repository.author_id,
+ repo_id=repository.pk,
+ measurable_id__in=measurable_ids,
+ timestamp_bin__lte=before,
+ )
+
+ if after is not None:
+ queryset = queryset.filter(
+ timestamp_bin__gte=aligned_start_date(interval, after)
+ )
+
+ if branch:
+ queryset = queryset.filter(branch=branch)
+
+ queryset = aggregate_measurements(
+ queryset, ["timestamp_bin", "owner_id", "repo_id", "measurable_id"]
+ )
+
+ # group by measurable_id
+ measurements: Dict[Any, List[Dict[str, Any]]] = {}
+ for measurement in queryset:
+ measurable_id = measurement["measurable_id"]
+ if measurable_id not in measurements:
+ measurements[measurable_id] = []
+ measurements[measurable_id].append(measurement)
+
+ return measurements
+
+
+def measurements_last_uploaded_before_start_date(
+ owner_id: int,
+ repo_id: int,
+ measurable_name: str,
+ measurable_id: int,
+ start_date: datetime,
+ branch: Optional[str] = None,
+) -> QuerySet:
+ queryset = Measurement.objects.filter(
+ owner_id=owner_id,
+ repo_id=repo_id,
+ measurable_id=measurable_id,
+ name=measurable_name,
+ timestamp__lt=start_date,
+ )
+
+ if branch:
+ queryset = queryset.filter(branch=branch)
+
+ return queryset.values("measurable_id", "value").annotate(
+ last_uploaded=Max("timestamp")
+ )
+
+
+def measurements_last_uploaded_by_ids(
+ owner_id: int,
+ repo_id: int,
+ measurable_name: str,
+ measurable_ids: str,
+ branch: Optional[str] = None,
+) -> QuerySet:
+ queryset = Measurement.objects.filter(
+ owner_id=owner_id,
+ repo_id=repo_id,
+ measurable_id__in=measurable_ids,
+ name=measurable_name,
+ )
+
+ if branch:
+ queryset = queryset.filter(branch=branch)
+
+ return queryset.values("measurable_id").annotate(last_uploaded=Max("timestamp"))
diff --git a/apps/codecov-api/graphql_api/actions/owner.py b/apps/codecov-api/graphql_api/actions/owner.py
new file mode 100644
index 0000000000..8f5b7580cd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/owner.py
@@ -0,0 +1,35 @@
+from asgiref.sync import sync_to_async
+
+from codecov.commands.exceptions import MissingService
+from codecov_auth.models import Owner
+from utils.services import get_long_service_name
+
+
+def search_my_owners(current_user, filters):
+ filters = filters if filters else {}
+ term = filters.get("term")
+ queryset = current_user.orgs.exclude(username=None)
+ if term:
+ queryset = queryset.filter(username__contains=term)
+ return queryset
+
+
+@sync_to_async
+def get_owner(service, username):
+ if not service:
+ raise MissingService()
+
+ long_service = get_long_service_name(service)
+ return (
+ Owner.objects.filter(username=username, service=long_service)
+ .prefetch_related("account")
+ .first()
+ )
+
+
+def get_owner_login_sessions(current_user):
+ return current_user.session_set.filter(type="login").all()
+
+
+def get_user_tokens(owner: Owner):
+ return owner.user_tokens.all()
diff --git a/apps/codecov-api/graphql_api/actions/path_contents.py b/apps/codecov-api/graphql_api/actions/path_contents.py
new file mode 100644
index 0000000000..cda82d435f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/path_contents.py
@@ -0,0 +1,26 @@
+import sentry_sdk
+
+from graphql_api.types.enums import PathContentDisplayType
+from services.path import Dir, File
+
+
+@sentry_sdk.trace
+def sort_path_contents(items: list[File | Dir], filters: dict = {}) -> list[File | Dir]:
+ filter_parameter = filters.get("ordering", {}).get("parameter")
+ filter_direction = filters.get("ordering", {}).get("direction")
+
+ if filter_parameter and filter_direction:
+ parameter_value = filter_parameter.value
+ direction_value = filter_direction.value
+ items.sort(
+ key=lambda item: getattr(item, parameter_value),
+ reverse=direction_value == "descending",
+ )
+ display_type = filters.get("display_type", {})
+ if (
+ parameter_value == "name"
+ and display_type is not PathContentDisplayType.LIST
+ ):
+ items.sort(key=lambda item: isinstance(item, File))
+
+ return items
diff --git a/apps/codecov-api/graphql_api/actions/repository.py b/apps/codecov-api/graphql_api/actions/repository.py
new file mode 100644
index 0000000000..6bd03325e0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/actions/repository.py
@@ -0,0 +1,98 @@
+import logging
+from typing import Any
+
+from django.db.models import QuerySet
+from shared.django_apps.codecov_auth.models import GithubAppInstallation, Owner
+from shared.django_apps.core.models import Repository
+
+from utils.config import get_config
+
+log = logging.getLogger(__name__)
+AI_FEATURES_GH_APP_ID = get_config("github", "ai_features_app_id")
+
+
+def apply_filters_to_queryset(
+ queryset: QuerySet, filters: dict[str, Any] | None, owner: Owner | None = None
+) -> QuerySet:
+ filters = filters or {}
+ term = filters.get("term")
+ active = filters.get("active")
+ activated = filters.get("activated")
+ repo_names = filters.get("repo_names")
+ is_public = filters.get("is_public")
+ ai_enabled = filters.get("ai_enabled")
+
+ if repo_names:
+ queryset = queryset.filter(name__in=repo_names)
+ if term:
+ queryset = queryset.filter(name__contains=term)
+ if activated is not None:
+ queryset = queryset.filter(activated=activated)
+ if active is not None:
+ queryset = queryset.filter(active=active)
+ if is_public is not None:
+ queryset = queryset.filter(private=not is_public)
+ if ai_enabled is not None:
+ queryset = filter_queryset_by_ai_enabled_repos(queryset, owner)
+ return queryset
+
+
+def filter_queryset_by_ai_enabled_repos(queryset: QuerySet, owner: Owner) -> QuerySet:
+ ai_features_app_install = GithubAppInstallation.objects.filter(
+ app_id=AI_FEATURES_GH_APP_ID, owner=owner
+ ).first()
+
+ if not ai_features_app_install:
+ return Repository.objects.none()
+
+ if ai_features_app_install.repository_service_ids:
+ queryset = queryset.filter(
+ service_id__in=ai_features_app_install.repository_service_ids
+ )
+
+ return queryset
+
+
+def list_repository_for_owner(
+ current_owner: Owner,
+ owner: Owner,
+ filters: dict[str, Any] | None,
+ okta_account_auths: list[int],
+ exclude_okta_enforced_repos: bool = True,
+) -> QuerySet:
+ queryset = Repository.objects.viewable_repos(current_owner)
+ filters = filters or {}
+ ai_enabled_filter = filters.get("ai_enabled")
+
+ if ai_enabled_filter:
+ return filter_queryset_by_ai_enabled_repos(queryset, owner)
+
+ if exclude_okta_enforced_repos:
+ queryset = queryset.exclude_accounts_enforced_okta(okta_account_auths)
+
+ queryset = (
+ queryset.with_recent_coverage().with_latest_commit_at().filter(author=owner)
+ )
+ queryset = apply_filters_to_queryset(queryset, filters, owner)
+ return queryset
+
+
+def search_repos(
+ current_owner: Owner,
+ filters: dict[str, Any] | None,
+ okta_account_auths: list[int],
+ exclude_okta_enforced_repos: bool = True,
+) -> QuerySet:
+ authors_from = [current_owner.ownerid] + (current_owner.organizations or [])
+ queryset = Repository.objects.viewable_repos(current_owner)
+
+ if exclude_okta_enforced_repos:
+ queryset = queryset.exclude_accounts_enforced_okta(okta_account_auths)
+
+ queryset = (
+ queryset.with_recent_coverage()
+ .with_latest_commit_at()
+ .filter(author__ownerid__in=authors_from)
+ )
+ queryset = apply_filters_to_queryset(queryset, filters)
+ return queryset
diff --git a/apps/codecov-api/graphql_api/apps.py b/apps/codecov-api/graphql_api/apps.py
new file mode 100644
index 0000000000..de8bb1c341
--- /dev/null
+++ b/apps/codecov-api/graphql_api/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class GraphqlApiConfig(AppConfig):
+ name = "graphql_api"
diff --git a/apps/codecov-api/graphql_api/dataloader/bundle_analysis.py b/apps/codecov-api/graphql_api/dataloader/bundle_analysis.py
new file mode 100644
index 0000000000..fed5af95f8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/bundle_analysis.py
@@ -0,0 +1,67 @@
+from typing import Union
+
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import (
+ BundleAnalysisReportLoader,
+ MissingBaseReportError,
+ MissingHeadReportError,
+)
+from shared.storage import get_appropriate_storage_service
+
+from core.models import Commit
+from graphql_api.types.comparison.comparison import MissingBaseReport, MissingHeadReport
+from reports.models import CommitReport
+from services.bundle_analysis import BundleAnalysisComparison, BundleAnalysisReport
+
+
+def load_bundle_analysis_comparison(
+ base_commit: Commit, head_commit: Commit
+) -> Union[BundleAnalysisComparison, MissingHeadReport, MissingBaseReport]:
+ head_report = CommitReport.objects.filter(
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS, commit=head_commit
+ ).first()
+ if head_report is None:
+ return MissingHeadReport()
+
+ base_report = CommitReport.objects.filter(
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS, commit=base_commit
+ ).first()
+ if base_report is None:
+ return MissingBaseReport()
+
+ loader = BundleAnalysisReportLoader(
+ storage_service=get_appropriate_storage_service(head_commit.repository.repoid),
+ repo_key=ArchiveService.get_archive_hash(head_commit.repository),
+ )
+
+ try:
+ return BundleAnalysisComparison(
+ loader=loader,
+ base_report_key=base_report.external_id,
+ head_report_key=head_report.external_id,
+ repository=head_commit.repository,
+ )
+ except MissingBaseReportError:
+ return MissingBaseReport()
+ except MissingHeadReportError:
+ return MissingHeadReport()
+
+
+def load_bundle_analysis_report(
+ commit: Commit,
+) -> Union[BundleAnalysisReport, MissingHeadReport, MissingBaseReport]:
+ report = CommitReport.objects.filter(
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS, commit=commit
+ ).first()
+ if report is None:
+ return MissingHeadReport()
+
+ loader = BundleAnalysisReportLoader(
+ storage_service=get_appropriate_storage_service(commit.repository.repoid),
+ repo_key=ArchiveService.get_archive_hash(commit.repository),
+ )
+ report = loader.load(report.external_id)
+ if report is None:
+ return MissingHeadReport()
+
+ return BundleAnalysisReport(report)
diff --git a/apps/codecov-api/graphql_api/dataloader/commit.py b/apps/codecov-api/graphql_api/dataloader/commit.py
new file mode 100644
index 0000000000..871031f356
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/commit.py
@@ -0,0 +1,34 @@
+from django.db.models import Prefetch
+
+from core.models import Commit
+from reports.models import CommitReport
+
+from .loader import BaseLoader
+
+
+class CommitLoader(BaseLoader):
+ @classmethod
+ def key(cls, commit):
+ return commit.commitid
+
+ def __init__(self, info, repository_id, *args, **kwargs):
+ self.repository_id = repository_id
+ super().__init__(info, *args, **kwargs)
+
+ def batch_queryset(self, keys):
+ # We don't select the `report` column here b/c then can be
+ # very large JSON blobs and cause performance issues
+
+ # prefetch the CommitReport with the ReportLevelTotals
+ prefetch = Prefetch(
+ "reports",
+ queryset=CommitReport.objects.coverage_reports()
+ .filter(code=None)
+ .select_related("reportleveltotals"),
+ )
+
+ return (
+ Commit.objects.filter(commitid__in=keys, repository_id=self.repository_id)
+ .defer("_report")
+ .prefetch_related(prefetch)
+ )
diff --git a/apps/codecov-api/graphql_api/dataloader/comparison.py b/apps/codecov-api/graphql_api/dataloader/comparison.py
new file mode 100644
index 0000000000..eadfa66f16
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/comparison.py
@@ -0,0 +1,137 @@
+import logging
+
+from asgiref.sync import sync_to_async
+
+from compare.models import CommitComparison
+from core.models import Commit
+from services.comparison import CommitComparisonService
+from services.task import TaskService
+
+from .commit import CommitLoader
+from .loader import BaseLoader
+
+log = logging.getLogger(__name__)
+
+comparison_table = CommitComparison._meta.db_table
+commit_table = Commit._meta.db_table
+
+
+class CommitCache:
+ def __init__(self, commits):
+ self.commits = [commit for commit in commits if commit]
+ self._by_pk = {commit.pk: commit for commit in self.commits}
+ self._by_commitid = {commit.commitid: commit for commit in self.commits}
+
+ def get_by_pk(self, pk):
+ return self._by_pk.get(pk)
+
+ def get_by_commitid(self, commitid):
+ return self._by_commitid.get(commitid)
+
+
+class ComparisonLoader(BaseLoader):
+ @classmethod
+ def key(cls, commit_comparison):
+ return (commit_comparison.base_commitid, commit_comparison.compare_commitid)
+
+ def __init__(self, info, repository_id, *args, **kwargs):
+ self.repository_id = repository_id
+ super().__init__(info, *args, **kwargs)
+
+ def batch_queryset(self, keys):
+ return CommitComparisonService.fetch_precomputed(self.repository_id, keys)
+
+ async def batch_load_fn(self, keys):
+ # flat list of all commits involved in all comparisons
+ commitids = {commitid for key in keys for commitid in key}
+
+ commit_loader = CommitLoader.loader(self.info, self.repository_id)
+ commits = await commit_loader.load_many(commitids)
+
+ commit_cache = CommitCache(commits)
+
+ return await self._load_comparisons(keys, commit_cache)
+
+ @sync_to_async
+ def _load_comparisons(self, keys, commit_cache):
+ # initial fetch of comparisons (we may be missing some at this point)
+ queryset = self.batch_queryset(keys)
+ comparisons = {self.key(record): record for record in queryset}
+
+ # handle missing comparisons
+ missing_keys = set(keys) - set(comparisons.keys())
+ if len(missing_keys) > 0:
+ # create comparisons for the missing keys
+ for record in self._create_comparisons(missing_keys, commit_cache):
+ comparisons[self.key(record)] = record
+
+ # recalculate comparisons if needed
+ self._refresh_comparisons(comparisons, missing_keys, commit_cache)
+
+ # return comparisons in the same order as `keys`
+ return [comparisons.get(key) for key in keys]
+
+ def _create_comparisons(self, keys, commit_cache):
+ """
+ Insert new comparisons for the given keys (skipping insert of any duplicates).
+ """
+ comparisons = [
+ CommitComparison(
+ base_commit=commit_cache.get_by_commitid(base_commitid),
+ compare_commit=commit_cache.get_by_commitid(compare_commitid),
+ )
+ for (base_commitid, compare_commitid) in keys
+ if base_commitid
+ and commit_cache.get_by_commitid(base_commitid)
+ and compare_commitid
+ and commit_cache.get_by_commitid(compare_commitid)
+ ]
+ CommitComparison.objects.bulk_create(
+ comparisons,
+ ignore_conflicts=True,
+ )
+
+ # refetch missing comparisons (since they cannot be returned from the create call above
+ # due to the use of `ignore_conflicts`)
+ results = self.batch_queryset(keys)
+
+ if len(results) != len(comparisons):
+ # We've been seeing some instances of commit comparisons being created but no
+ # corresponding compute comparisons task being enqueued.
+ # Not sure why this would happen but curious to see if we see this line in the logs
+ log.warning(
+ "Failed to refetch all commit comparisons",
+ extra=dict(
+ created_count=len(comparisons),
+ fetched_count=len(results),
+ ),
+ )
+
+ return results
+
+ def _refresh_comparisons(self, comparisons, missing_keys, commit_cache):
+ """
+ Recalculate comparisons for newly added or out-of-date comparisons.
+ """
+ comparison_ids = []
+ for key, comparison in comparisons.items():
+ # we already have these commits fetched so we might as well store them
+ # on the comparison for the call to `needs_recompute` below
+ comparison.base_commit = commit_cache.get_by_pk(comparison.base_commit_id)
+ comparison.compare_commit = commit_cache.get_by_pk(
+ comparison.compare_commit_id
+ )
+
+ commit_comparison_service = CommitComparisonService(comparison)
+ if key in missing_keys or commit_comparison_service.needs_recompute():
+ comparison_ids.append(comparison.pk)
+
+ # optimistically update the state so we don't need to refetch this comparison
+ # (actual database update happens below)
+ comparison.state = CommitComparison.CommitComparisonStates.PENDING
+
+ if len(comparison_ids) > 0:
+ CommitComparison.objects.filter(pk__in=comparison_ids).update(
+ state=CommitComparison.CommitComparisonStates.PENDING
+ )
+ TaskService().compute_comparisons(comparison_ids)
diff --git a/apps/codecov-api/graphql_api/dataloader/loader.py b/apps/codecov-api/graphql_api/dataloader/loader.py
new file mode 100644
index 0000000000..26096e3109
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/loader.py
@@ -0,0 +1,66 @@
+from typing import Self
+
+from aiodataloader import DataLoader
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+
+
+class BaseLoader(DataLoader):
+ @classmethod
+ def loader(cls, info: GraphQLResolveInfo, *args) -> Self:
+ """
+ Creates a new loader for the given `info` (instance of GraphQLResolveInfo) and `args`.
+ If a loader of this type already exists for the given `args` then that same object will
+ be returned from the request context.
+ """
+ context_key = f"__dataloader_{cls.__name__}"
+ if len(args) > 0:
+ args_key = "_".join([str(arg) for arg in args])
+ context_key += f"_{args_key}"
+
+ if context_key not in info.context:
+ # one loader of a given (type, args) per request
+ info.context[context_key] = cls(info, *args)
+
+ return info.context[context_key]
+
+ def __init__(self, info, *args, **kwargs):
+ self.info = info
+ super().__init__(*args, **kwargs)
+
+ @classmethod
+ def key(cls, record):
+ """
+ Return the cache key for the given record (defaults to `id`)
+ """
+ return record.id
+
+ def cache(self, record):
+ """
+ Prime the cache with the given record
+ """
+ self.prime(self.key(record), record)
+
+ def batch_queryset(self, keys):
+ """
+ Return an unordered QuerySet that includes a record for every key in `keys`.
+ (ordering is handled in `batch_load_fn`)
+ """
+ raise NotImplementedError("override batch_queryset in subclass")
+
+ @sync_to_async
+ def batch_load_fn(self, keys):
+ """
+ This implements the aiodataloader interface to batch load records for an
+ ordered list of keys.
+
+ Each time we call `load` in the same tick of the event loop, aiodataloader
+ remembers the load key and defers the results. At the end of the tick we
+ batch load the records for all those keys.
+ """
+
+ queryset = self.batch_queryset(keys)
+ results = {self.key(record): record for record in queryset}
+
+ # the returned list of records must be in the exact order of `keys`
+ return [results.get(key) for key in keys]
diff --git a/apps/codecov-api/graphql_api/dataloader/owner.py b/apps/codecov-api/graphql_api/dataloader/owner.py
new file mode 100644
index 0000000000..9013a46aee
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/owner.py
@@ -0,0 +1,12 @@
+from codecov_auth.models import Owner
+
+from .loader import BaseLoader
+
+
+class OwnerLoader(BaseLoader):
+ @classmethod
+ def key(cls, owner):
+ return owner.ownerid
+
+ def batch_queryset(self, keys):
+ return Owner.objects.filter(ownerid__in=keys)
diff --git a/apps/codecov-api/graphql_api/dataloader/tests/test_bundle_analysis.py b/apps/codecov-api/graphql_api/dataloader/tests/test_bundle_analysis.py
new file mode 100644
index 0000000000..1f7b27ea23
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/tests/test_bundle_analysis.py
@@ -0,0 +1,163 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from graphql_api.dataloader.bundle_analysis import (
+ MissingBaseReportError,
+ MissingHeadReportError,
+ load_bundle_analysis_comparison,
+ load_bundle_analysis_report,
+)
+from graphql_api.types.comparison.comparison import MissingBaseReport, MissingHeadReport
+from reports.models import CommitReport
+from reports.tests.factories import CommitReportFactory
+
+
+class MockReportLoader:
+ def load(self, external_id):
+ return True
+
+
+class MockReportLoaderTwo:
+ def load(self, external_id):
+ return None
+
+
+class MockBundleAnalysisLoaderServiceMissingHeadReport:
+ """
+ During construction of the Comparison the shared code may raise an exception
+ when accessing head_report if it is not available
+ """
+
+ def __init__(self):
+ raise MissingHeadReportError()
+
+
+class MockBundleAnalysisLoaderServiceMissingBaseReport:
+ """
+ During construction of the Comparison the shared code may raise an exception
+ when accessing base_report if it is not available
+ """
+
+ def __init__(self):
+ raise MissingBaseReportError()
+
+
+class BundleAnalysisComparisonLoader(TestCase):
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ self.base_commit = CommitFactory(repository=self.repo)
+ self.base_commit_report = CommitReportFactory(
+ commit=self.base_commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ self.head_commit = CommitFactory(repository=self.repo)
+ self.head_commit_report = CommitReportFactory(
+ commit=self.head_commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ @patch("graphql_api.dataloader.bundle_analysis.BundleAnalysisComparison")
+ @patch("graphql_api.dataloader.bundle_analysis.BundleAnalysisReportLoader")
+ def test_loader(self, mock_loader, mock_comparison):
+ mock_loader.return_value = None
+ mock_comparison.return_value = True
+ loader = load_bundle_analysis_comparison(self.base_commit, self.head_commit)
+
+ assert loader == True
+
+ def test_loader_missing_base_report(self):
+ base_commit = CommitFactory(repository=self.repo)
+ CommitReportFactory(
+ commit=base_commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+
+ loader = load_bundle_analysis_comparison(
+ base_commit,
+ self.head_commit,
+ )
+ assert loader.message == MissingBaseReport.message
+
+ def test_loader_missing_head_report(self):
+ head_commit = CommitFactory(repository=self.repo)
+ CommitReportFactory(
+ commit=head_commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+
+ loader = load_bundle_analysis_comparison(
+ self.base_commit,
+ head_commit,
+ )
+ assert loader.message == MissingHeadReport.message
+
+ def test_loader_no_base_report(self):
+ base_commit = CommitFactory(repository=self.repo)
+
+ loader = load_bundle_analysis_comparison(
+ base_commit,
+ self.head_commit,
+ )
+ assert loader.message == MissingBaseReport.message
+
+ def test_loader_no_head_report(self):
+ head_commit = CommitFactory(repository=self.repo)
+
+ loader = load_bundle_analysis_comparison(
+ self.base_commit,
+ head_commit,
+ )
+ assert loader.message == MissingHeadReport.message
+
+ def test_loader_raises_missing_head_report(self):
+ with patch(
+ "graphql_api.dataloader.bundle_analysis.BundleAnalysisComparison",
+ side_effect=MissingHeadReportError(),
+ ):
+ loader = load_bundle_analysis_comparison(self.base_commit, self.head_commit)
+ assert loader.message == MissingHeadReport.message
+
+ def test_loader_raises_missing_base_report(self):
+ with patch(
+ "graphql_api.dataloader.bundle_analysis.BundleAnalysisComparison",
+ side_effect=MissingBaseReportError(),
+ ):
+ loader = load_bundle_analysis_comparison(self.base_commit, self.head_commit)
+ assert loader.message == MissingBaseReport.message
+
+
+class BundleAnalysisReportLoader(TestCase):
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ self.commit = CommitFactory(repository=self.repo)
+ self.commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ @patch("graphql_api.dataloader.bundle_analysis.BundleAnalysisReport")
+ @patch("graphql_api.dataloader.bundle_analysis.BundleAnalysisReportLoader")
+ def test_loader(self, mock_loader, mock_report):
+ mock_loader.return_value = MockReportLoader()
+ mock_report.return_value = True
+ loader = load_bundle_analysis_report(self.commit)
+ assert loader == True
+
+ @patch("graphql_api.dataloader.bundle_analysis.BundleAnalysisReportLoader")
+ def test_loader_missing_head_report_two(self, mock_loader):
+ mock_loader.return_value = MockReportLoaderTwo()
+ loader = load_bundle_analysis_report(self.commit)
+ assert loader.message == MissingHeadReport.message
+
+ def test_loader_missing_head_report(self):
+ head_commit = CommitFactory(repository=self.repo)
+ CommitReportFactory(
+ commit=head_commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+ loader = load_bundle_analysis_report(head_commit)
+ assert loader.message == MissingHeadReport.message
+
+ def test_loader_no_head_report(self):
+ head_commit = CommitFactory(repository=self.repo)
+ loader = load_bundle_analysis_report(head_commit)
+ assert loader.message == MissingHeadReport.message
diff --git a/apps/codecov-api/graphql_api/dataloader/tests/test_commit.py b/apps/codecov-api/graphql_api/dataloader/tests/test_commit.py
new file mode 100644
index 0000000000..5e8b7a9485
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/tests/test_commit.py
@@ -0,0 +1,100 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from graphql_api.dataloader.commit import CommitLoader
+
+
+class GraphQLResolveInfo:
+ def __init__(self):
+ self.context = {}
+
+
+class CommitLoaderTestCase(TestCase):
+ def setUp(self):
+ self.repository = RepositoryFactory(name="test-repo-1")
+ self.pull_1_commit = CommitFactory(
+ message="pull-1-head", repository=self.repository, commitid="123"
+ )
+ self.pull_3_commits = [
+ CommitFactory(
+ message="pull-3-commit-1", repository=self.repository, commitid="456"
+ ),
+ CommitFactory(
+ message="pull-3-commit-2", repository=self.repository, commitid="231"
+ ),
+ CommitFactory(
+ message="pull-3-head", repository=self.repository, commitid="159"
+ ),
+ ]
+ self.pull_2_commits = [
+ CommitFactory(
+ message="pull-2-commit-1", repository=self.repository, commitid="153"
+ ),
+ CommitFactory(
+ message="pull-2-head", repository=self.repository, commitid="164"
+ ),
+ ]
+ self.base_commit = CommitFactory(
+ message="base-commit", repository=self.repository, commitid="346"
+ )
+ self.pulls = [
+ PullFactory(
+ pullid=11,
+ repository=self.repository,
+ title="test-pull-request-1",
+ head=self.pull_1_commit.commitid,
+ base=self.base_commit.commitid,
+ ),
+ PullFactory(
+ pullid=12,
+ repository=self.repository,
+ title="test-pull-request-2",
+ head=self.pull_3_commits[2].commitid,
+ base=self.base_commit.commitid,
+ ),
+ PullFactory(
+ pullid=13,
+ repository=self.repository,
+ title="test-pull-request-3",
+ head=self.pull_2_commits[1].commitid,
+ base=self.base_commit.commitid,
+ ),
+ ]
+ self.info = GraphQLResolveInfo()
+
+ async def test_pull_with_one_commit(self):
+ loader = CommitLoader.loader(self.info, self.pulls[0].repository_id)
+ commit = await loader.load(self.pulls[0].head)
+ assert commit == self.pull_1_commit
+
+ async def test_pull_with_many_commit(self):
+ loader = CommitLoader.loader(self.info, self.pulls[1].repository_id)
+ commit = await loader.load(self.pulls[1].head)
+ assert commit == self.pull_3_commits[2]
+
+ async def test_pull_base_commit(self):
+ loader = CommitLoader.loader(self.info, self.pulls[0].repository_id)
+ commit = await loader.load(self.pulls[0].base)
+ assert commit == self.base_commit
+
+ async def test_on_multiple_pulls_commit(self):
+ loader = CommitLoader.loader(self.info, self.pulls[1].repository_id)
+ commit = await loader.load(self.pulls[1].base)
+ assert commit == self.base_commit
+
+ loader = CommitLoader.loader(self.info, self.pulls[2].repository_id)
+ commit_2 = await loader.load(self.pulls[2].base)
+ assert commit_2 == self.base_commit
+
+ async def test_repeated_commit_in_(self):
+ loader = CommitLoader.loader(self.info, self.pulls[1].repository_id)
+ commit = await loader.load(self.pulls[1].base)
+ assert commit == self.base_commit
+
+ loader = CommitLoader.loader(self.info, self.pulls[2].repository_id)
+ commit_2 = await loader.load(self.pulls[2].base)
+ assert commit_2 == self.base_commit
diff --git a/apps/codecov-api/graphql_api/dataloader/tests/test_comparison.py b/apps/codecov-api/graphql_api/dataloader/tests/test_comparison.py
new file mode 100644
index 0000000000..c5ea12c433
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/tests/test_comparison.py
@@ -0,0 +1,92 @@
+from unittest.mock import patch
+
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory
+from graphql_api.dataloader.comparison import ComparisonLoader
+
+
+class GraphQLResolveInfo:
+ def __init__(self):
+ self.context = {}
+
+
+async def load_comparisons(repoid, keys):
+ info = GraphQLResolveInfo()
+ loader = ComparisonLoader.loader(info, repoid)
+ return await loader.load_many(keys)
+
+
+@patch("services.task.TaskService.compute_comparisons")
+class ComparisonLoaderTestCase(TestCase):
+ def setUp(self):
+ self.repository = RepositoryFactory(name="test-repo-1")
+
+ def _load(self, keys):
+ return async_to_sync(load_comparisons)(self.repository.pk, keys)
+
+ def test_compare_commits_new_comparison(self, compute_comparisons):
+ commit1 = CommitFactory(repository=self.repository)
+ commit2 = CommitFactory(repository=self.repository)
+
+ comparison = CommitComparison.objects.filter(
+ base_commit=commit1,
+ compare_commit=commit2,
+ ).first()
+ assert comparison is None
+
+ (comparison,) = self._load([(commit1.commitid, commit2.commitid)])
+ assert comparison is not None
+ assert comparison.base_commit == commit1
+ assert comparison.compare_commit == commit2
+
+ compute_comparisons.assert_called_once_with([comparison.pk])
+ comparison.refresh_from_db()
+ assert comparison.state == "pending"
+
+ def test_compare_commits_existing_comparison(self, compute_comparisons):
+ commit1 = CommitFactory(repository=self.repository)
+ commit2 = CommitFactory(repository=self.repository)
+
+ CommitComparisonFactory(
+ base_commit=commit1,
+ compare_commit=commit2,
+ state="processed",
+ )
+
+ (comparison,) = self._load([(commit1.commitid, commit2.commitid)])
+ assert comparison is not None
+ assert comparison.base_commit == commit1
+ assert comparison.compare_commit == commit2
+
+ assert not compute_comparisons.called
+ comparison.refresh_from_db()
+ assert comparison.state == "processed"
+
+ def test_compare_commits_multiple_comparisons(self, compute_comparisons):
+ commit1 = CommitFactory(repository=self.repository)
+ commit2 = CommitFactory(repository=self.repository)
+ commit3 = CommitFactory(repository=self.repository)
+
+ CommitComparisonFactory(
+ base_commit=commit1,
+ compare_commit=commit2,
+ )
+
+ comparison1, comparison2 = self._load(
+ [
+ (commit1.commitid, commit2.commitid),
+ (commit2.commitid, commit3.commitid),
+ ]
+ )
+ assert comparison1 is not None
+ assert comparison1.base_commit == commit1
+ assert comparison1.compare_commit == commit2
+ assert comparison2 is not None
+ assert comparison2.base_commit == commit2
+ assert comparison2.compare_commit == commit3
+
+ compute_comparisons.assert_called_once_with([comparison2.pk])
diff --git a/apps/codecov-api/graphql_api/dataloader/tests/test_loader.py b/apps/codecov-api/graphql_api/dataloader/tests/test_loader.py
new file mode 100644
index 0000000000..00d6939c04
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/tests/test_loader.py
@@ -0,0 +1,27 @@
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory
+
+from graphql_api.dataloader.loader import BaseLoader
+
+
+class GraphQLResolveInfo:
+ def __init__(self):
+ self.context = {}
+
+
+class BaseLoaderTestCase(TestCase):
+ def setUp(self):
+ # record type is irrelevant here
+ self.record = CommitFactory(message="test commit", commitid="123")
+
+ self.info = GraphQLResolveInfo()
+
+ async def test_unimplemented_load(self):
+ loader = BaseLoader.loader(self.info)
+ with pytest.raises(NotImplementedError):
+ await loader.load(self.record.id)
+
+ async def test_default_key(self):
+ key = BaseLoader.key(self.record)
+ assert key == self.record.id
diff --git a/apps/codecov-api/graphql_api/dataloader/tests/test_owner.py b/apps/codecov-api/graphql_api/dataloader/tests/test_owner.py
new file mode 100644
index 0000000000..3b6478ad7b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/dataloader/tests/test_owner.py
@@ -0,0 +1,46 @@
+import asyncio
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.dataloader.owner import OwnerLoader
+
+
+class GraphQLResolveInfo:
+ def __init__(self):
+ self.context = {}
+
+
+class OnwerLoaderTestCase(TestCase):
+ def setUp(self):
+ self.users = [
+ OwnerFactory(username="codecov-1"),
+ OwnerFactory(username="codecov-2"),
+ OwnerFactory(username="codecov-3"),
+ OwnerFactory(username="codecov-4"),
+ OwnerFactory(username="codecov-5"),
+ ]
+ self.info = GraphQLResolveInfo()
+
+ async def test_one_user(self):
+ loader = OwnerLoader.loader(self.info)
+ user = await loader.load(self.users[2].ownerid)
+ assert user == self.users[2]
+
+ async def test_a_set_of_users(self):
+ loader = OwnerLoader.loader(self.info)
+ users = [
+ loader.load(self.users[3].ownerid),
+ loader.load(self.users[2].ownerid),
+ loader.load(self.users[4].ownerid),
+ loader.load(self.users[0].ownerid),
+ loader.load(self.users[1].ownerid),
+ ]
+ users_loaded = await asyncio.gather(*users)
+ assert users_loaded == [
+ self.users[3],
+ self.users[2],
+ self.users[4],
+ self.users[0],
+ self.users[1],
+ ]
diff --git a/apps/codecov-api/graphql_api/helpers/__init__.py b/apps/codecov-api/graphql_api/helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphql_api/helpers/ariadne.py b/apps/codecov-api/graphql_api/helpers/ariadne.py
new file mode 100644
index 0000000000..0b14e845e4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/ariadne.py
@@ -0,0 +1,13 @@
+import pathlib
+
+from ariadne import load_schema_from_path
+
+
+def ariadne_load_local_graphql(current_file, graphql_file):
+ """
+ Given the current_file (__file__) of the caller and a graphql file name
+ import that file and load it with ariadne.load_schema_from_path
+ """
+ current_dir = pathlib.Path(current_file).parent.absolute()
+ graphql_file_path = current_dir.joinpath(graphql_file)
+ return load_schema_from_path(graphql_file_path)
diff --git a/apps/codecov-api/graphql_api/helpers/connection.py b/apps/codecov-api/graphql_api/helpers/connection.py
new file mode 100644
index 0000000000..5eb3222604
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/connection.py
@@ -0,0 +1,271 @@
+import enum
+from base64 import b64decode
+from dataclasses import dataclass
+from functools import cached_property
+from typing import Any, Dict, List, Optional
+
+from asgiref.sync import sync_to_async
+from cursor_pagination import CursorPage, CursorPaginator, InvalidCursor
+from django.db.models import QuerySet
+
+from codecov.commands.exceptions import ValidationError
+from graphql_api.types.enums import OrderingDirection
+
+
+def build_connection_graphql(connection_name, type_node):
+ edge_name = connection_name + "Edge"
+ return f"""
+ type {connection_name} {{
+ edges: [{edge_name}]
+ totalCount: Int!
+ pageInfo: PageInfo!
+ }}
+
+ type {edge_name} {{
+ cursor: String!
+ node: {type_node}
+ }}
+ """
+
+
+def field_order(field, ordering):
+ if isinstance(field, enum.Enum):
+ field = field.value
+ if ordering == OrderingDirection.DESC:
+ field = f"-{field}"
+ return field
+
+
+@dataclass
+class Connection:
+ queryset: QuerySet
+ paginator: CursorPaginator
+ page: CursorPage
+
+ @cached_property
+ def edges(self):
+ return [
+ {"cursor": self.paginator.cursor(self.page[pos]), "node": node}
+ for pos, node in enumerate(self.page)
+ ]
+
+ @sync_to_async
+ def total_count(self, *args, **kwargs):
+ return self.queryset.count()
+
+ @cached_property
+ def start_cursor(self):
+ return self.paginator.cursor(self.page[0]) if len(self.page) > 0 else None
+
+ @cached_property
+ def end_cursor(self):
+ return self.paginator.cursor(self.page[-1]) if len(self.page) > 0 else None
+
+ @sync_to_async
+ def page_info(self, *args, **kwargs):
+ return {
+ "has_next_page": self.page.has_next,
+ "has_previous_page": self.page.has_previous,
+ "start_cursor": self.start_cursor,
+ "end_cursor": self.end_cursor,
+ }
+
+
+class ArrayPaginator:
+ """Cursor-based paginator for in-memory arrays."""
+
+ def __init__(
+ self,
+ data: List[Any],
+ first: Optional[int] = None,
+ last: Optional[int] = None,
+ after: Optional[str] = None,
+ before: Optional[str] = None,
+ ):
+ self.data = data
+ self.start_index = 0
+ self.end_index = len(data)
+
+ if first and last:
+ raise ValidationError("Cannot provide both 'first' and 'last'")
+
+ if after is not None:
+ try:
+ self.start_index = int(after) + 1
+ except ValueError:
+ raise ValidationError("'after' cursor must be an integer")
+
+ if before is not None:
+ try:
+ self.end_index = min(self.end_index, int(before))
+ except ValueError:
+ raise ValidationError("'before' cursor must be an integer")
+
+ # Ensure valid bounds after 'after' and 'before'
+ self.start_index = max(self.start_index, 0)
+ self.end_index = min(self.end_index, len(data))
+
+ if first is not None:
+ self.end_index = min(self.start_index + first, len(data))
+
+ if last is not None:
+ range_length = self.end_index - self.start_index
+ if range_length > last:
+ self.start_index = self.end_index - last
+
+ # Ensure bounds remain valid
+ self.start_index = max(self.start_index, 0)
+ self.end_index = min(self.end_index, len(data))
+
+ def cursor(self, position: int) -> str:
+ """Generate a cursor based on the position (index)."""
+ return str(position)
+
+ @property
+ def page(self) -> List[Any]:
+ """Returns the sliced page of data."""
+ return self.data[self.start_index : self.end_index]
+
+ @property
+ def has_next(self) -> bool:
+ """Check if there's a next page."""
+ return self.end_index < len(self.data)
+
+ @property
+ def has_previous(self) -> bool:
+ """Check if there's a previous page."""
+ return self.start_index > 0
+
+
+class ArrayConnection:
+ """Connection wrapper for array pagination."""
+
+ def __init__(self, paginator: ArrayPaginator):
+ self.data = paginator.data
+ self.paginator = paginator
+ self.page = paginator.page
+
+ @property
+ def edges(self) -> List[Dict[str, Any]]:
+ """Generate edges with cursor and node information"""
+ return [
+ {"cursor": self.paginator.cursor(pos), "node": node}
+ for pos, node in enumerate(self.page)
+ ]
+
+ @property
+ def total_count(self) -> int:
+ """Total number of items in the original data"""
+ return len(self.data)
+
+ @property
+ def start_cursor(self) -> Optional[str]:
+ """Cursor for the first item in the page"""
+ return self.paginator.cursor(self.paginator.start_index) if self.page else None
+
+ @property
+ def end_cursor(self) -> Optional[str]:
+ """Cursor for the last item in the page"""
+ return (
+ self.paginator.cursor(self.paginator.end_index - 1) if self.page else None
+ )
+
+ @property
+ def page_info(self) -> Dict[str, Any]:
+ """Pagination information"""
+ return {
+ "has_next_page": self.paginator.has_next,
+ "has_previous_page": self.paginator.has_previous,
+ "start_cursor": self.start_cursor,
+ "end_cursor": self.end_cursor,
+ }
+
+
+class DictCursorPaginator(CursorPaginator):
+ NULL_VALUE_REPR = "\x1f"
+ """
+ WARNING: DictCursorPaginator does not work for dict objects where a key contains the following string: "__"
+ TODO: if instance is a dictionary and not an object, don't split the ordering
+
+ ordering = "test__name"
+ Django object:
+ -> obj.test.name
+
+ Dict:
+ -> obj["test"]["name"] X wrong
+ we want obj["test__name"]
+
+ overrides CursorPaginator's position_from_instance method
+ because it assumes that instance's fields are attributes on the
+ instance. This doesn't work with the aggregate_test_results query
+ because since it uses annotate() and values() the instance is actually
+ a dict and the fields are keys in that dict.
+
+ So if getattr fails to find the attribute on the instance then we try getting the "attr"
+ via a dict access
+
+ if the dict access fails then it throws an exception, although it would be a different
+ """
+
+ def decode_cursor(self, cursor):
+ try:
+ orderings = b64decode(cursor.encode("ascii")).decode("utf8")
+ orderings = orderings.split(self.delimiter)
+ return [
+ None if ordering == self.NULL_VALUE_REPR else ordering
+ for ordering in orderings
+ ]
+ except (TypeError, ValueError):
+ raise InvalidCursor(self.invalid_cursor_message)
+
+ def position_from_instance(self, instance):
+ position = []
+ for order in self.ordering:
+ parts = order.lstrip("-").split("__")
+ attr = instance
+ while parts:
+ try:
+ attr = getattr(attr, parts[0])
+ except AttributeError as attr_err:
+ try:
+ attr = attr[parts[0]]
+ except (KeyError, TypeError):
+ raise attr_err from None
+ parts.pop(0)
+ position.append(self.NULL_VALUE_REPR if attr is None else str(attr))
+ return position
+
+
+def queryset_to_connection_sync(
+ data: QuerySet | list,
+ *,
+ ordering=None,
+ ordering_direction=None,
+ first=None,
+ after=None,
+ last=None,
+ before=None,
+):
+ """
+ A method to take a queryset or an array and return it in paginated order based on the cursor pattern.
+ Handles both QuerySets (database queries) and arrays (in-memory data).
+ """
+ if not first and not last:
+ first = 25
+
+ if isinstance(data, list):
+ array_paginator = ArrayPaginator(
+ data, first=first, last=last, after=after, before=before
+ )
+ return ArrayConnection(array_paginator)
+
+ else:
+ ordering = tuple(field_order(field, ordering_direction) for field in ordering)
+ paginator = DictCursorPaginator(data, ordering=ordering)
+ page = paginator.page(first=first, after=after, last=last, before=before)
+ return Connection(data, paginator, page)
+
+
+@sync_to_async
+def queryset_to_connection(*args, **kwargs):
+ return queryset_to_connection_sync(*args, **kwargs)
diff --git a/apps/codecov-api/graphql_api/helpers/lookahead.py b/apps/codecov-api/graphql_api/helpers/lookahead.py
new file mode 100644
index 0000000000..6de145bab9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/lookahead.py
@@ -0,0 +1,75 @@
+from typing import Iterable, Optional
+
+from graphql.language.ast import (
+ FragmentSpreadNode,
+ Node,
+ SelectionSetNode,
+ VariableNode,
+)
+from graphql.type.definition import GraphQLResolveInfo
+
+
+class LookaheadNode:
+ """
+ Wrapper around a node in the GraphQL AST that has a set of args
+ and potentially some child nodes.
+ """
+
+ def __init__(self, node: Node, info: GraphQLResolveInfo):
+ self.node = node
+ self.info = info
+
+ @property
+ def args(self) -> dict[str, any]:
+ """
+ Return a dict of this node's arguments as a name -> value mapping
+ """
+ args = {}
+ for arg in self.node.arguments:
+ name = arg.name.value
+ value_node = arg.value
+ if isinstance(value_node, VariableNode):
+ variable_name = value_node.name.value
+ value = self.info.variable_values[variable_name]
+ else:
+ value = value_node.value
+ args[name] = value
+ return args
+
+ def __getitem__(self, name: str) -> Optional["LookaheadNode"]:
+ """
+ Get a child node by name
+ """
+ if self.node.selection_set:
+ for selection in self._flatten_selections(self.node.selection_set):
+ if selection.name.value == name:
+ return LookaheadNode(selection, self.info)
+
+ def _flatten_selections(self, selection_set: SelectionSetNode) -> Iterable[Node]:
+ """
+ Expand fragments into flat list of selections
+ """
+ selections = []
+ for selection in selection_set.selections:
+ if isinstance(selection, FragmentSpreadNode):
+ fragment = self.info.fragments[selection.name.value]
+ for selection in fragment.selection_set.selections:
+ selections.append(selection) # noqa: PERF402
+ else:
+ selections.append(selection)
+ return selections
+
+
+def lookahead(info: GraphQLResolveInfo, path: Iterable[str]) -> Optional[LookaheadNode]:
+ """
+ Traverse the GraphQL AST and return the lookahead node at the given `path`
+ """
+ node = LookaheadNode(info.field_nodes[0], info)
+
+ for item in path:
+ if node:
+ node = node[item]
+ else:
+ return None
+
+ return node
diff --git a/apps/codecov-api/graphql_api/helpers/mutation.py b/apps/codecov-api/graphql_api/helpers/mutation.py
new file mode 100644
index 0000000000..a9739406ee
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/mutation.py
@@ -0,0 +1,96 @@
+from codecov.commands import exceptions
+from codecov_auth.helpers import current_user_part_of_org
+
+
+class WrappedException:
+ """
+ Our own class to wrap a Python exception as the core GraphQL library would
+ reraise an exception if a resolver returns an error (https://github.com/graphql-python/graphql-core/blob/c602d00b8a8f78bc349a911e0c26d73e1a9bbbac/src/graphql/execution/execute.py#L663-L666)
+ so we need to wrap it with a class that is not an Exception; so we can pass
+ it as a value to be returned by the mutation
+ """
+
+ exception = None
+
+ def __init__(self, exception):
+ self.exception = exception
+
+ def get_graphql_type(self):
+ """
+ Map an exception from "codecov.commands.exceptions" to a GraphQL type
+ """
+ error_to_graphql_type = {
+ exceptions.Unauthenticated: "UnauthenticatedError",
+ exceptions.Unauthorized: "UnauthorizedError",
+ exceptions.NotFound: "NotFoundError",
+ exceptions.ValidationError: "ValidationError",
+ exceptions.MissingService: "MissingService",
+ }
+ type_exception = type(self.exception)
+ return error_to_graphql_type.get(type_exception, None)
+
+ def __getattr__(self, attr):
+ """
+ Proxy all the attribute to the exception itself
+ """
+ return getattr(self.exception, attr)
+
+
+def wrap_error_handling_mutation(resolver):
+ async def resolver_with_error_handling(*args, **kwargs):
+ try:
+ return await resolver(*args, **kwargs)
+ except exceptions.BaseException as e:
+ # Wrap a pure Python exception with our Wrapper to pass as a value
+ return {"error": WrappedException(e)}
+
+ return resolver_with_error_handling
+
+
+def require_authenticated(resolver):
+ def authenticated_resolver(instance, info, *args, **kwargs):
+ current_user = info.context["request"].user
+ if not current_user.is_authenticated:
+ raise exceptions.Unauthenticated()
+
+ return resolver(instance, info, *args, **kwargs)
+
+ return authenticated_resolver
+
+
+def require_part_of_org(resolver):
+ def authenticated_resolver(queried_owner, info, *args, **kwargs):
+ current_user = info.context["request"].user
+ current_owner = info.context["request"].current_owner
+ if (
+ not current_user
+ or not current_user.is_authenticated
+ or not current_owner
+ or not current_user_part_of_org(current_owner, queried_owner)
+ ):
+ return None
+
+ return resolver(queried_owner, info, *args, **kwargs)
+
+ return authenticated_resolver
+
+
+def require_shared_account_or_part_of_org(resolver):
+ def authenticated_resolver(queried_owner, info, *args, **kwargs):
+ current_user = info.context["request"].user
+ if (
+ current_user
+ and current_user.is_authenticated
+ and queried_owner
+ and queried_owner.account
+ and current_user in queried_owner.account.users.all()
+ ):
+ return resolver(queried_owner, info, *args, **kwargs)
+
+ return require_part_of_org(resolver)(queried_owner, info, *args, **kwargs)
+
+ return authenticated_resolver
+
+
+def resolve_union_error_type(error, *_):
+ return error.get_graphql_type()
diff --git a/apps/codecov-api/graphql_api/helpers/requested_fields.py b/apps/codecov-api/graphql_api/helpers/requested_fields.py
new file mode 100644
index 0000000000..c6a961e149
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/requested_fields.py
@@ -0,0 +1,59 @@
+# This was adapted from
+from collections.abc import Generator, Iterable
+
+from graphql import GraphQLResolveInfo
+from graphql.language import (
+ FieldNode,
+ FragmentSpreadNode,
+ InlineFragmentNode,
+ SelectionNode,
+)
+
+
+def selected_fields(info: GraphQLResolveInfo) -> set[str]:
+ """
+ Given a GraphQL "sub-query", this recursively collects all the queried fields.
+
+ For example, if the original GraphQL query looks like `owner { repository { name } }`,
+ this would resolve to `repository` and `repository.name`.
+
+ This function works by traversing the parts of the GraphQL Query AST which
+ are exposed to each "resolver".
+ """
+ names: set[str] = set()
+ for node in info.field_nodes:
+ if node.selection_set is None:
+ continue
+ names.update(_fields_from_selections(info, node.selection_set.selections))
+ return names
+
+
+def _fields_from_selections(
+ info: GraphQLResolveInfo, selections: Iterable[SelectionNode]
+) -> Generator[str, None, None]:
+ for selection in selections:
+ match selection:
+ case FieldNode():
+ name = selection.name.value
+ yield name
+
+ if selection.selection_set is not None:
+ yield from (
+ f"{name}.{field}"
+ for field in _fields_from_selections(
+ info, selection.selection_set.selections
+ )
+ )
+
+ case InlineFragmentNode():
+ yield from _fields_from_selections(
+ info, selection.selection_set.selections
+ )
+ case FragmentSpreadNode():
+ fragment = info.fragments[selection.name.value]
+ yield from _fields_from_selections(
+ info, fragment.selection_set.selections
+ )
+
+ case _:
+ raise NotImplementedError(f"field type {type(selection)} not supported")
diff --git a/apps/codecov-api/graphql_api/helpers/tests/__init__.py b/apps/codecov-api/graphql_api/helpers/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphql_api/helpers/tests/test_connection.py b/apps/codecov-api/graphql_api/helpers/tests/test_connection.py
new file mode 100644
index 0000000000..fea9512cb0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/tests/test_connection.py
@@ -0,0 +1,175 @@
+from asgiref.sync import async_to_sync
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import RepositoryFactory
+
+from codecov.commands.exceptions import ValidationError
+from core.models import Repository
+from graphql_api.types.enums import OrderingDirection, RepositoryOrdering
+
+
+class RepositoryQuerySetTests(TestCase):
+ def test_queryset_to_connection_deterministic_ordering(self):
+ from graphql_api.helpers.connection import queryset_to_connection
+
+ repo_1 = RepositoryFactory(name="c")
+ repo_2 = RepositoryFactory(name="b")
+ repo_3 = RepositoryFactory(name="b")
+ repo_4 = RepositoryFactory(name="b")
+ repo_5 = RepositoryFactory(name="a")
+
+ connection = async_to_sync(queryset_to_connection)(
+ Repository.objects.all(),
+ ordering=(RepositoryOrdering.NAME.value, RepositoryOrdering.ID.value),
+ ordering_direction=OrderingDirection.ASC,
+ )
+ repos = [edge["node"] for edge in connection.edges]
+
+ self.assertEqual(repos, [repo_5, repo_2, repo_3, repo_4, repo_1])
+
+ connection = async_to_sync(queryset_to_connection)(
+ Repository.objects.all(),
+ ordering=(RepositoryOrdering.NAME.value, RepositoryOrdering.ID.value),
+ ordering_direction=OrderingDirection.DESC,
+ )
+ repos = [edge["node"] for edge in connection.edges]
+
+ self.assertEqual(repos, [repo_1, repo_4, repo_3, repo_2, repo_5])
+
+ def test_queryset_to_connection_accepts_enum_for_ordering(self):
+ from graphql_api.helpers.connection import queryset_to_connection
+
+ repo_1 = RepositoryFactory(name="a")
+ repo_2 = RepositoryFactory(name="b")
+ repo_3 = RepositoryFactory(name="c")
+
+ connection = async_to_sync(queryset_to_connection)(
+ Repository.objects.all(),
+ ordering=(RepositoryOrdering.NAME,),
+ ordering_direction=OrderingDirection.ASC,
+ )
+ repos = [edge["node"] for edge in connection.edges]
+
+ self.assertEqual(repos, [repo_1, repo_2, repo_3])
+
+ def test_queryset_to_connection_defers_count(self):
+ from graphql_api.helpers.connection import queryset_to_connection
+
+ RepositoryFactory(name="a")
+ RepositoryFactory(name="b")
+ RepositoryFactory(name="c")
+
+ connection = async_to_sync(queryset_to_connection)(
+ Repository.objects.all(),
+ ordering=(RepositoryOrdering.NAME,),
+ ordering_direction=OrderingDirection.ASC,
+ )
+
+ count = async_to_sync(connection.total_count)()
+ assert count == 3
+
+ def test_array_pagination_first_after(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ # Test first parameter
+ connection = queryset_to_connection_sync(data, first=2)
+ self.assertEqual([edge["node"] for edge in connection.edges], [1, 2])
+ self.assertTrue(connection.page_info["has_next_page"])
+ self.assertFalse(connection.page_info["has_previous_page"])
+
+ # Test after parameter
+ connection = queryset_to_connection_sync(data, first=2, after="1")
+ self.assertEqual([edge["node"] for edge in connection.edges], [3, 4])
+ self.assertTrue(connection.page_info["has_next_page"])
+ self.assertTrue(connection.page_info["has_previous_page"])
+
+ def test_array_pagination_last_before(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ # Test last parameter
+ connection = queryset_to_connection_sync(data, last=2)
+ self.assertEqual([edge["node"] for edge in connection.edges], [4, 5])
+ self.assertFalse(connection.page_info["has_next_page"])
+ self.assertTrue(connection.page_info["has_previous_page"])
+
+ # Test before parameter
+ connection = queryset_to_connection_sync(data, last=2, before="4")
+ self.assertEqual([edge["node"] for edge in connection.edges], [3, 4])
+ self.assertTrue(connection.page_info["has_next_page"])
+ self.assertTrue(connection.page_info["has_previous_page"])
+
+ def test_array_pagination_edge_cases(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3]
+
+ # Empty array
+ connection = queryset_to_connection_sync([], first=2)
+ self.assertEqual(connection.edges, [])
+ self.assertEqual(connection.total_count, 0)
+
+ # First greater than array length
+ connection = queryset_to_connection_sync(data, first=5)
+ self.assertEqual([edge["node"] for edge in connection.edges], [1, 2, 3])
+ self.assertEqual(connection.total_count, 3)
+
+ # Last greater than array length
+ connection = queryset_to_connection_sync(data, last=5)
+ self.assertEqual([edge["node"] for edge in connection.edges], [1, 2, 3])
+ self.assertEqual(connection.total_count, 3)
+
+ def test_array_pagination_edge_cases_with_before_cursor_2(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ connection = queryset_to_connection_sync(data, last=3, before="3")
+ self.assertEqual([edge["node"] for edge in connection.edges], [1, 2, 3])
+
+ def test_array_pagination_edge_cases_with_before_and_after(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ connection = queryset_to_connection_sync(data, last=3, before="3", after="0")
+ self.assertEqual([edge["node"] for edge in connection.edges], [2, 3])
+
+ def test_both_first_and_last(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ with self.assertRaises(ValidationError):
+ queryset_to_connection_sync(data, last=3, first=2)
+
+ def test_invalid_cursors(self):
+ from graphql_api.helpers.connection import queryset_to_connection_sync
+
+ data = [1, 2, 3, 4, 5]
+
+ with self.assertRaises(ValidationError):
+ queryset_to_connection_sync(data, last=3, before="invalid")
+
+ with self.assertRaises(ValidationError):
+ queryset_to_connection_sync(data, first=3, after="invalid")
+
+ def test_dict_cursor_paginator_null_encoding(self):
+ from graphql_api.helpers.connection import DictCursorPaginator, field_order
+
+ repo_1 = RepositoryFactory(name="a", active=None)
+ repo_2 = RepositoryFactory(name="b", active=True)
+ repo_3 = RepositoryFactory(name="c", active=False)
+ r = Repository.objects.all()
+
+ ordering = tuple(
+ field_order(field, OrderingDirection.ASC) for field in ("active",)
+ )
+
+ paginator = DictCursorPaginator(r, ordering=ordering)
+
+ assert paginator.position_from_instance(repo_1) == ["\x1f"]
+ assert paginator.position_from_instance(repo_2) == ["True"]
+ assert paginator.position_from_instance(repo_3) == ["False"]
diff --git a/apps/codecov-api/graphql_api/helpers/tests/test_mutation.py b/apps/codecov-api/graphql_api/helpers/tests/test_mutation.py
new file mode 100644
index 0000000000..b2d656a7c0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/helpers/tests/test_mutation.py
@@ -0,0 +1,74 @@
+from asgiref.sync import sync_to_async
+from django.test import SimpleTestCase
+
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+
+from ..mutation import resolve_union_error_type, wrap_error_handling_mutation
+
+
+class HelperMutationTest(SimpleTestCase):
+ async def test_mutation_when_everything_is_good(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ return "5"
+
+ assert await resolver() == "5"
+
+ async def test_mutation_when_unauthenticated_is_raised(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ raise Unauthenticated()
+
+ resolved_value = await resolver()
+ assert resolved_value["error"].message == "You are not authenticated"
+ graphql_type_error = resolve_union_error_type(resolved_value["error"])
+ assert graphql_type_error == "UnauthenticatedError"
+
+ async def test_mutation_when_unauthorized_is_raised(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ raise Unauthorized()
+
+ resolved_value = await resolver()
+ assert resolved_value["error"].message == "You are not authorized"
+ graphql_type_error = resolve_union_error_type(resolved_value["error"])
+ assert graphql_type_error == "UnauthorizedError"
+
+ async def test_mutation_when_validation_is_raised(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ raise ValidationError("wrong data")
+
+ resolved_value = await resolver()
+ assert resolved_value["error"].message == "wrong data"
+ graphql_type_error = resolve_union_error_type(resolved_value["error"])
+ assert graphql_type_error == "ValidationError"
+
+ async def test_mutation_when_not_found_is_raised(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ raise NotFound()
+
+ resolved_value = await resolver()
+ assert resolved_value["error"].message == "Cant find the requested resource"
+ graphql_type_error = resolve_union_error_type(resolved_value["error"])
+ assert graphql_type_error == "NotFoundError"
+
+ async def test_mutation_when_random_exception_is_raised_it_reraise(self):
+ @wrap_error_handling_mutation
+ @sync_to_async
+ def resolver():
+ raise AttributeError()
+
+ with self.assertRaises(AttributeError):
+ await resolver()
diff --git a/apps/codecov-api/graphql_api/schema.py b/apps/codecov-api/graphql_api/schema.py
new file mode 100644
index 0000000000..e37b22cb04
--- /dev/null
+++ b/apps/codecov-api/graphql_api/schema.py
@@ -0,0 +1,7 @@
+from ariadne import make_executable_schema
+
+from .types import bindables, types
+
+# convert_names_case automatically converts the field name from camelCase
+# to snake_case. See: https://ariadnegraphql.org/docs/api-reference#optional-arguments-10
+schema = make_executable_schema(types, *bindables, convert_names_case=True)
diff --git a/apps/codecov-api/graphql_api/tests/__init__.py b/apps/codecov-api/graphql_api/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphql_api/tests/actions/test_commits.py b/apps/codecov-api/graphql_api/tests/actions/test_commits.py
new file mode 100644
index 0000000000..742c94960f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/actions/test_commits.py
@@ -0,0 +1,137 @@
+from collections import Counter
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from graphql_api.actions.commits import repo_commits
+from reports.models import CommitReport
+from reports.tests.factories import CommitReportFactory, UploadFactory
+
+
+class RepoCommitsTests(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.repo_with_deleted_commits = RepositoryFactory(
+ author=self.org, private=False
+ )
+ self.commits = [
+ CommitFactory(repository=self.repo, message="Foo Bar", state="complete"),
+ CommitFactory(
+ repository=self.repo, branch="test", message="barn", state="error"
+ ),
+ CommitFactory(
+ repository=self.repo, branch="test", message="baz", state="pending"
+ ),
+ ]
+ self.deleted_commits = [
+ CommitFactory(repository=self.repo_with_deleted_commits, deleted=True),
+ CommitFactory(
+ repository=self.repo_with_deleted_commits, branch="test", deleted=True
+ ),
+ CommitFactory(
+ repository=self.repo_with_deleted_commits, branch="test", deleted=True
+ ),
+ ]
+ self.repo_2 = RepositoryFactory(author=self.org, private=False)
+ self.commits_2 = [
+ CommitFactory(repository=self.repo_2, pullid=179),
+ CommitFactory(repository=self.repo_2, branch="test2", pullid=179),
+ CommitFactory(repository=self.repo_2, ci_passed=False, branch="test2"),
+ ]
+
+ def test_all(self):
+ commits = repo_commits(self.repo, None)
+ assert len(list(commits)) == 3
+ assert Counter(list(commits)) == Counter(self.commits)
+
+ def test_hide_failed_ci(self):
+ commits = repo_commits(self.repo_2, {"hide_failed_ci": True})
+ commits_with_filter = list(
+ filter(lambda commit: commit.ci_passed is True, self.commits_2)
+ )
+ assert Counter(list(commits)) == Counter(commits_with_filter)
+
+ def test_pullid(self):
+ commits = repo_commits(self.repo_2, {"pull_id": 179})
+ commits_with_filter = list(
+ filter(lambda commit: commit.pullid == 179, self.commits_2)
+ )
+ assert Counter(list(commits)) == Counter(commits_with_filter)
+
+ def test_branch_name(self):
+ commits = repo_commits(self.repo, {"branch_name": "test"})
+ commits_with_filter = list(
+ filter(lambda commit: commit.branch == "test", self.commits)
+ )
+ assert Counter(list(commits)) == Counter(commits_with_filter)
+
+ def test_deleted_commits(self):
+ commits = repo_commits(self.repo_with_deleted_commits, None)
+ assert list(commits) == []
+
+ def test_branch_name_hide_failed_ci(self):
+ commits = repo_commits(
+ self.repo_2, {"hide_failed_ci": True, "branch_name": "test2"}
+ )
+ commits_with_filter = list(
+ filter(
+ lambda commit: commit.branch == "test2" and commit.ci_passed is True,
+ self.commits_2,
+ )
+ )
+ assert list(commits) == commits_with_filter
+
+ def test_long_sha(self):
+ commits = repo_commits(self.repo, {"search": self.commits[0].commitid})
+ assert list(commits) == [self.commits[0]]
+
+ def test_short_sha(self):
+ commits = repo_commits(self.repo, {"search": self.commits[0].commitid[0:7]})
+ assert list(commits) == [self.commits[0]]
+
+ def test_message(self):
+ commits = repo_commits(self.repo, {"search": "bar"})
+ assert list(commits) == [self.commits[0], self.commits[1]]
+
+ def test_states(self):
+ commits = repo_commits(self.repo, {"states": ["complete", "pending"]})
+ assert list(commits) == [self.commits[0], self.commits[2]]
+
+ def test_coverage_status(self):
+ report_0 = CommitReportFactory(
+ commit=self.commits[0], report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=report_0, state="processed")
+ report_1 = CommitReportFactory(
+ commit=self.commits[1], report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=report_1, state="uploaded")
+ report_2 = CommitReportFactory(
+ commit=self.commits[2], report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=report_2, state="error")
+
+ commits = repo_commits(self.repo, {"coverage_status": ["COMPLETED"]})
+ assert list(commits) == [self.commits[0]]
+
+ commits = repo_commits(self.repo, {"coverage_status": ["PENDING"]})
+ assert list(commits) == [self.commits[1]]
+
+ commits = repo_commits(self.repo, {"coverage_status": ["ERROR"]})
+ assert list(commits) == [self.commits[2]]
+
+ commits = repo_commits(self.repo, {"coverage_status": ["COMPLETED", "PENDING"]})
+ assert list(commits) == [self.commits[0], self.commits[1]]
+
+ commits = repo_commits(
+ self.repo, {"coverage_status": ["COMPLETED", "PENDING", "ERROR"]}
+ )
+ assert list(commits) == [self.commits[0], self.commits[1], self.commits[2]]
+
+ commits = repo_commits(self.repo, {"coverage_status": []})
+ assert list(commits) == [self.commits[0], self.commits[1], self.commits[2]]
diff --git a/apps/codecov-api/graphql_api/tests/helper.py b/apps/codecov-api/graphql_api/tests/helper.py
new file mode 100644
index 0000000000..0a7f9da271
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/helper.py
@@ -0,0 +1,48 @@
+from http.cookies import SimpleCookie
+
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, UserFactory
+
+from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY
+from utils.test_utils import Client
+
+
+class GraphQLTestHelper:
+ def gql_request(
+ self,
+ query,
+ provider="gh",
+ owner=None,
+ variables=None,
+ with_errors=False,
+ okta_signed_in_accounts=[],
+ impersonate_owner=False,
+ ):
+ url = f"/graphql/{provider}"
+
+ if owner:
+ self.client = Client()
+
+ if impersonate_owner:
+ staff_owner = OwnerFactory(
+ name="staff_user", service="github", user=UserFactory(is_staff=True)
+ )
+ self.client.cookies = SimpleCookie({"staff_user": owner.pk})
+ self.client.force_login_owner(staff_owner)
+ else:
+ self.client.force_login_owner(owner)
+
+ if okta_signed_in_accounts:
+ session = self.client.session
+ session[OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY] = okta_signed_in_accounts
+ session.save()
+
+ response = self.client.post(
+ url,
+ {"query": query, "variables": variables or {}},
+ content_type="application/json",
+ )
+ return response.json() if with_errors else response.json()["data"]
+
+
+def paginate_connection(connection):
+ return [edge["node"] for edge in connection["edges"]]
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_activate_measurements.py b/apps/codecov-api/graphql_api/tests/mutation/test_activate_measurements.py
new file mode 100644
index 0000000000..ee3a978f7e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_activate_measurements.py
@@ -0,0 +1,54 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: ActivateMeasurementsInput!) {
+ activateMeasurements(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class ActivateMeasurementsTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "test-repo",
+ "measurementType": "FLAG_COVERAGE",
+ }
+ },
+ )
+ assert (
+ data["activateMeasurements"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ @patch(
+ "core.commands.repository.interactors.activate_measurements.ActivateMeasurementsInteractor.execute"
+ )
+ def test_when_authenticated(self, execute):
+ data = self.gql_request(
+ query,
+ owner=self.owner,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "test-repo",
+ "measurementType": "FLAG_COVERAGE",
+ }
+ },
+ )
+ assert data == {"activateMeasurements": None}
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_cancel_trial.py b/apps/codecov-api/graphql_api/tests/mutation/test_cancel_trial.py
new file mode 100644
index 0000000000..65b09c565d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_cancel_trial.py
@@ -0,0 +1,86 @@
+from django.test import TestCase
+from prometheus_client import REGISTRY
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import PlanName, TierName, TrialStatus
+
+from graphql_api.tests.helper import GraphQLTestHelper
+from graphql_api.views import GQL_ERROR_COUNTER, GQL_HIT_COUNTER, GQL_REQUEST_LATENCIES
+
+query = """
+ mutation($input: CancelTrialInput!) {
+ cancelTrial(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class CancelTrialMutationTest(GraphQLTestHelper, TestCase):
+ def _request(self, owner=None, org_username: str = None):
+ return self.gql_request(
+ query,
+ variables={"input": {"orgUsername": org_username}},
+ owner=owner,
+ )
+
+ def test_unauthenticated(self):
+ assert self._request() == {
+ "cancelTrial": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_authenticated(self):
+ GQL_HIT_COUNTER.labels(
+ operation_type="mutation", operation_name="CancelTrialInput"
+ )
+ GQL_ERROR_COUNTER.labels(
+ operation_type="mutation", operation_name="CancelTrialInput"
+ )
+ GQL_REQUEST_LATENCIES.labels(
+ operation_type="mutation", operation_name="CancelTrialInput"
+ )
+ before = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ errors_before = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ timer_before = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ trial_status = TrialStatus.ONGOING.value
+ tier = TierFactory(tier_name=TierName.TRIAL.value)
+ plan = PlanFactory(name=PlanName.TRIAL_PLAN_NAME.value, tier=tier)
+ owner = OwnerFactory(trial_status=trial_status, plan=plan.name)
+ owner.save()
+ assert self._request(owner=owner, org_username=owner.username) == {
+ "cancelTrial": None
+ }
+ after = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ errors_after = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ timer_after = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"},
+ )
+ assert after - before == 1
+ assert errors_after - errors_before == 0
+ assert timer_after - timer_before == 1
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_create_api_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_create_api_token.py
new file mode 100644
index 0000000000..1b0cfa264f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_create_api_token.py
@@ -0,0 +1,56 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import Session
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: CreateApiTokenInput!) {
+ createApiToken(input: $input) {
+ error {
+ __typename
+ }
+ fullToken
+ session {
+ ip
+ lastseen
+ lastFour
+ type
+ name
+ useragent
+ }
+ }
+}
+"""
+
+
+class CreateApiTokenTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"name": "yo"}})
+ assert data["createApiToken"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_when_authenticated(self):
+ name = "yo"
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"name": name}}
+ )
+ created_token = self.owner.session_set.filter(name=name).first()
+ assert data["createApiToken"]["session"] == {
+ "name": name,
+ "ip": None,
+ "lastseen": None,
+ "useragent": None,
+ "type": Session.SessionType.API.value,
+ "lastFour": str(created_token.token)[-4:],
+ }
+
+ def test_when_authenticated_full_token(self):
+ name = "yo"
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"name": name}}
+ )
+ created_token = self.owner.session_set.filter(name=name).first()
+ assert data["createApiToken"]["fullToken"] == str(created_token.token)
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_create_stripe_setup_intent.py b/apps/codecov-api/graphql_api/tests/mutation/test_create_stripe_setup_intent.py
new file mode 100644
index 0000000000..9143e51374
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_create_stripe_setup_intent.py
@@ -0,0 +1,67 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: CreateStripeSetupIntentInput!) {
+ createStripeSetupIntent(input: $input) {
+ error {
+ __typename
+ }
+ clientSecret
+ }
+}
+"""
+
+
+class CreateStripeSetupIntentTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"owner": "somename"}})
+ assert (
+ data["createStripeSetupIntent"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ def test_when_unauthorized(self):
+ other_owner = OwnerFactory(username="other-user")
+ data = self.gql_request(
+ query,
+ owner=self.owner,
+ variables={"input": {"owner": other_owner.username}},
+ )
+ assert (
+ data["createStripeSetupIntent"]["error"]["__typename"]
+ == "UnauthorizedError"
+ )
+
+ @patch("services.billing.stripe.SetupIntent.create")
+ def test_when_validation_error(self, setup_intent_create_mock):
+ setup_intent_create_mock.side_effect = Exception("Some error")
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
+ )
+ assert (
+ data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
+ )
+
+ def test_when_owner_not_found(self):
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"owner": "nonexistent-user"}}
+ )
+ assert (
+ data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
+ )
+
+ @patch("services.billing.stripe.SetupIntent.create")
+ def test_success(self, setup_intent_create_mock):
+ setup_intent_create_mock.return_value = {"client_secret": "test-client-secret"}
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
+ )
+ assert data["createStripeSetupIntent"]["clientSecret"] == "test-client-secret"
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_create_user_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_create_user_token.py
new file mode 100644
index 0000000000..721ee15f25
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_create_user_token.py
@@ -0,0 +1,50 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import UserToken
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: CreateUserTokenInput!) {
+ createUserToken(input: $input) {
+ error {
+ __typename
+ }
+ fullToken
+ token {
+ id
+ type
+ name
+ lastFour
+ }
+ }
+}
+"""
+
+
+class CreateApiTokenTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"name": "test"}})
+ assert data["createUserToken"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_authenticated(self):
+ name = "test"
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"name": name}}
+ )
+ user_token = self.owner.user_tokens.filter(name=name).first()
+ assert user_token
+
+ assert data["createUserToken"] == {
+ "token": {
+ "id": str(user_token.external_id),
+ "name": name,
+ "type": UserToken.TokenType.API.value,
+ "lastFour": str(user_token.token)[-4:],
+ },
+ "fullToken": str(user_token.token),
+ "error": None,
+ }
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_delete_component_measurements.py b/apps/codecov-api/graphql_api/tests/mutation/test_delete_component_measurements.py
new file mode 100644
index 0000000000..a01417e4f8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_delete_component_measurements.py
@@ -0,0 +1,150 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: DeleteComponentMeasurementsInput!) {
+ deleteComponentMeasurements(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class DeleteComponentMeasurementsTest(GraphQLTestHelper, TestCase):
+ @patch(
+ "core.commands.component.interactors.delete_component_measurements.DeleteComponentMeasurementsInteractor.execute"
+ )
+ def test_delete_component_measurements(self, execute_mock):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "componentId": "test-component",
+ }
+ },
+ )
+
+ assert data == {"deleteComponentMeasurements": None}
+
+ execute_mock.assert_called_once_with(
+ owner_username="test-owner",
+ repo_name="test-repo",
+ component_id="test-component",
+ )
+
+ @patch(
+ "core.commands.component.interactors.delete_component_measurements.DeleteComponentMeasurementsInteractor.execute"
+ )
+ def test_delete_component_measurements_unauthenticated(self, execute_mock):
+ execute_mock.side_effect = Unauthenticated()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "componentId": "test-component",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteComponentMeasurements": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ @patch(
+ "core.commands.component.interactors.delete_component_measurements.DeleteComponentMeasurementsInteractor.execute"
+ )
+ def test_delete_component_measurements_unauthorized(self, execute_mock):
+ execute_mock.side_effect = Unauthorized()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "componentId": "test-component",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteComponentMeasurements": {
+ "error": {
+ "__typename": "UnauthorizedError",
+ "message": "You are not authorized",
+ }
+ }
+ }
+
+ @patch(
+ "core.commands.component.interactors.delete_component_measurements.DeleteComponentMeasurementsInteractor.execute"
+ )
+ def test_delete_component_measurements_validation_error(self, execute_mock):
+ execute_mock.side_effect = ValidationError("test error")
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "componentId": "test-component",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteComponentMeasurements": {
+ "error": {"__typename": "ValidationError", "message": "test error"}
+ }
+ }
+
+ @patch(
+ "core.commands.component.interactors.delete_component_measurements.DeleteComponentMeasurementsInteractor.execute"
+ )
+ def test_delete_component_measurements_not_found(self, execute_mock):
+ execute_mock.side_effect = NotFound()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "componentId": "test-component",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteComponentMeasurements": {
+ "error": {
+ "__typename": "NotFoundError",
+ "message": "Cant find the requested resource",
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_delete_flag.py b/apps/codecov-api/graphql_api/tests/mutation/test_delete_flag.py
new file mode 100644
index 0000000000..a5649c8347
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_delete_flag.py
@@ -0,0 +1,140 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from codecov.commands.exceptions import (
+ NotFound,
+ Unauthenticated,
+ Unauthorized,
+ ValidationError,
+)
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: DeleteFlagInput!) {
+ deleteFlag(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class DeleteFlagTest(GraphQLTestHelper, TestCase):
+ @patch("core.commands.flag.interactors.delete_flag.DeleteFlagInteractor.execute")
+ def test_delete_flag(self, execute_mock):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "flagName": "test-flag",
+ }
+ },
+ )
+
+ assert data == {"deleteFlag": None}
+
+ execute_mock.assert_called_once_with(
+ owner_username="test-owner",
+ repo_name="test-repo",
+ flag_name="test-flag",
+ )
+
+ @patch("core.commands.flag.interactors.delete_flag.DeleteFlagInteractor.execute")
+ def test_delete_flag_unauthenticated(self, execute_mock):
+ execute_mock.side_effect = Unauthenticated()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "flagName": "test-flag",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteFlag": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ @patch("core.commands.flag.interactors.delete_flag.DeleteFlagInteractor.execute")
+ def test_delete_flag_unauthorized(self, execute_mock):
+ execute_mock.side_effect = Unauthorized()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "flagName": "test-flag",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteFlag": {
+ "error": {
+ "__typename": "UnauthorizedError",
+ "message": "You are not authorized",
+ }
+ }
+ }
+
+ @patch("core.commands.flag.interactors.delete_flag.DeleteFlagInteractor.execute")
+ def test_delete_flag_validation_error(self, execute_mock):
+ execute_mock.side_effect = ValidationError("test error")
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "flagName": "test-flag",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteFlag": {
+ "error": {"__typename": "ValidationError", "message": "test error"}
+ }
+ }
+
+ @patch("core.commands.flag.interactors.delete_flag.DeleteFlagInteractor.execute")
+ def test_delete_flag_not_found(self, execute_mock):
+ execute_mock.side_effect = NotFound()
+
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "ownerUsername": "test-owner",
+ "repoName": "test-repo",
+ "flagName": "test-flag",
+ }
+ },
+ )
+
+ assert data == {
+ "deleteFlag": {
+ "error": {
+ "__typename": "NotFoundError",
+ "message": "Cant find the requested resource",
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_delete_session.py b/apps/codecov-api/graphql_api/tests/mutation/test_delete_session.py
new file mode 100644
index 0000000000..a6e7df746d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_delete_session.py
@@ -0,0 +1,82 @@
+from django.contrib import auth
+from django.test import TransactionTestCase
+from django.utils import timezone
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, UserFactory
+
+from codecov_auth.models import DjangoSession, Session
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: DeleteSessionInput!) {
+ deleteSession(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class DeleteSessionTestCase(GraphQLTestHelper, TransactionTestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ user = self.user = UserFactory()
+ self.owner.user = user
+ self.owner.save()
+
+ # clear pre-existing login sessions, as some testcase seems to leak these
+ DjangoSession.objects.all().delete()
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"sessionid": 1}})
+ assert data["deleteSession"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_when_authenticated(self):
+ login_query = "{ me { user { username }} }"
+ self.gql_request(login_query, owner=self.owner)
+
+ user = auth.get_user(self.client)
+ assert user.is_authenticated
+
+ django_session_id = DjangoSession.objects.all()
+ assert len(django_session_id) == 1
+
+ django_session_id = django_session_id[0]
+
+ sessionid = Session.objects.create(
+ lastseen=timezone.now(),
+ useragent="Firefox",
+ ip="0.0.0.0",
+ login_session=django_session_id,
+ type=Session.SessionType.LOGIN,
+ owner=self.owner,
+ ).sessionid
+
+ self.gql_request(
+ query, owner=self.owner, variables={"input": {"sessionid": sessionid}}
+ )
+ assert len(Session.objects.filter(sessionid=sessionid)) == 0
+
+ def test_when_authenticated_session_not_valid(self):
+ login_query = "{ me { user { username }} }"
+ self.gql_request(login_query, owner=self.owner)
+
+ user = auth.get_user(self.client)
+ assert user.is_authenticated
+
+ django_session_id = DjangoSession.objects.all()
+ assert len(django_session_id) == 1
+
+ django_session_id = django_session_id[0]
+
+ Session.objects.create(
+ lastseen=timezone.now(),
+ useragent="Firefox",
+ ip="0.0.0.0",
+ login_session=django_session_id,
+ type=Session.SessionType.LOGIN,
+ owner=self.owner,
+ ).sessionid
+
+ self.gql_request(query, owner=self.owner, variables={"input": {"sessionid": 0}})
+ assert len(Session.objects.all()) == 1
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_encode_secret_string.py b/apps/codecov-api/graphql_api/tests/mutation/test_encode_secret_string.py
new file mode 100644
index 0000000000..e7f50c4393
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_encode_secret_string.py
@@ -0,0 +1,43 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.encryption.yaml_secret import yaml_secret_encryptor
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: EncodeSecretStringInput!) {
+ encodeSecretString(input: $input) {
+ value
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class TestEncodeSecretString(TestCase, GraphQLTestHelper):
+ def _request(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={"input": {"repoName": "test-repo", "value": "token-1"}},
+ )
+ return data["encodeSecretString"]["value"]
+
+ def setUp(self):
+ self.org = OwnerFactory(username="test-org")
+ self.repo = RepositoryFactory(
+ name="test-repo",
+ author=self.org,
+ private=True,
+ )
+ self.owner = OwnerFactory(permission=[self.repo.pk])
+
+ def test_encoded_secret_string(self):
+ res = self._request()
+ check_encryptor = yaml_secret_encryptor
+ assert "token-1" in check_encryptor.decode(res[7:])
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_erase_repository.py b/apps/codecov-api/graphql_api/tests/mutation/test_erase_repository.py
new file mode 100644
index 0000000000..5c69e7d47c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_erase_repository.py
@@ -0,0 +1,126 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: EraseRepositoryInput!) {
+ eraseRepository(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class EraseRepositoryTests(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.non_admin_user = OwnerFactory(organizations=[self.org.ownerid])
+ self.admin_user = OwnerFactory(organizations=[self.org.ownerid])
+ self.org.add_admin(self.admin_user)
+
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", active=True)
+
+ def test_when_authenticated(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ }
+ },
+ )
+
+ assert data == {"eraseRepository": None}
+
+ def test_when_validation_error_repo_not_found(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "DNE",
+ }
+ },
+ )
+ assert data["eraseRepository"]["error"]["__typename"] == "ValidationError"
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "repoName": "DNE",
+ }
+ },
+ )
+ assert data["eraseRepository"]["error"]["__typename"] == "UnauthenticatedError"
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.is_admin_owner")
+ def test_when_not_self_hosted_admin(self, is_admin_owner):
+ is_admin_owner.return_value = False
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ }
+ },
+ )
+
+ assert data["eraseRepository"]["error"]["__typename"] == "UnauthorizedError"
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.is_admin_owner")
+ def test_when_self_hosted_admin(self, is_admin_owner):
+ is_admin_owner.return_value = True
+
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ }
+ },
+ )
+
+ assert data == {"eraseRepository": None}
+
+ def test_when_other_admin(self):
+ data = self.gql_request(
+ query,
+ owner=self.admin_user,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "gazebo",
+ }
+ },
+ )
+
+ assert data == {"eraseRepository": None}
+
+ def test_when_not_other_admin(self):
+ data = self.gql_request(
+ query,
+ owner=self.non_admin_user,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "gazebo",
+ }
+ },
+ )
+
+ assert data["eraseRepository"]["error"]["__typename"] == "UnauthorizedError"
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_token.py
new file mode 100644
index 0000000000..8bfd8f7675
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_token.py
@@ -0,0 +1,104 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: RegenerateRepositoryTokenInput!) {
+ regenerateRepositoryToken(input: $input) {
+ token
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class RegeneratRepositoryTokenTests(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", active=True)
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ "owner": "codecov",
+ "tokenType": "PROFILING",
+ }
+ },
+ )
+ assert (
+ data["regenerateRepositoryToken"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ def test_when_validation_error_repo_not_viewable(self):
+ random_user = OwnerFactory(organizations=[self.org.ownerid])
+ data = self.gql_request(
+ query,
+ owner=random_user,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ "owner": "codecov",
+ "tokenType": "PROFILING",
+ }
+ },
+ )
+ assert (
+ data["regenerateRepositoryToken"]["error"]["__typename"]
+ == "ValidationError"
+ )
+
+ def test_when_authenticated_regenerate_staticanalysis_token(self):
+ user = OwnerFactory(
+ organizations=[self.org.ownerid], permission=[self.repo.repoid]
+ )
+ RepositoryTokenFactory(
+ repository=self.repo, key="random", token_type="static_analysis"
+ )
+ data = self.gql_request(
+ query,
+ owner=user,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "gazebo",
+ "tokenType": "STATIC_ANALYSIS",
+ }
+ },
+ )
+ newToken = data["regenerateRepositoryToken"]["token"]
+ assert newToken != "random"
+ assert len(newToken) == 40
+
+ def test_when_authenticated_regenerate_upload_token(self):
+ user = OwnerFactory(
+ organizations=[self.org.ownerid], permission=[self.repo.repoid]
+ )
+ RepositoryTokenFactory(repository=self.repo, key="random", token_type="upload")
+ data = self.gql_request(
+ query,
+ owner=user,
+ variables={
+ "input": {
+ "owner": "codecov",
+ "repoName": "gazebo",
+ "tokenType": "UPLOAD",
+ }
+ },
+ )
+ newToken = data["regenerateRepositoryToken"]["token"]
+ assert newToken != "random"
+ assert len(newToken) == 40
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_upload_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_upload_token.py
new file mode 100644
index 0000000000..b1e74b61b1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_regenerate_repository_upload_token.py
@@ -0,0 +1,54 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: RegenerateRepositoryUploadTokenInput!) {
+ regenerateRepositoryUploadToken(input: $input) {
+ token
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class RegenerateRepositoryUploadTokenTests(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo")
+ self.old_repo_token = self.repo.upload_token
+
+ def test_when_authenticated_updates_token(self):
+ user = OwnerFactory(
+ organizations=[self.org.ownerid], permission=[self.repo.repoid]
+ )
+
+ data = self.gql_request(
+ query,
+ owner=user,
+ variables={"input": {"repoName": "gazebo", "owner": "codecov"}},
+ )
+
+ assert data["regenerateRepositoryUploadToken"]["token"] != self.old_repo_token
+
+ def test_when_validation_error_repo_not_found(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "DNE",
+ "owner": "codecov",
+ }
+ },
+ )
+ assert (
+ data["regenerateRepositoryUploadToken"]["error"]["__typename"]
+ == "ValidationError"
+ )
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_regenrate_org_upload_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_regenrate_org_upload_token.py
new file mode 100644
index 0000000000..0819539699
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_regenrate_org_upload_token.py
@@ -0,0 +1,49 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: RegenerateOrgUploadTokenInput!) {
+ regenerateOrgUploadToken(input: $input) {
+ orgUploadToken
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class RegenerateOrgUploadToken(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(
+ username="codecov", plan="users-enterprisem", service="github"
+ )
+
+ def test_when_unauthenticated_error(self):
+ data = self.gql_request(query, variables={"input": {"owner": "codecov"}})
+ assert (
+ data["regenerateOrgUploadToken"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ def test_when_validation_error(self):
+ owner = OwnerFactory(name="rula")
+ data = self.gql_request(
+ query,
+ owner=owner,
+ variables={"input": {"owner": "random"}},
+ )
+ assert (
+ data["regenerateOrgUploadToken"]["error"]["__typename"] == "ValidationError"
+ )
+
+ def test_when_authenticated_regenerate_token(self):
+ data = self.gql_request(
+ query,
+ owner=self.owner,
+ variables={"input": {"owner": "codecov"}},
+ )
+ newToken = data["regenerateOrgUploadToken"]["orgUploadToken"]
+ assert newToken
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_revoke_user_token.py b/apps/codecov-api/graphql_api/tests/mutation/test_revoke_user_token.py
new file mode 100644
index 0000000000..0c3d020e60
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_revoke_user_token.py
@@ -0,0 +1,36 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ UserTokenFactory,
+)
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: RevokeUserTokenInput!) {
+ revokeUserToken(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class RevokeUserTokenTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"tokenid": "testing"}})
+ assert data["revokeUserToken"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_authenticated(self):
+ user_token = UserTokenFactory(owner=self.owner)
+ tokenid = str(user_token.external_id)
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"tokenid": tokenid}}
+ )
+ assert data["revokeUserToken"] is None
+ deleted_user_token = self.owner.user_tokens.filter(external_id=tokenid).first()
+ assert deleted_user_token is None
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_save_okta_config.py b/apps/codecov-api/graphql_api/tests/mutation/test_save_okta_config.py
new file mode 100644
index 0000000000..7d85232b69
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_save_okta_config.py
@@ -0,0 +1,59 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import AccountFactory, OwnerFactory
+
+from codecov_auth.models import OktaSettings
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: SaveOktaConfigInput!) {
+ saveOktaConfig(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class SaveOktaConfigTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.current_user = OwnerFactory(username="codecov-user")
+ self.owner = OwnerFactory(
+ username="codecov-owner",
+ admins=[self.current_user.ownerid],
+ account=AccountFactory(),
+ )
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "clientId": "some-client-id",
+ "clientSecret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "orgUsername": self.owner.username,
+ }
+ },
+ )
+ assert data["saveOktaConfig"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_when_authenticated(self):
+ data = self.gql_request(
+ query,
+ owner=self.owner,
+ variables={
+ "input": {
+ "clientId": "some-client-id",
+ "clientSecret": "some-client-secret",
+ "url": "https://okta.example.com",
+ "enabled": True,
+ "enforced": True,
+ "orgUsername": self.owner.username,
+ }
+ },
+ )
+ assert OktaSettings.objects.filter(account=self.owner.account).exists()
+ assert data["saveOktaConfig"] is None
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_save_sentry_state.py b/apps/codecov-api/graphql_api/tests/mutation/test_save_sentry_state.py
new file mode 100644
index 0000000000..d7713e7363
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_save_sentry_state.py
@@ -0,0 +1,68 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+from services.sentry import SentryInvalidStateError, SentryUserAlreadyExistsError
+
+query = """
+ mutation($input: SaveSentryStateInput!) {
+ saveSentryState(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+@patch("services.sentry.save_sentry_state")
+class SaveSentryStateMutationTest(GraphQLTestHelper, TestCase):
+ def _request(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={"input": {"state": "test-state"}},
+ owner=owner,
+ )
+
+ def test_unauthenticated(self, save_sentry_state):
+ assert self._request() == {
+ "saveSentryState": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_invalid_state(self, save_sentry_state):
+ save_sentry_state.side_effect = SentryInvalidStateError()
+ assert self._request(owner=OwnerFactory()) == {
+ "saveSentryState": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "Invalid state",
+ }
+ }
+ }
+
+ def test_sentry_user_already_exists(self, save_sentry_state):
+ save_sentry_state.side_effect = SentryUserAlreadyExistsError()
+ assert self._request(owner=OwnerFactory()) == {
+ "saveSentryState": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "Invalid Sentry user",
+ }
+ }
+ }
+
+ def test_authenticated(self, save_sentry_state):
+ owner = OwnerFactory()
+ assert self._request(owner=owner) == {"saveSentryState": None}
+
+ save_sentry_state.assert_called_once_with(owner, "test-state")
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_save_terms_agreement.py b/apps/codecov-api/graphql_api/tests/mutation/test_save_terms_agreement.py
new file mode 100644
index 0000000000..a4d854066e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_save_terms_agreement.py
@@ -0,0 +1,85 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: SaveTermsAgreementInput!) {
+ saveTermsAgreement(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class SaveTermsAgreementMutationTest(GraphQLTestHelper, TestCase):
+ def _request_deprecated(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={"input": {"termsAgreement": True, "customerIntent": "Business"}},
+ owner=owner,
+ )
+
+ def _request_invalid_customer_intent_deprecated(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={"input": {"termsAgreement": True, "customerIntent": "invalid"}},
+ owner=owner,
+ )
+
+ def _request(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={
+ "input": {
+ "termsAgreement": True,
+ "businessEmail": "something@email.com",
+ "name": "codecov-user",
+ }
+ },
+ owner=owner,
+ )
+
+ def test_invalid_customer_intent_deprecated(self):
+ owner = OwnerFactory()
+ assert self._request_invalid_customer_intent_deprecated(owner=owner) == {
+ "saveTermsAgreement": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "Invalid customer intent provided",
+ }
+ }
+ }
+
+ def test_unauthenticated_deprecated(self):
+ assert self._request_deprecated() == {
+ "saveTermsAgreement": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_authenticated_deprecated(self):
+ owner = OwnerFactory()
+ assert self._request_deprecated(owner=owner) == {"saveTermsAgreement": None}
+
+ def test_unauthenticated(self):
+ assert self._request() == {
+ "saveTermsAgreement": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_authenticated(self):
+ owner = OwnerFactory()
+ assert self._request(owner=owner) == {"saveTermsAgreement": None}
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_set_upload_token_required.py b/apps/codecov-api/graphql_api/tests/mutation/test_set_upload_token_required.py
new file mode 100644
index 0000000000..fa729c9507
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_set_upload_token_required.py
@@ -0,0 +1,101 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: SetUploadTokenRequiredInput!) {
+ setUploadTokenRequired(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class SetUploadTokenRequiredTests(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+
+ def test_when_authenticated_updates_upload_token_required(self):
+ user = OwnerFactory(
+ organizations=[self.org.ownerid],
+ )
+ self.org.admins = [user.ownerid]
+ self.org.save()
+
+ data = self.gql_request(
+ query,
+ owner=user,
+ variables={
+ "input": {"orgUsername": "codecov", "uploadTokenRequired": True}
+ },
+ )
+
+ assert data["setUploadTokenRequired"] is None
+
+ def test_when_validation_error_org_not_found(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "orgUsername": "non_existent_org",
+ "uploadTokenRequired": True,
+ }
+ },
+ )
+ assert (
+ data["setUploadTokenRequired"]["error"]["__typename"] == "ValidationError"
+ )
+
+ def test_when_unauthorized_non_admin(self):
+ non_admin_user = OwnerFactory(
+ organizations=[self.org.ownerid],
+ )
+
+ data = self.gql_request(
+ query,
+ owner=non_admin_user,
+ variables={
+ "input": {"orgUsername": "codecov", "uploadTokenRequired": True}
+ },
+ )
+
+ assert (
+ data["setUploadTokenRequired"]["error"]["__typename"] == "UnauthorizedError"
+ )
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {"orgUsername": "codecov", "uploadTokenRequired": True}
+ },
+ )
+
+ assert (
+ data["setUploadTokenRequired"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ def test_when_not_part_of_org(self):
+ non_part_of_org_user = OwnerFactory(
+ organizations=[self.org.ownerid],
+ )
+
+ data = self.gql_request(
+ query,
+ owner=non_part_of_org_user,
+ variables={
+ "input": {"orgUsername": "codecov", "uploadTokenRequired": True}
+ },
+ )
+
+ assert (
+ data["setUploadTokenRequired"]["error"]["__typename"] == "UnauthorizedError"
+ )
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_set_yaml_on_owner.py b/apps/codecov-api/graphql_api/tests/mutation/test_set_yaml_on_owner.py
new file mode 100644
index 0000000000..ed4f153b13
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_set_yaml_on_owner.py
@@ -0,0 +1,40 @@
+import asyncio
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: SetYamlOnOwnerInput!) {
+ setYamlOnOwner(input: $input) {
+ error {
+ __typename
+ }
+ owner {
+ username
+ }
+ }
+}
+"""
+
+
+class SetYamlOnOwnerMutationTest(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ asyncio.set_event_loop(asyncio.new_event_loop())
+
+ @patch("codecov_auth.commands.owner.owner.OwnerCommands.set_yaml_on_owner")
+ def test_mutation_dispatch_to_command(self, command_mock):
+ # mock the command to return a Future which resolved to the owner
+ f = asyncio.Future()
+ f.set_result(self.owner)
+ command_mock.return_value = f
+ input = {
+ "username": self.owner.username,
+ "yaml": "codecov:\n require_ci_to_pass: true",
+ }
+ data = self.gql_request(query, owner=self.owner, variables={"input": input})
+ command_mock.assert_called_once_with(input["username"], input["yaml"])
+ assert data["setYamlOnOwner"]["owner"]["username"] == self.owner.username
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_start_trial.py b/apps/codecov-api/graphql_api/tests/mutation/test_start_trial.py
new file mode 100644
index 0000000000..92e3ba5330
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_start_trial.py
@@ -0,0 +1,43 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: StartTrialInput!) {
+ startTrial(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class StartTrialMutationTest(GraphQLTestHelper, TestCase):
+ def _request(self, owner=None, org_username: str = None):
+ return self.gql_request(
+ query,
+ variables={"input": {"orgUsername": org_username}},
+ owner=owner,
+ )
+
+ def test_unauthenticated(self):
+ assert self._request() == {
+ "startTrial": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_authenticated(self):
+ owner = OwnerFactory()
+ owner.save()
+ assert self._request(owner=owner, org_username=owner.username) == {
+ "startTrial": None
+ }
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_store_codecov_metrics.py b/apps/codecov-api/graphql_api/tests/mutation/test_store_codecov_metrics.py
new file mode 100644
index 0000000000..7976d8d70a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_store_codecov_metrics.py
@@ -0,0 +1,108 @@
+from django.test import TestCase
+from shared.django_apps.codecov_metrics.models import UserOnboardingLifeCycleMetrics
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: StoreEventMetricsInput!) {
+ storeEventMetric(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class StoreEventMetricMutationTest(GraphQLTestHelper, TestCase):
+ def _request(self, org_username: str, event: str, json_payload: str, owner=None):
+ return self.gql_request(
+ query,
+ variables={
+ "input": {
+ "orgUsername": org_username,
+ "eventName": event,
+ "jsonPayload": json_payload,
+ }
+ },
+ owner=owner,
+ )
+
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_unauthenticated(self):
+ response = self._request(
+ org_username="codecov-user",
+ event="VISITED_PAGE",
+ json_payload='{"key": "value"}',
+ )
+ assert response == {
+ "storeEventMetric": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ def test_authenticated_inserts_into_db(self):
+ self._request(
+ org_username="codecov-user",
+ event="VISITED_PAGE",
+ json_payload='{"some-key": "some-value"}',
+ owner=self.owner,
+ )
+ metric = UserOnboardingLifeCycleMetrics.objects.filter(
+ event="VISITED_PAGE"
+ ).first()
+ self.assertIsNotNone(metric)
+ self.assertEqual(metric.additional_data, {"some-key": "some-value"})
+
+ def test_invalid_org(self):
+ response = self._request(
+ org_username="invalid_org",
+ event="VISITED_PAGE",
+ json_payload='{"key": "value"}',
+ owner=self.owner,
+ )
+ assert response == {
+ "storeEventMetric": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "Cannot find owner record in the database",
+ }
+ }
+ }
+
+ def test_invalid_event(self):
+ self._request(
+ org_username="codecov-user",
+ event="INVALID_EVENT",
+ json_payload='{"key": "value"}',
+ owner=self.owner,
+ )
+ metric = UserOnboardingLifeCycleMetrics.objects.filter(
+ event="INVALID_EVENT"
+ ).first()
+ self.assertIsNone(metric)
+
+ def test_invalid_json_string(self):
+ response = self._request(
+ org_username="codecov-user",
+ event="VISITED_PAGE",
+ json_payload="invalid-json",
+ owner=self.owner,
+ )
+ assert response == {
+ "storeEventMetric": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "Invalid JSON string",
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_update_bundle_cache_config.py b/apps/codecov-api/graphql_api/tests/mutation/test_update_bundle_cache_config.py
new file mode 100644
index 0000000000..53256f40f0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_update_bundle_cache_config.py
@@ -0,0 +1,80 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: ActivateMeasurementsInput!) {
+ activateMeasurements(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+query = """
+mutation UpdateBundleCacheConfig(
+ $owner: String!
+ $repoName: String!
+ $bundles: [BundleCacheConfigInput!]!
+) {
+ updateBundleCacheConfig(input: {
+ owner: $owner,
+ repoName: $repoName,
+ bundles: $bundles
+ }) {
+ results {
+ bundleName
+ isCached
+ cacheConfig
+ }
+ error {
+ __typename
+ ... on UnauthenticatedError {
+ message
+ }
+ ... on ValidationError {
+ message
+ }
+ }
+ }
+}
+"""
+
+
+class UpdateBundleCacheConfigTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "owner": "codecov",
+ "repoName": "test-repo",
+ "bundles": [{"bundleName": "pr_bundle1", "toggleCaching": True}],
+ },
+ )
+ assert (
+ data["updateBundleCacheConfig"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ @patch(
+ "core.commands.repository.interactors.update_bundle_cache_config.UpdateBundleCacheConfigInteractor.execute"
+ )
+ def test_when_authenticated(self, execute):
+ data = self.gql_request(
+ query,
+ owner=self.owner,
+ variables={
+ "owner": "codecov",
+ "repoName": "test-repo",
+ "bundles": [{"bundleName": "pr_bundle1", "toggleCaching": True}],
+ },
+ )
+ assert data == {"updateBundleCacheConfig": {"results": [], "error": None}}
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_update_default_organization.py b/apps/codecov-api/graphql_api/tests/mutation/test_update_default_organization.py
new file mode 100644
index 0000000000..caf79cbdaf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_update_default_organization.py
@@ -0,0 +1,39 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from codecov_auth.models import OwnerProfile
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: UpdateDefaultOrganizationInput!) {
+ updateDefaultOrganization(input: $input) {
+ error {
+ __typename
+ }
+ }
+}
+"""
+
+
+class UpdateProfileTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.default_organization_username = "sample-default-org-username"
+ self.default_organization = OwnerFactory(
+ username=self.default_organization_username, service="github"
+ )
+ self.owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ organizations=[self.default_organization.ownerid],
+ )
+
+ def test_when_authenticated(self):
+ self.gql_request(
+ query,
+ owner=self.owner,
+ variables={"input": {"username": self.default_organization_username}},
+ )
+ owner_profile: OwnerProfile = OwnerProfile.objects.filter(
+ owner_id=self.owner.ownerid
+ ).first()
+ assert owner_profile.default_org == self.default_organization
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_update_profile.py b/apps/codecov-api/graphql_api/tests/mutation/test_update_profile.py
new file mode 100644
index 0000000000..615e196e45
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_update_profile.py
@@ -0,0 +1,36 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: UpdateProfileInput!) {
+ updateProfile(input: $input) {
+ error {
+ __typename
+ }
+ me {
+ email
+ user {
+ name
+ }
+ }
+ }
+}
+"""
+
+
+class UpdateProfileTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(query, variables={"input": {"name": "yo"}})
+ assert data["updateProfile"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_when_authenticated(self):
+ name = "yo"
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": {"name": name}}
+ )
+ assert data["updateProfile"]["me"]["user"]["name"] == name
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_update_repository.py b/apps/codecov-api/graphql_api/tests/mutation/test_update_repository.py
new file mode 100644
index 0000000000..067879b486
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_update_repository.py
@@ -0,0 +1,107 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+mutation($input: UpdateRepositoryInput!) {
+ updateRepository(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+}
+"""
+
+repo_query = """{
+ me {
+ owner {
+ repository(name: "gazebo") {
+ ... on Repository {
+ activated
+ defaultBranch
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class UpdateRepositoryTests(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov", service="github")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", activated=False)
+
+ def test_when_authenticated_update_activated(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={"input": {"activated": True, "repoName": "gazebo"}},
+ )
+
+ repo_result = self.gql_request(
+ repo_query,
+ owner=self.org,
+ )
+ assert repo_result["me"]["owner"]["repository"]["activated"] == True
+
+ assert data == {"updateRepository": None}
+
+ def test_when_authenticated_update_branch(self):
+ BranchFactory.create(name="some other branch", repository=self.repo)
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={"input": {"branch": "some other branch", "repoName": "gazebo"}},
+ )
+
+ repo_result = self.gql_request(
+ repo_query,
+ owner=self.org,
+ )
+ assert (
+ repo_result["me"]["owner"]["repository"]["defaultBranch"]
+ == "some other branch"
+ )
+
+ assert data == {"updateRepository": None}
+
+ def test_when_authenticated_branch_does_not_exist(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={"input": {"branch": "Dne", "repoName": "gazebo"}},
+ )
+
+ assert data["updateRepository"]["error"]["__typename"] == "ValidationError"
+
+ def test_when_unauthenticated(self):
+ data = self.gql_request(
+ query,
+ variables={
+ "input": {
+ "repoName": "gazebo",
+ }
+ },
+ )
+ assert data["updateRepository"]["error"]["__typename"] == "UnauthenticatedError"
+
+ def test_when_validation_error_repo_not_found(self):
+ data = self.gql_request(
+ query,
+ owner=self.org,
+ variables={
+ "input": {
+ "repoName": "DNE",
+ }
+ },
+ )
+ assert data["updateRepository"]["error"]["__typename"] == "ValidationError"
diff --git a/apps/codecov-api/graphql_api/tests/mutation/test_update_self_hosted_settings.py b/apps/codecov-api/graphql_api/tests/mutation/test_update_self_hosted_settings.py
new file mode 100644
index 0000000000..e2b6b4bfad
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/mutation/test_update_self_hosted_settings.py
@@ -0,0 +1,68 @@
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+query = """
+ mutation($input: UpdateSelfHostedSettingsInput!) {
+ updateSelfHostedSettings(input: $input) {
+ error {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+"""
+
+
+class UpdateSelfHostedSettingsTest(GraphQLTestHelper, TestCase):
+ def _request(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={"input": {"shouldAutoActivate": True}},
+ owner=owner,
+ )
+
+ def _request_deactivate(self, owner=None):
+ return self.gql_request(
+ query,
+ variables={"input": {"shouldAutoActivate": False}},
+ owner=owner,
+ )
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_unauthenticated(self):
+ assert self._request() == {
+ "updateSelfHostedSettings": {
+ "error": {
+ "__typename": "UnauthenticatedError",
+ "message": "You are not authenticated",
+ }
+ }
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_authenticated_enable_autoactivation(self):
+ owner = OwnerFactory()
+ assert self._request(owner=owner) == {"updateSelfHostedSettings": None}
+
+ @override_settings(IS_ENTERPRISE=True)
+ def test_authenticate_disable_autoactivation(self):
+ owner = OwnerFactory()
+ assert self._request_deactivate(owner=owner) == {
+ "updateSelfHostedSettings": None
+ }
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_invalid_settings(self):
+ owner = OwnerFactory()
+ assert self._request(owner=owner) == {
+ "updateSelfHostedSettings": {
+ "error": {
+ "__typename": "ValidationError",
+ "message": "enable_autoactivation and disable_autoactivation are only available in self-hosted environments",
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_flag_filter__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_flag_filter__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_flag_filter__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_term_filter__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_term_filter__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_term_filter__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_testsuite_filter__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_testsuite_filter__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__analytics_testsuite_filter__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results__0.json
new file mode 100644
index 0000000000..b9504a0cb6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results__0.json
@@ -0,0 +1,97 @@
+[
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test4",
+ "testsuite": [
+ "testsuite4"
+ ],
+ "flags": [
+ "flag4"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-04T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test3",
+ "testsuite": [
+ "testsuite3"
+ ],
+ "flags": [
+ "flag3"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-03T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test2",
+ "testsuite": [
+ "testsuite2"
+ ],
+ "flags": [
+ "flag2"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-02T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_asc__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_asc__0.json
new file mode 100644
index 0000000000..71413d9177
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_asc__0.json
@@ -0,0 +1,97 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test2",
+ "testsuite": [
+ "testsuite2"
+ ],
+ "flags": [
+ "flag2"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-02T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test3",
+ "testsuite": [
+ "testsuite3"
+ ],
+ "flags": [
+ "flag3"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-03T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test4",
+ "testsuite": [
+ "testsuite4"
+ ],
+ "flags": [
+ "flag4"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-04T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ },
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after__0.json
new file mode 100644
index 0000000000..7f325cfb41
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test2",
+ "testsuite": [
+ "testsuite2"
+ ],
+ "flags": [
+ "flag2"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-02T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after_no_next__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after_no_next__0.json
new file mode 100644
index 0000000000..4205c87efc
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_first_1_after_no_next__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1__0.json
new file mode 100644
index 0000000000..4205c87efc
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before__0.json
new file mode 100644
index 0000000000..f40128b02a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test4",
+ "testsuite": [
+ "testsuite4"
+ ],
+ "flags": [
+ "flag4"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-04T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before_no_previous__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before_no_previous__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_asc_last_1_before_no_previous__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1__0.json
new file mode 100644
index 0000000000..4205c87efc
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after__0.json
new file mode 100644
index 0000000000..f40128b02a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test4",
+ "testsuite": [
+ "testsuite4"
+ ],
+ "flags": [
+ "flag4"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-04T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after_no_next__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after_no_next__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_first_1_after_no_next__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1__0.json
new file mode 100644
index 0000000000..7125723b64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test1",
+ "testsuite": [
+ "testsuite1"
+ ],
+ "flags": [
+ "flag1"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-01T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before__0.json
new file mode 100644
index 0000000000..7f325cfb41
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test2",
+ "testsuite": [
+ "testsuite2"
+ ],
+ "flags": [
+ "flag2"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-02T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before_no_previous__0.json b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before_no_previous__0.json
new file mode 100644
index 0000000000..4205c87efc
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/snapshots/analytics__TestAnalyticsTestCase__results_pagination_last_1_before_no_previous__0.json
@@ -0,0 +1,21 @@
+[
+ {
+ "name": "test5",
+ "testsuite": [
+ "testsuite5"
+ ],
+ "flags": [
+ "flag5"
+ ],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": "2024-01-05T00:00:00",
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0
+ }
+]
diff --git a/apps/codecov-api/graphql_api/tests/test_account.py b/apps/codecov-api/graphql_api/tests/test_account.py
new file mode 100644
index 0000000000..e0ea0f030c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_account.py
@@ -0,0 +1,233 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ AccountsUsersFactory,
+ OktaSettingsFactory,
+ OwnerFactory,
+)
+
+from .helper import GraphQLTestHelper
+
+
+class AccountTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.account = AccountFactory(
+ name="Test Account", plan_seat_count=10, free_seat_count=1
+ )
+ self.owner = OwnerFactory(
+ username="randomOwner", service="github", account=self.account
+ )
+ self.okta_settings = OktaSettingsFactory(
+ account=self.account,
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ )
+
+ def test_fetch_okta_config(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ oktaConfig {
+ clientId
+ clientSecret
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ data = result["owner"]["account"]
+ assert data["oktaConfig"]["clientId"] == "test-client-id"
+ assert data["oktaConfig"]["clientSecret"] == "test-client-secret"
+
+ def test_fetch_total_seat_count(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ totalSeatCount
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ seatCount = result["owner"]["account"]["totalSeatCount"]
+ assert seatCount == 11
+
+ def test_fetch_activated_user_count(self) -> None:
+ for _ in range(7):
+ AccountsUsersFactory(account=self.account)
+
+ query = """
+ query {
+ owner(username: "%s") {
+ account {
+ activatedUserCount
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ activatedUserCount = result["owner"]["account"]["activatedUserCount"]
+ assert activatedUserCount == 7
+
+ def test_fetch_organizations(self) -> None:
+ account = AccountFactory(name="account")
+ owner = OwnerFactory(
+ username="owner-0",
+ plan_activated_users=[],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-1",
+ plan_activated_users=[0],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-2",
+ plan_activated_users=[0, 1],
+ account=account,
+ )
+
+ query = """
+ query {
+ owner(username: "%s") {
+ account {
+ organizations(first: 20) {
+ edges {
+ node {
+ username
+ activatedUserCount
+ }
+ }
+ }
+ }
+ }
+ }
+ """ % (owner.username)
+
+ result = self.gql_request(query, owner=owner)
+
+ assert "errors" not in result
+
+ orgs = [
+ node["node"]["username"]
+ for node in result["owner"]["account"]["organizations"]["edges"]
+ ]
+
+ assert orgs == ["owner-0", "owner-1", "owner-2"]
+
+ def test_fetch_organizations_desc(self) -> None:
+ account = AccountFactory(name="account")
+ owner = OwnerFactory(
+ username="owner-0",
+ plan_activated_users=[],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-1",
+ plan_activated_users=[0],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-2",
+ plan_activated_users=[0, 1],
+ account=account,
+ )
+
+ query = """
+ query {
+ owner(username: "%s") {
+ account {
+ organizations(first: 20, orderingDirection: DESC) {
+ edges {
+ node {
+ username
+ activatedUserCount
+ }
+ }
+ }
+ }
+ }
+ }
+ """ % (owner.username)
+
+ result = self.gql_request(query, owner=owner)
+
+ assert "errors" not in result
+
+ orgs = [
+ node["node"]["username"]
+ for node in result["owner"]["account"]["organizations"]["edges"]
+ ]
+
+ assert orgs == ["owner-2", "owner-1", "owner-0"]
+
+ def test_fetch_organizations_pagination(self) -> None:
+ account = AccountFactory(name="account")
+ owner = OwnerFactory(
+ username="owner-0",
+ plan_activated_users=[],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-1",
+ plan_activated_users=[0],
+ account=account,
+ )
+ OwnerFactory(
+ username="owner-2",
+ plan_activated_users=[0, 1],
+ account=account,
+ )
+
+ query = """
+ query {
+ owner(username: "%s") {
+ account {
+ organizations(first: 2) {
+ edges {
+ node {
+ username
+ activatedUserCount
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ }
+ }
+ }
+ }
+ }
+ """ % (owner.username)
+
+ result = self.gql_request(query, owner=owner)
+
+ assert "errors" not in result
+
+ totalCount = result["owner"]["account"]["organizations"]["totalCount"]
+
+ assert totalCount == 3
+
+ orgs = [
+ node["node"]["username"]
+ for node in result["owner"]["account"]["organizations"]["edges"]
+ ]
+
+ assert orgs == ["owner-0", "owner-1"]
+
+ hasNextPage = result["owner"]["account"]["organizations"]["pageInfo"][
+ "hasNextPage"
+ ]
+ assert hasNextPage
diff --git a/apps/codecov-api/graphql_api/tests/test_billing.py b/apps/codecov-api/graphql_api/tests/test_billing.py
new file mode 100644
index 0000000000..5a9602925a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_billing.py
@@ -0,0 +1,73 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from stripe.api_resources import PaymentIntent, SetupIntent
+
+from .helper import GraphQLTestHelper
+
+
+class BillingTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(stripe_customer_id="test-customer-id")
+
+ def test_fetch_unverified_payment_methods(self):
+ query = """
+ query {
+ owner(username: "%s") {
+ billing {
+ unverifiedPaymentMethods {
+ paymentMethodId
+ hostedVerificationUrl
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ payment_intent = PaymentIntent.construct_from(
+ {
+ "payment_method": "pm_123",
+ "next_action": {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": "https://verify.stripe.com/1"
+ },
+ },
+ },
+ "fake_api_key",
+ )
+
+ setup_intent = SetupIntent.construct_from(
+ {
+ "payment_method": "pm_456",
+ "next_action": {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": "https://verify.stripe.com/2"
+ },
+ },
+ },
+ "fake_api_key",
+ )
+
+ with (
+ patch(
+ "services.billing.stripe.PaymentIntent.list"
+ ) as payment_intent_list_mock,
+ patch("services.billing.stripe.SetupIntent.list") as setup_intent_list_mock,
+ ):
+ payment_intent_list_mock.return_value.data = [payment_intent]
+ payment_intent_list_mock.return_value.has_more = False
+ setup_intent_list_mock.return_value.data = [setup_intent]
+ setup_intent_list_mock.return_value.has_more = False
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ data = result["owner"]["billing"]["unverifiedPaymentMethods"]
+ assert len(data) == 2
+ assert data[0]["paymentMethodId"] == "pm_123"
+ assert data[0]["hostedVerificationUrl"] == "https://verify.stripe.com/1"
+ assert data[1]["paymentMethodId"] == "pm_456"
+ assert data[1]["hostedVerificationUrl"] == "https://verify.stripe.com/2"
diff --git a/apps/codecov-api/graphql_api/tests/test_branch.py b/apps/codecov-api/graphql_api/tests/test_branch.py
new file mode 100644
index 0000000000..1e8f9405fa
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_branch.py
@@ -0,0 +1,1548 @@
+from datetime import datetime, timedelta
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.types import ReportTotals
+
+from services.components import Component
+
+from .helper import GraphQLTestHelper
+
+query_branch = """
+ query FetchBranch($org: String!, $repo: String!, $branch: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ %s
+ }
+ }
+ }
+ }
+ }
+"""
+
+query_files = """
+ query FetchFiles($org: String!, $repo: String!, $branch: String!, $path: String!, $filters: PathContentsFilters!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ head {
+ pathContents (path: $path, filters: $filters) {
+ __typename
+ ... on PathContents {
+ results {
+ __typename
+ name
+ path
+ hits
+ misses
+ partials
+ lines
+ percentCovered
+ }
+ }
+ ... on MissingHeadReport {
+ message
+ }
+ ... on MissingCoverage {
+ message
+ }
+ ... on UnknownPath {
+ message
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+query_files_connection = """
+ query FetchFiles($org: String!, $repo: String!, $branch: String!, $path: String!, $filters: PathContentsFilters!, $first: Int, $after: String, $last: Int, $before: String) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ head {
+ deprecatedPathContents (path: $path, filters: $filters, first: $first, after: $after, last: $last, before: $before) {
+ __typename
+ ... on PathContentConnection {
+ edges {
+ cursor
+ node {
+ __typename
+ name
+ path
+ hits
+ misses
+ partials
+ lines
+ percentCovered
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ ... on MissingHeadReport {
+ message
+ }
+ ... on MissingCoverage {
+ message
+ }
+ ... on UnknownPath {
+ message
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+
+class MockCoverage(object):
+ def __init__(self, coverage, hits, lines):
+ self.coverage = coverage
+ self.hits = hits
+ self.lines = lines
+
+
+class MockTotals(object):
+ def __init__(self):
+ self.totals = ReportTotals.default_totals()
+ self.totals.hits = 8
+ self.totals.lines = 10
+
+
+class MockFlag(object):
+ @property
+ def totals(self):
+ return MockTotals()
+
+
+class MockSession(object):
+ pass
+
+
+class MockFile:
+ def __init__(self, name: str):
+ self.name = name
+
+
+class MockReport(object):
+ def __init__(self):
+ self.sessions = {1: MockSession()}
+
+ def get(self, file):
+ return MockTotals()
+
+ @property
+ def files(self):
+ return [
+ "fileA.py",
+ "fileB.py",
+ "folder/fileB.py",
+ "folder/subfolder/fileC.py",
+ "folder/subfolder/fileD.py",
+ ]
+
+ def __iter__(self):
+ for name in self.files:
+ yield MockFile(name)
+
+ def get_flag_names(self):
+ return ["flag_a"]
+
+ @property
+ def flags(self):
+ return {"flag-a": MockFlag()}
+
+ def get_file_totals(self, path):
+ return MockTotals().totals
+
+ def filter(self, paths, flags):
+ return MockFilteredReport()
+
+ def sessions(self):
+ return [1, 2, 3]
+
+
+class MockFilteredReport(MockReport):
+ pass
+
+
+class MockNoFlagsReport(object):
+ def __init__(self):
+ self.sessions = {1: MockSession()}
+
+ @property
+ def flags(self):
+ return None
+
+ def get_flag_names(self):
+ return []
+
+
+class TestBranch(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", private=False)
+ self.head = CommitFactory(repository=self.repo, timestamp=datetime.now())
+ self.commit = CommitFactory(repository=self.repo)
+ self.branch = BranchFactory(
+ repository=self.repo,
+ head=self.head.commitid,
+ name="test1",
+ updatestamp=(datetime.now() + timedelta(1)),
+ )
+ self.branch_2 = BranchFactory(
+ repository=self.repo,
+ head=self.commit.commitid,
+ name="test2",
+ updatestamp=(datetime.now() + timedelta(2)),
+ )
+
+ def test_fetch_branch(self):
+ query = query_branch % "name, headSha, head { commitid }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data["owner"]["repository"]["branch"] == {
+ "name": self.branch.name,
+ "headSha": self.head.commitid,
+ "head": {
+ "commitid": self.head.commitid,
+ },
+ }
+
+ def test_fetch_branch_missing_commit(self):
+ self.head.delete()
+ query = query_branch % "name, headSha, head { commitid }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data["owner"]["repository"]["branch"] == {
+ "name": self.branch.name,
+ "headSha": self.branch.head,
+ "head": None,
+ }
+
+ def test_fetch_branches(self):
+ query_branches = """{
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ branches {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ variables = {"org": self.org.username, "repo": self.repo.name}
+ query = query_branches % (self.org.username, self.repo.name)
+ data = self.gql_request(query, variables=variables)
+ branches = data["owner"]["repository"]["branches"]["edges"]
+ assert isinstance(branches, list)
+ assert len(branches) == 3
+ assert branches == [
+ {"node": {"name": "test2"}},
+ {"node": {"name": "test1"}},
+ {"node": {"name": "main"}},
+ ]
+
+ def test_fetch_branches_with_filters(self):
+ query_branches = """{
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ branches (filters: {searchValue: "%s"}) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ variables = {"org": self.org.username, "repo": self.repo.name}
+ query = query_branches % (self.org.username, self.repo.name, "test2")
+ data = self.gql_request(query, variables=variables)
+ branches = data["owner"]["repository"]["branches"]["edges"]
+ assert isinstance(branches, list)
+ assert len(branches) == 1
+ assert branches == [
+ {"node": {"name": "test2"}},
+ ]
+
+ @override_settings(DEBUG=True)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_with_no_report(self, report_mock):
+ report_mock.return_value = None
+ commit_without_report = CommitFactory(repository=self.repo)
+ branch = BranchFactory(
+ repository=self.repo,
+ head=commit_without_report.commitid,
+ name="branch-two",
+ updatestamp=(datetime.now() + timedelta(1)),
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": branch.name,
+ "path": "",
+ "filters": {},
+ }
+ data = self.gql_request(query_files, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "MissingHeadReport",
+ "message": "Missing head report",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_with_files(self, report_mock):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {
+ "ordering": {
+ "direction": "DESC",
+ "parameter": "NAME",
+ }
+ },
+ }
+ report_mock.return_value = MockReport()
+
+ data = self.gql_request(query_files, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentDir",
+ "name": "folder",
+ "path": "folder",
+ "hits": 24,
+ "misses": 0,
+ "partials": 0,
+ "lines": 30,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_with_files_and_path_prefix(
+ self,
+ report_mock,
+ ):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "folder",
+ "filters": {
+ "ordering": {
+ "direction": "ASC",
+ "parameter": "HITS",
+ }
+ },
+ }
+ report_mock.return_value = MockReport()
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "folder/fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentDir",
+ "name": "subfolder",
+ "path": "folder/subfolder",
+ "hits": 16,
+ "misses": 0,
+ "partials": 0,
+ "lines": 20,
+ "percentCovered": 80.0,
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_with_files_and_search_value_case_insensitive(
+ self,
+ report_mock,
+ ):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {
+ "searchValue": "fileB",
+ },
+ }
+ report_mock.return_value = MockReport()
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "folder/fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_with_files_and_list_display_type(self, report_mock):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {
+ "displayType": "LIST",
+ },
+ }
+ report_mock.return_value = MockReport()
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "folder/fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileC.py",
+ "path": "folder/subfolder/fileC.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileD.py",
+ "path": "folder/subfolder/fileD.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+ assert len(
+ data["owner"]["repository"]["branch"]["head"]["pathContents"]["results"]
+ ) == len(report_mock.return_value.files)
+
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_missing_coverage(
+ self, report_mock, paths_mock, provider_path_exists_mock
+ ):
+ report_mock.return_value = MockReport()
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = True
+
+ data = self.gql_request(
+ query_files,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "invalid",
+ "filters": {},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "MissingCoverage",
+ "message": "missing coverage for path: invalid",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_unknown_path(
+ self, report_mock, paths_mock, provider_path_exists_mock
+ ):
+ report_mock.return_value = MockReport()
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = False
+
+ data = self.gql_request(
+ query_files,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "invalid",
+ "filters": {},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "UnknownPath",
+ "message": "path does not exist: invalid",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_unknown_flags_no_flags(self, report_mock):
+ report_mock.return_value = MockNoFlagsReport()
+
+ data = self.gql_request(
+ query_files,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"flags": ["test-123"]},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "UnknownFlags",
+ "message": "No coverage with chosen flags: ['test-123']",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_component_filter_missing_coverage(
+ self, report_mock, commit_components_mock
+ ):
+ components = ["ComponentThree"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components},
+ }
+
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["fileA.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "c2",
+ "name": "ComponentTwo",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ }
+ ),
+ ]
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "MissingCoverage",
+ "message": f"missing coverage for report with components: {components}",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.ReportPaths.files", new_callable=PropertyMock)
+ def test_fetch_path_contents_component_filter_has_coverage(
+ self, files_mock, report_mock, commit_components_mock, filtered_mock
+ ):
+ files_mock.return_value = [
+ "folder/fileB.py",
+ "folder/subfolder/fileC.py",
+ "folder/subfolder/fileD.py",
+ ]
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["fileA.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "c2",
+ "name": "ComponentTwo",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockReport()
+
+ components = ["Global"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components},
+ }
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentDir",
+ "hits": 24,
+ "lines": 30,
+ "misses": 0,
+ "name": "folder",
+ "partials": 0,
+ "path": "folder",
+ "percentCovered": 80.0,
+ }
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.ReportPaths.files", new_callable=PropertyMock)
+ def test_fetch_path_contents_component_and_flag_filters(
+ self,
+ files_mock,
+ report_mock,
+ commit_components_mock,
+ filtered_mock,
+ ):
+ files_mock.return_value = ["fileA.py"]
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "unit",
+ "name": "unit",
+ "paths": ["fileA.py"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "integration",
+ "name": "integration",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockReport()
+
+ query_files = """
+ query FetchFiles($org: String!, $repo: String!, $branch: String!, $path: String!, $filters: PathContentsFilters!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ head {
+ pathContents (path: $path, filters: $filters) {
+ __typename
+ ... on PathContents {
+ results {
+ __typename
+ name
+ path
+ }
+ }
+ ... on MissingCoverage {
+ message
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ components, flags = ["unit"], ["flag-a"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components, "flags": flags},
+ }
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ }
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.ReportPaths.files", new_callable=PropertyMock)
+ def test_fetch_path_contents_component_and_flag_filters_unknown_flags(
+ self, files_mock, report_mock, commit_components_mock, filtered_mock
+ ):
+ files_mock.return_value = ["fileA.py"]
+ report_mock.return_value = MockNoFlagsReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "unit",
+ "name": "unit",
+ "paths": ["fileA.py"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "integration",
+ "name": "integration",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockNoFlagsReport()
+
+ query_files = """
+ query FetchFiles($org: String!, $repo: String!, $branch: String!, $path: String!, $filters: PathContentsFilters!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ head {
+ pathContents (path: $path, filters: $filters) {
+ __typename
+ ... on PathContents {
+ results {
+ __typename
+ name
+ path
+ }
+ }
+ ... on MissingCoverage {
+ message
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ components, flags = ["integration"], ["flag-a"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components, "flags": flags},
+ }
+ data = self.gql_request(query_files, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "UnknownFlags",
+ "message": f"No coverage with chosen flags: {flags}",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.ReportPaths.files", new_callable=PropertyMock)
+ def test_fetch_path_contents_component_flags_filters(
+ self,
+ files_mock,
+ report_mock,
+ commit_components_mock,
+ filtered_mock,
+ ):
+ files_mock.return_value = ["fileA.py"]
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "unit",
+ "name": "unit",
+ "paths": ["fileA.py"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "integration",
+ "name": "integration",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ "flag_regexes": "flag-a",
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockReport()
+
+ query_files = """
+ query FetchFiles($org: String!, $repo: String!, $branch: String!, $path: String!, $filters: PathContentsFilters!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ branch(name: $branch) {
+ head {
+ pathContents (path: $path, filters: $filters) {
+ __typename
+ ... on PathContents {
+ results {
+ __typename
+ name
+ path
+ }
+ }
+ ... on MissingCoverage {
+ message
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ components = ["unit"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components},
+ }
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ }
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated(self, report_mock):
+ report_mock.return_value = MockReport()
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {},
+ }
+
+ data = self.gql_request(query_files, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "pathContents": {
+ "__typename": "PathContents",
+ "results": [
+ {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ {
+ "__typename": "PathContentDir",
+ "name": "folder",
+ "path": "folder",
+ "hits": 24,
+ "misses": 0,
+ "partials": 0,
+ "lines": 30,
+ "percentCovered": 80.0,
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_paginated(
+ self,
+ report_mock,
+ ):
+ report_mock.return_value = MockReport()
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {},
+ "first": 2,
+ }
+
+ data = self.gql_request(query_files_connection, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "PathContentConnection",
+ "edges": [
+ {
+ "cursor": "0",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ {
+ "cursor": "1",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ ],
+ "totalCount": 3,
+ "pageInfo": {
+ "hasNextPage": True,
+ "hasPreviousPage": False,
+ "startCursor": "0",
+ "endCursor": "1",
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @override_settings(DEBUG=True)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_with_no_report(self, report_mock):
+ report_mock.return_value = None
+ commit_without_report = CommitFactory(repository=self.repo)
+ branch = BranchFactory(
+ repository=self.repo,
+ head=commit_without_report.commitid,
+ name="branch-two",
+ updatestamp=(datetime.now() + timedelta(1)),
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": branch.name,
+ "path": "",
+ "filters": {},
+ }
+ data = self.gql_request(query_files_connection, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "MissingHeadReport",
+ "message": "Missing head report",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @override_settings(DEBUG=True)
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_missing_coverage(
+ self, report_mock, paths_mock, provider_path_exists_mock
+ ):
+ report_mock.return_value = MockReport()
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = True
+
+ data = self.gql_request(
+ query_files_connection,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "invalid",
+ "filters": {},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "MissingCoverage",
+ "message": "missing coverage for path: invalid",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @override_settings(DEBUG=True)
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_unknown_path(
+ self, report_mock, paths_mock, provider_path_exists_mock
+ ):
+ report_mock.return_value = MockReport()
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = False
+
+ data = self.gql_request(
+ query_files_connection,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "invalid",
+ "filters": {},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "UnknownPath",
+ "message": "path does not exist: invalid",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @override_settings(DEBUG=True)
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_unknown_flags_no_flags(self, report_mock):
+ report_mock.return_value = MockNoFlagsReport()
+
+ data = self.gql_request(
+ query_files_connection,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"flags": ["test-123"]},
+ },
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "UnknownFlags",
+ "message": "No coverage with chosen flags: ['test-123']",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @override_settings(DEBUG=True)
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_component_filter_missing_coverage(
+ self, report_mock, commit_components_mock
+ ):
+ components = ["ComponentThree"]
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {"components": components},
+ }
+
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["fileA.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "c2",
+ "name": "ComponentTwo",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["(?s:.*/[^\\/]*\\.py.*)\\Z"],
+ }
+ ),
+ ]
+
+ data = self.gql_request(query_files_connection, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "MissingCoverage",
+ "message": f"missing coverage for report with components: {components}",
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_path_contents_deprecated_with_files_and_list_display_type(
+ self, report_mock
+ ):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "branch": self.branch.name,
+ "path": "",
+ "filters": {
+ "displayType": "LIST",
+ },
+ }
+ report_mock.return_value = MockReport()
+
+ data = self.gql_request(query_files_connection, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "branch": {
+ "head": {
+ "deprecatedPathContents": {
+ "__typename": "PathContentConnection",
+ "edges": [
+ {
+ "cursor": "0",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileA.py",
+ "path": "fileA.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ {
+ "cursor": "1",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ {
+ "cursor": "2",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileB.py",
+ "path": "folder/fileB.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ {
+ "cursor": "3",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileC.py",
+ "path": "folder/subfolder/fileC.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ {
+ "cursor": "4",
+ "node": {
+ "__typename": "PathContentFile",
+ "name": "fileD.py",
+ "path": "folder/subfolder/fileD.py",
+ "hits": 8,
+ "misses": 0,
+ "partials": 0,
+ "lines": 10,
+ "percentCovered": 80.0,
+ },
+ },
+ ],
+ "totalCount": 5,
+ "pageInfo": {
+ "hasNextPage": False,
+ "hasPreviousPage": False,
+ "startCursor": "0",
+ "endCursor": "4",
+ },
+ }
+ }
+ }
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_bundle_analysis_measurements.py b/apps/codecov-api/graphql_api/tests/test_bundle_analysis_measurements.py
new file mode 100644
index 0000000000..9b53945c6b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_bundle_analysis_measurements.py
@@ -0,0 +1,3402 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import StoragePaths
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.storage.memory import MemoryStorageService
+
+from reports.models import CommitReport
+from reports.tests.factories import CommitReportFactory
+from timeseries.tests.factories import MeasurementFactory
+
+from .helper import GraphQLTestHelper
+
+
+class TestBundleAnalysisMeasurements(GraphQLTestHelper, TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", private=False)
+ self.parent_commit = CommitFactory(repository=self.repo)
+ self.commit = CommitFactory(
+ repository=self.repo,
+ totals={"c": "12", "diff": [0, 0, 0, 0, 0, "14"]},
+ parent_commit_id=self.parent_commit.commitid,
+ )
+ self.head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ measurements_data = [
+ # 2024-06-10
+ ["bundle_analysis_report_size", "super", "2024-06-10T19:07:22", 29927],
+ ["bundle_analysis_font_size", "super", "2024-06-10T19:07:22", 290],
+ ["bundle_analysis_image_size", "super", "2024-06-10T19:07:22", 2900],
+ ["bundle_analysis_stylesheet_size", "super", "2024-06-10T19:07:22", 29],
+ ["bundle_analysis_javascript_size", "super", "2024-06-10T19:07:22", 26708],
+ [
+ "bundle_analysis_asset_size",
+ "ca05c27a-74f7-4d0e-a851-537c7b2bcb48",
+ "2024-06-10T19:07:22",
+ 14126,
+ ],
+ [
+ "bundle_analysis_asset_size",
+ "4e03bec3-1af3-4e58-b1b7-99aa995122a6",
+ "2024-06-10T19:07:22",
+ 11421,
+ ],
+ # 2024-06-06
+ ["bundle_analysis_report_size", "super", "2024-06-06T19:07:22", 6263],
+ ["bundle_analysis_font_size", "super", "2024-06-06T19:07:22", 50],
+ ["bundle_analysis_image_size", "super", "2024-06-06T19:07:22", 500],
+ ["bundle_analysis_stylesheet_size", "super", "2024-06-06T19:07:22", 5],
+ ["bundle_analysis_javascript_size", "super", "2024-06-06T19:07:22", 5708],
+ [
+ "bundle_analysis_asset_size",
+ "ca05c27a-74f7-4d0e-a851-537c7b2bcb48",
+ "2024-06-06T19:07:22",
+ 4126,
+ ],
+ [
+ "bundle_analysis_asset_size",
+ "4e03bec3-1af3-4e58-b1b7-99aa995122a6",
+ "2024-06-06T19:07:22",
+ 1421,
+ ],
+ ]
+
+ for item in measurements_data:
+ MeasurementFactory(
+ name=item[0],
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=item[1],
+ commit_sha=self.commit.pk,
+ timestamp=item[2],
+ value=item[3],
+ )
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_measurements(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Test without using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-06",
+ "before": "2024-06-10",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 4126.0,
+ "max": 4126.0,
+ "min": 4126.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 14126.0,
+ "max": 14126.0,
+ "min": 14126.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 150,
+ },
+ "size": {
+ "gzip": 14,
+ "uncompress": 14126,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 1421.0,
+ "max": 1421.0,
+ "min": 1421.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 11421.0,
+ "max": 11421.0,
+ "min": 11421.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 121,
+ },
+ "size": {
+ "gzip": 11,
+ "uncompress": 11421,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 2,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 240,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 50.0,
+ "max": 50.0,
+ "min": 50.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 290.0,
+ "max": 290.0,
+ "min": 290.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 3,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 290,
+ },
+ },
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 25,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2400,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 500.0,
+ "max": 500.0,
+ "min": 500.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 2900.0,
+ "max": 2900.0,
+ "min": 2900.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 30,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2900,
+ },
+ },
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 6,
+ "threeG": 252,
+ },
+ "size": {
+ "gzip": 23,
+ "uncompress": 23664,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 6263.0,
+ "max": 6263.0,
+ "min": 6263.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29927.0,
+ "max": 29927.0,
+ "min": 29927.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 319,
+ },
+ "size": {
+ "gzip": 29,
+ "uncompress": 29927,
+ },
+ },
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 24,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5.0,
+ "max": 5.0,
+ "min": 5.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29.0,
+ "max": 29.0,
+ "min": 29.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 29,
+ },
+ },
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ # Test with using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-06",
+ "before": "2024-06-10",
+ "filters": {"assetTypes": "JAVASCRIPT_SIZE"},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_asset_measurements(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ $asset: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ asset(name: $asset){
+ name
+ measurements(
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Tests can only fetch JS asset
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-06",
+ "before": "2024-06-10",
+ "asset": "asset-same-name-diff-modules.js",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "asset": {
+ "measurements": {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 4126.0,
+ "max": 4126.0,
+ "min": 4126.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 14126.0,
+ "max": 14126.0,
+ "min": 14126.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 150,
+ },
+ "size": {
+ "gzip": 14,
+ "uncompress": 14126,
+ },
+ },
+ },
+ "name": "asset-same-name-diff-modules.js",
+ },
+ },
+ }
+
+ # Tests non-JS asset can't be fetched
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-06",
+ "before": "2024-06-10",
+ "asset": "asset-css-A-TWO.css",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "asset": {
+ "measurements": None,
+ "name": "asset-css-A-TWO.css",
+ },
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_measurements_carryovers(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Test without using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-07",
+ "before": "2024-06-10",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 4126.0,
+ "max": 4126.0,
+ "min": 4126.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 14126.0,
+ "max": 14126.0,
+ "min": 14126.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 150,
+ },
+ "size": {
+ "gzip": 14,
+ "uncompress": 14126,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 1421.0,
+ "max": 1421.0,
+ "min": 1421.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 11421.0,
+ "max": 11421.0,
+ "min": 11421.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 121,
+ },
+ "size": {
+ "gzip": 11,
+ "uncompress": 11421,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 2,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 240,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 50.0,
+ "max": 50.0,
+ "min": 50.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 290.0,
+ "max": 290.0,
+ "min": 290.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 3,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 290,
+ },
+ },
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 25,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2400,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 500.0,
+ "max": 500.0,
+ "min": 500.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 2900.0,
+ "max": 2900.0,
+ "min": 2900.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 30,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2900,
+ },
+ },
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 6,
+ "threeG": 252,
+ },
+ "size": {
+ "gzip": 23,
+ "uncompress": 23664,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 6263.0,
+ "max": 6263.0,
+ "min": 6263.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29927.0,
+ "max": 29927.0,
+ "min": 29927.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 319,
+ },
+ "size": {
+ "gzip": 29,
+ "uncompress": 29927,
+ },
+ },
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 24,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5.0,
+ "max": 5.0,
+ "min": 5.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29.0,
+ "max": 29.0,
+ "min": 29.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 29,
+ },
+ },
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ # Test with using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-07",
+ "before": "2024-06-10",
+ "filters": {"assetTypes": "JAVASCRIPT_SIZE"},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_no_carryovers(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Test without using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-05",
+ "before": "2024-06-10",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 4126.0,
+ "max": 4126.0,
+ "min": 4126.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 14126.0,
+ "max": 14126.0,
+ "min": 14126.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 150,
+ },
+ "size": {
+ "gzip": 14,
+ "uncompress": 14126,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 2,
+ "threeG": 106,
+ },
+ "size": {
+ "gzip": 10,
+ "uncompress": 10000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 1421.0,
+ "max": 1421.0,
+ "min": 1421.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 11421.0,
+ "max": 11421.0,
+ "min": 11421.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ "size": {
+ "loadTime": {
+ "highSpeed": 3,
+ "threeG": 121,
+ },
+ "size": {
+ "gzip": 11,
+ "uncompress": 11421,
+ },
+ },
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 2,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 240,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 50.0,
+ "max": 50.0,
+ "min": 50.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 290.0,
+ "max": 290.0,
+ "min": 290.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 3,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 290,
+ },
+ },
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 25,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2400,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 500.0,
+ "max": 500.0,
+ "min": 500.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 2900.0,
+ "max": 2900.0,
+ "min": 2900.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 30,
+ },
+ "size": {
+ "gzip": 2,
+ "uncompress": 2900,
+ },
+ },
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 6,
+ "threeG": 252,
+ },
+ "size": {
+ "gzip": 23,
+ "uncompress": 23664,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 6263.0,
+ "max": 6263.0,
+ "min": 6263.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29927.0,
+ "max": 29927.0,
+ "min": 29927.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 319,
+ },
+ "size": {
+ "gzip": 29,
+ "uncompress": 29927,
+ },
+ },
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 24,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 5.0,
+ "max": 5.0,
+ "min": 5.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 29.0,
+ "max": 29.0,
+ "min": 29.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 29,
+ },
+ },
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ # Test with using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-05",
+ "before": "2024-06-10",
+ "filters": {"assetTypes": "JAVASCRIPT_SIZE"},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 5,
+ "threeG": 224,
+ },
+ "size": {
+ "gzip": 21,
+ "uncompress": 21000,
+ },
+ },
+ "measurements": [
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 7,
+ "threeG": 284,
+ },
+ "size": {
+ "gzip": 26,
+ "uncompress": 26708,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_branch(self, get_storage_service):
+ measurements_data = [
+ # 2024-06-10
+ ["bundle_analysis_report_size", "super", "2024-06-10T19:07:23", 123],
+ # 2024-06-06
+ ["bundle_analysis_report_size", "super", "2024-06-06T19:07:23", 456],
+ ]
+
+ for item in measurements_data:
+ MeasurementFactory(
+ name=item[0],
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="feat",
+ measurable_id=item[1],
+ commit_sha=self.commit.pk,
+ timestamp=item[2],
+ value=item[3],
+ )
+
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ $branch: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ branch: $branch
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-07",
+ "before": "2024-06-10",
+ "branch": "feat",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "name": "super",
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "name": None,
+ "size": {
+ "loadTime": {"threeG": 1, "highSpeed": 0},
+ "size": {"gzip": 0, "uncompress": 123},
+ },
+ "change": {
+ "loadTime": {"threeG": -3, "highSpeed": 0},
+ "size": {"gzip": 0, "uncompress": -333},
+ },
+ "measurements": [
+ {
+ "avg": 456.0,
+ "min": 456.0,
+ "max": 456.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 123.0,
+ "min": 123.0,
+ "max": 123.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": -3,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": -333,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 456.0,
+ "max": 456.0,
+ "min": 456.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 123.0,
+ "max": 123.0,
+ "min": 123.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 1,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 123,
+ },
+ },
+ },
+ ],
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_no_after(self, get_storage_service):
+ measurements_data = [
+ # 2024-06-10
+ ["bundle_analysis_report_size", "super", "2024-06-10T19:07:23", 123],
+ # 2024-06-06
+ ["bundle_analysis_report_size", "super", "2024-06-06T19:07:23", 456],
+ ]
+
+ for item in measurements_data:
+ MeasurementFactory(
+ name=item[0],
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="feat",
+ measurable_id=item[1],
+ commit_sha=self.commit.pk,
+ timestamp=item[2],
+ value=item[3],
+ )
+
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime
+ $branch: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ branch: $branch
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": None,
+ "before": "2024-06-10",
+ "branch": "feat",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "name": "super",
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "name": "asset-*.js",
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "name": None,
+ "size": {
+ "loadTime": {"threeG": 1, "highSpeed": 0},
+ "size": {"gzip": 0, "uncompress": 123},
+ },
+ "change": {
+ "loadTime": {"threeG": -3, "highSpeed": 0},
+ "size": {"gzip": 0, "uncompress": -333},
+ },
+ "measurements": [
+ {
+ "avg": 456.0,
+ "min": 456.0,
+ "max": 456.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 123.0,
+ "min": 123.0,
+ "max": 123.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "name": None,
+ "size": None,
+ "change": None,
+ "measurements": [],
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": -3,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": -333,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 456.0,
+ "max": 456.0,
+ "min": 456.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 123.0,
+ "max": 123.0,
+ "min": 123.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 1,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 123,
+ },
+ },
+ },
+ ],
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_bad_data_check(self, get_storage_service):
+ measurements_data = [
+ # 2024-06-10
+ ["bundle_analysis_report_size", "super", "2024-06-10T19:07:23", 123],
+ # 2024-06-06
+ ["bundle_analysis_image_size", "super", "2024-06-06T19:07:23", 456],
+ # 2024-06-05
+ ["bundle_analysis_image_size", "super", "2024-06-05T19:07:23", 666],
+ ]
+
+ for item in measurements_data:
+ MeasurementFactory(
+ name=item[0],
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="feat",
+ measurable_id=item[1],
+ commit_sha=self.commit.pk,
+ timestamp=item[2],
+ value=item[3],
+ )
+
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime
+ $branch: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ branch: $branch
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": None,
+ "before": "2024-06-10",
+ "branch": "feat",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": "asset-*.js",
+ "size": None,
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": None,
+ "size": None,
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": -2,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": -210,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 666.0,
+ "max": 666.0,
+ "min": 666.0,
+ "timestamp": "2024-06-05T00:00:00+00:00",
+ },
+ {
+ "avg": 456.0,
+ "max": 456.0,
+ "min": 456.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 4,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 456,
+ },
+ },
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": None,
+ "size": None,
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "change": None,
+ "measurements": [
+ {
+ "avg": 123.0,
+ "max": 123.0,
+ "min": 123.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 1,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 123,
+ },
+ },
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": None,
+ "size": None,
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": None,
+ "measurements": [],
+ "name": None,
+ "size": None,
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_measurements_only_unknown(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ size {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ change {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Test without using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-06",
+ "before": "2024-06-10",
+ "filters": {"assetTypes": ["UNKNOWN_SIZE"]},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "change": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ "measurements": [
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-06T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ "size": {
+ "loadTime": {
+ "highSpeed": 0,
+ "threeG": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 0,
+ },
+ },
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_report_measurements_no_data_in_range(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/bundle_with_uuid.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchMeasurements(
+ $org: String!,
+ $repo: String!,
+ $commit: String!
+ $filters: BundleAnalysisMeasurementsSetFilters
+ $orderingDirection: OrderingDirection!
+ $interval: MeasurementInterval!
+ $before: DateTime!
+ $after: DateTime!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "super") {
+ name
+ measurements(
+ filters: $filters
+ orderingDirection: $orderingDirection
+ after: $after
+ interval: $interval
+ before: $before
+ ){
+ assetType
+ name
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Test without using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-08",
+ "before": "2024-06-09",
+ "filters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "ASSET_SIZE",
+ "measurements": [
+ {
+ "avg": 4126.0,
+ "max": 4126.0,
+ "min": 4126.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": "asset-*.js",
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "measurements": [],
+ "name": "asset-*.js",
+ },
+ {
+ "assetType": "ASSET_SIZE",
+ "measurements": [],
+ "name": "asset-*.js",
+ },
+ {
+ "assetType": "FONT_SIZE",
+ "measurements": [
+ {
+ "avg": 50.0,
+ "max": 50.0,
+ "min": 50.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ {
+ "assetType": "IMAGE_SIZE",
+ "measurements": [
+ {
+ "avg": 500.0,
+ "max": 500.0,
+ "min": 500.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ {
+ "assetType": "REPORT_SIZE",
+ "measurements": [
+ {
+ "avg": 6263.0,
+ "max": 6263.0,
+ "min": 6263.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ {
+ "assetType": "STYLESHEET_SIZE",
+ "measurements": [
+ {
+ "avg": 5.0,
+ "max": 5.0,
+ "min": 5.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ {
+ "assetType": "UNKNOWN_SIZE",
+ "measurements": [
+ {
+ "avg": 0.0,
+ "max": 0.0,
+ "min": 0.0,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ ],
+ "name": "super",
+ },
+ }
+
+ # Test with using asset type filters
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "orderingDirection": "ASC",
+ "interval": "INTERVAL_1_DAY",
+ "after": "2024-06-07",
+ "before": "2024-06-10",
+ "filters": {"assetTypes": "JAVASCRIPT_SIZE"},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "measurements": [
+ {
+ "assetType": "JAVASCRIPT_SIZE",
+ "measurements": [
+ {
+ "avg": 5708.0,
+ "max": 5708.0,
+ "min": 5708.0,
+ "timestamp": "2024-06-07T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-08T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "max": None,
+ "min": None,
+ "timestamp": "2024-06-09T00:00:00+00:00",
+ },
+ {
+ "avg": 26708.0,
+ "max": 26708.0,
+ "min": 26708.0,
+ "timestamp": "2024-06-10T00:00:00+00:00",
+ },
+ ],
+ "name": None,
+ },
+ ],
+ "name": "super",
+ },
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_commit.py b/apps/codecov-api/graphql_api/tests/test_commit.py
new file mode 100644
index 0000000000..9f9980d8f1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_commit.py
@@ -0,0 +1,3530 @@
+import asyncio
+import hashlib
+import os
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, PropertyMock, patch
+
+import yaml
+from django.test import TestCase
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import StoragePaths
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.django_apps.core.tests.factories import (
+ CommitErrorFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.types import LineSession
+from shared.storage.memory import MemoryStorageService
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory
+from graphql_api.types.enums import CommitStatus, UploadErrorEnum, UploadState
+from graphql_api.types.enums.enums import UploadType
+from reports.models import CommitReport
+from reports.tests.factories import (
+ CommitReportFactory,
+ ReportLevelTotalsFactory,
+ RepositoryFlagFactory,
+ UploadErrorFactory,
+ UploadFactory,
+ UploadFlagMembershipFactory,
+)
+from services.comparison import MissingComparisonReport
+from services.components import Component
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+query_commit = """
+query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ %s
+ }
+ }
+ }
+ }
+}
+"""
+
+query_commits = """
+query FetchCommits($org: String!, $repo: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commits {
+ edges {
+ node {
+ %s
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class MockCoverage(object):
+ def __init__(self, cov):
+ self.coverage = cov
+ self.sessions = [
+ LineSession(0, None),
+ LineSession(1, None),
+ LineSession(2, None),
+ ]
+
+
+class MockLines(object):
+ def __init__(self):
+ self.lines = [
+ [0, MockCoverage("1/2")],
+ [1, MockCoverage(1)],
+ [2, MockCoverage(0)],
+ ]
+ self.totals = MockCoverage(83)
+
+
+class MockReport(object):
+ def get(self, filename):
+ MockLines()
+ return MockLines()
+
+ def filter(self, **kwargs):
+ return self
+
+ def get_flag_names(self):
+ return ["flag_a", "flag_b"]
+
+
+class EmptyReport(MockReport):
+ def get(self, filename):
+ return None
+
+
+class TestCommit(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ asyncio.set_event_loop(asyncio.new_event_loop())
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", private=False)
+ self.author = OwnerFactory()
+ self.parent_commit = CommitFactory(repository=self.repo)
+ self.commit = CommitFactory(
+ repository=self.repo,
+ totals={"c": "12", "diff": [0, 0, 0, 0, 0, "14"]},
+ parent_commit_id=self.parent_commit.commitid,
+ )
+ self.report = CommitReportFactory(commit=self.commit)
+
+ # mock reports for all tests in this class
+ self.head_report_patcher = patch(
+ "services.comparison.Comparison.head_report", new_callable=PropertyMock
+ )
+ self.head_report = self.head_report_patcher.start()
+ self.head_report.return_value = None
+ self.addCleanup(self.head_report_patcher.stop)
+ self.base_report_patcher = patch(
+ "services.comparison.Comparison.base_report", new_callable=PropertyMock
+ )
+ self.base_report = self.base_report_patcher.start()
+ self.base_report.return_value = None
+ self.addCleanup(self.base_report_patcher.stop)
+
+ def test_fetch_commit(self):
+ query = (
+ query_commit
+ % """
+ message,
+ createdAt,
+ commitid,
+ state,
+ author { username }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["commitid"] == self.commit.commitid
+ assert commit["message"] == self.commit.message
+ assert commit["author"]["username"] == self.commit.author.username
+ assert commit["state"] == self.commit.state
+
+ def test_fetch_commits(self):
+ query = query_commits % "message,commitid,ciPassed"
+ self.repo_2 = RepositoryFactory(
+ author=self.org, name="test-repo", private=False
+ )
+
+ CommitFactory(
+ repository=self.repo_2,
+ commitid=123,
+ timestamp=datetime.today() - timedelta(days=3),
+ )
+ CommitFactory(
+ repository=self.repo_2,
+ commitid=456,
+ timestamp=datetime.today() - timedelta(days=1),
+ )
+ CommitFactory(
+ repository=self.repo_2,
+ commitid=789,
+ timestamp=datetime.today() - timedelta(days=2),
+ )
+
+ variables = {"org": self.org.username, "repo": self.repo_2.name}
+ data = self.gql_request(query, variables=variables)
+ commits = paginate_connection(data["owner"]["repository"]["commits"])
+ commits_commitids = [commit["commitid"] for commit in commits]
+ assert commits_commitids == ["456", "789", "123"]
+
+ def test_fetch_parent_commit(self):
+ query = query_commit % "parent { commitid } "
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["parent"]["commitid"] == self.parent_commit.commitid
+
+ def test_resolve_commit_without_parent(self):
+ self.commit_without_parent = CommitFactory(
+ repository=self.repo, parent_commit_id=None
+ )
+ query = query_commit % "parent { commitid } "
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit_without_parent.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["parent"] is None
+
+ def test_fetch_commit_coverage(self):
+ ReportLevelTotalsFactory(report=self.report, coverage=12)
+ query = query_commit % "coverageAnalytics { totals { percentCovered }} "
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageAnalytics"]["totals"]["percentCovered"] == 12
+
+ def test_fetch_commit_build(self):
+ session_one = UploadFactory(report=self.report, provider="circleci")
+ session_two = UploadFactory(report=self.report, provider="travisci")
+ query = query_commit % "uploads { edges { node { provider } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ builds = paginate_connection(commit["uploads"])
+ assert builds == [
+ {"provider": session_one.provider},
+ {"provider": session_two.provider},
+ ]
+
+ def test_fetch_commit_uploads_state(self):
+ UploadFactory(
+ report=self.report, provider="circleci", state=UploadState.PROCESSED.value
+ )
+ UploadFactory(
+ report=self.report, provider="travisci", state=UploadState.ERROR.value
+ )
+ UploadFactory(
+ report=self.report, provider="travisci", state=UploadState.COMPLETE.value
+ )
+ UploadFactory(
+ report=self.report, provider="travisci", state=UploadState.UPLOADED.value
+ )
+ UploadFactory(report=self.report, provider="travisci", state="")
+ query = (
+ query_commit
+ % """
+ uploads {
+ edges {
+ node {
+ state
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+
+ assert uploads == [
+ {"state": UploadState.PROCESSED.name},
+ {"state": UploadState.ERROR.name},
+ {"state": UploadState.COMPLETE.name},
+ {"state": UploadState.UPLOADED.name},
+ {"state": UploadState.ERROR.name},
+ ]
+
+ def test_fetch_commit_uploads(self):
+ flag_a = RepositoryFlagFactory(flag_name="flag_a")
+ flag_b = RepositoryFlagFactory(flag_name="flag_b")
+ flag_c = RepositoryFlagFactory(flag_name="flag_c")
+ flag_d = RepositoryFlagFactory(flag_name="flag_d")
+
+ session_a = UploadFactory(
+ report=self.report,
+ upload_type=UploadType.UPLOADED.value,
+ order_number=0,
+ )
+ UploadFlagMembershipFactory(report_session=session_a, flag=flag_a)
+ UploadFlagMembershipFactory(report_session=session_a, flag=flag_b)
+ UploadFlagMembershipFactory(report_session=session_a, flag=flag_c)
+ session_b = UploadFactory(
+ report=self.report,
+ upload_type=UploadType.CARRIEDFORWARD.value,
+ order_number=1,
+ )
+ UploadFlagMembershipFactory(report_session=session_b, flag=flag_a)
+
+ session_c = UploadFactory(
+ report=self.report,
+ upload_type=UploadType.CARRIEDFORWARD.value,
+ order_number=2,
+ )
+ UploadFlagMembershipFactory(report_session=session_c, flag=flag_b)
+ session_d = UploadFactory(
+ report=self.report,
+ upload_type=UploadType.UPLOADED.value,
+ order_number=3,
+ )
+ UploadFlagMembershipFactory(report_session=session_d, flag=flag_b)
+
+ session_e = UploadFactory(
+ report=self.report,
+ upload_type=UploadType.CARRIEDFORWARD.value,
+ order_number=4,
+ )
+ UploadFlagMembershipFactory(report_session=session_e, flag=flag_d)
+ UploadFactory(
+ report=self.report,
+ upload_type=UploadType.UPLOADED.value,
+ order_number=5,
+ )
+
+ query = (
+ query_commit
+ % """
+ uploads {
+ edges {
+ node {
+ id
+ uploadType
+ flags
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+
+ # ordered by upload id, omits uploads with carriedforward flag if another
+ # upload exists with the same flag name is is not carriedforward
+
+ assert uploads == [
+ {
+ "id": 0,
+ "uploadType": "UPLOADED",
+ "flags": ["flag_a", "flag_b", "flag_c"],
+ },
+ # TEMP: we're returning ALL uploads for now while we figure out a more
+ # performant way to make this query
+ {"id": 1, "uploadType": "CARRIEDFORWARD", "flags": ["flag_a"]},
+ {"id": 2, "uploadType": "CARRIEDFORWARD", "flags": ["flag_b"]},
+ {"id": 3, "uploadType": "UPLOADED", "flags": ["flag_b"]},
+ {"id": 4, "uploadType": "CARRIEDFORWARD", "flags": ["flag_d"]},
+ {"id": 5, "uploadType": "UPLOADED", "flags": []},
+ ]
+
+ def test_fetch_commit_uploads_no_report(self):
+ commit = CommitFactory(
+ repository=self.repo,
+ parent_commit_id=self.commit.commitid,
+ )
+ query = (
+ query_commit
+ % """
+ uploads {
+ edges {
+ node {
+ uploadType
+ flags
+ provider
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+ assert uploads == []
+
+ def test_fetch_commit_uploads_errors(self):
+ session = UploadFactory(
+ report=self.report, provider="circleci", state=UploadState.ERROR.value
+ )
+ UploadErrorFactory(
+ report_session=session, error_code=UploadErrorEnum.REPORT_EXPIRED.value
+ )
+ UploadErrorFactory(
+ report_session=session, error_code=UploadErrorEnum.FILE_NOT_IN_STORAGE.value
+ )
+
+ query = (
+ query_commit
+ % """
+ uploads {
+ edges {
+ node {
+ errors {
+ edges {
+ node {
+ errorCode
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ [upload] = paginate_connection(commit["uploads"])
+ errors = paginate_connection(upload["errors"])
+
+ assert errors == [
+ {"errorCode": UploadErrorEnum.REPORT_EXPIRED.name},
+ {"errorCode": UploadErrorEnum.FILE_NOT_IN_STORAGE.name},
+ ]
+
+ def test_yaml_return_default_state_if_default(self):
+ org = OwnerFactory(username="default_yaml_owner")
+ repo = RepositoryFactory(author=org, private=False)
+ commit = CommitFactory(repository=repo)
+ query = (
+ query_commit
+ % """
+ yamlState
+ """
+ )
+ variables = {
+ "org": org.username,
+ "repo": repo.name,
+ "commit": commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data["owner"]["repository"]["commit"]["yamlState"] == "DEFAULT"
+
+ def test_fetch_commit_ci(self):
+ UploadFactory(
+ report=self.report,
+ provider="circleci",
+ job_code=123,
+ build_code=456,
+ )
+ query = query_commit % "uploads { edges { node { ciUrl } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+ assert uploads == [
+ {
+ "ciUrl": "https://circleci.com/gh/codecov/gazebo/456#tests/containers/123",
+ }
+ ]
+
+ def test_fetch_download_url(self):
+ upload = UploadFactory(
+ report=self.report,
+ storage_path="v4/raw/2022-06-23/942173DE95CBF167C5683F40B7DB34C0/ee3ecad424e67419d6c4531540f1ef5df045ff12/919ccc6d-7972-4895-b289-f2d569683a17.txt",
+ )
+ query = query_commit % "uploads { edges { node { downloadUrl } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+ assert uploads == [
+ {
+ "downloadUrl": f"https://testserver/upload/gh/{self.org.username}/{self.repo.name}/download?path={upload.storage_path}",
+ }
+ ]
+
+ @patch(
+ "core.commands.commit.commit.CommitCommands.get_final_yaml",
+ new_callable=AsyncMock,
+ )
+ def test_fetch_commit_yaml_call_the_command(self, command_mock):
+ query = query_commit % "yaml"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ fake_config = {"codecov": "yes"}
+ command_mock.return_value = fake_config
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["yaml"] == yaml.dump(fake_config)
+
+ @patch("core.commands.commit.commit.CommitCommands.get_file_content")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_coverage_file_call_the_command(
+ self, report_mock, content_mock
+ ):
+ query = (
+ query_commit
+ % 'coverageAnalytics { coverageFile(path: "path") { hashedPath, content, coverage { line,coverage }, totals { percentCovered } } }'
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ fake_coverage = {
+ "content": "file content",
+ "coverage": [
+ {"line": 0, "coverage": "P"},
+ {"line": 1, "coverage": "H"},
+ {"line": 2, "coverage": "M"},
+ ],
+ "totals": {"percentCovered": 83.0},
+ }
+ content_mock.return_value = "file content"
+
+ report_mock.return_value = MockReport()
+ data = self.gql_request(query, variables=variables)
+ coverageFile = data["owner"]["repository"]["commit"]["coverageAnalytics"][
+ "coverageFile"
+ ]
+ assert coverageFile["content"] == fake_coverage["content"]
+ assert coverageFile["coverage"] == fake_coverage["coverage"]
+ assert coverageFile["totals"] == fake_coverage["totals"]
+ assert coverageFile["hashedPath"] == hashlib.md5("path".encode()).hexdigest()
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_coverage_file_with_components(
+ self, report_mock, commit_components_mock, filtered_mock
+ ):
+ components = ["Global"]
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ "components": components,
+ }
+
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["fileA.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "c2",
+ "name": "ComponentTwo",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["**/*.py"],
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockReport()
+
+ query_files = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!, $components: [String!]!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ coverageAnalytics {
+ coverageFile(path: "path", components: $components) {
+ hashedPath, content, coverage { line,coverage }, totals { percentCovered }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "coverageFile": {
+ "content": None,
+ "coverage": [
+ {"coverage": "P", "line": 0},
+ {"coverage": "H", "line": 1},
+ {"coverage": "M", "line": 2},
+ ],
+ "hashedPath": "d6fe1d0be6347b8ef2427fa629c04485",
+ "totals": {"percentCovered": 83.0},
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("core.commands.commit.commit.CommitCommands.get_file_content")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_with_no_coverage_data(
+ self,
+ report_mock,
+ content_mock,
+ ):
+ query = (
+ query_commit
+ % 'coverageAnalytics { coverageFile(path: "path") { content, coverage { line,coverage }, totals { percentCovered } } }'
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ fake_coverage = {"content": "file content", "coverage": [], "totals": None}
+ content_mock.return_value = "file content"
+
+ report_mock.return_value = EmptyReport()
+ data = self.gql_request(query, variables=variables)
+ coverageFile = data["owner"]["repository"]["commit"]["coverageAnalytics"][
+ "coverageFile"
+ ]
+ assert coverageFile["content"] == fake_coverage["content"]
+ assert coverageFile["coverage"] == fake_coverage["coverage"]
+ assert coverageFile["totals"] == fake_coverage["totals"]
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_flag_names(self, report_mock):
+ query = query_commit % "coverageAnalytics { flagNames }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ report_mock.return_value = MockReport()
+ data = self.gql_request(query, variables=variables)
+ flags = data["owner"]["repository"]["commit"]["coverageAnalytics"]["flagNames"]
+ assert flags == ["flag_a", "flag_b"]
+
+ def test_fetch_commit_compare_call_the_command(self):
+ CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ )
+ query = query_commit % "compareWithParent { ... on Comparison { state } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["compareWithParent"] == {"state": "pending"}
+
+ def test_fetch_commit_compare_no_parent(self):
+ self.commit.parent_commit_id = None
+ self.commit.save()
+
+ query = (
+ query_commit
+ % """
+ compareWithParent { __typename ... on Comparison { state } }
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent { __typename ... on BundleAnalysisComparison { bundleData { size { uncompress } } } }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["compareWithParent"]["__typename"] == "MissingBaseCommit"
+ assert (
+ commit["bundleAnalysis"]["bundleAnalysisCompareWithParent"]["__typename"]
+ == "MissingBaseCommit"
+ )
+
+ def test_compare_with_parent_comparison_missing_when_commit_comparison_state_is_errored(
+ self,
+ ):
+ CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.ERROR,
+ )
+ query = (
+ query_commit
+ % "compareWithParent { __typename ... on Comparison { state } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["compareWithParent"]["__typename"] == "MissingComparison"
+
+ def test_compare_with_parent_change_coverage(self):
+ CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ )
+ ReportLevelTotalsFactory(
+ report=CommitReportFactory(commit=self.parent_commit),
+ coverage=75.0,
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ branches=0,
+ methods=0,
+ )
+ ReportLevelTotalsFactory(
+ report=self.report,
+ coverage=80.0,
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ branches=0,
+ methods=0,
+ )
+
+ query = (
+ query_commit % "compareWithParent { ... on Comparison { changeCoverage } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["compareWithParent"]["changeCoverage"] == 5.0
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_compare(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ base_commit_report = CommitReportFactory(
+ commit=self.parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundles {
+ name
+ changeType
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["bundleAnalysis"]["bundleAnalysisCompareWithParent"] == {
+ "__typename": "BundleAnalysisComparison",
+ "bundles": [
+ {
+ "name": "b1",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 20}},
+ "bundleChange": {"size": {"uncompress": 5}},
+ },
+ {
+ "name": "b2",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 200}},
+ "bundleChange": {"size": {"uncompress": 50}},
+ },
+ {
+ "name": "b3",
+ "changeType": "added",
+ "bundleData": {"size": {"uncompress": 1500}},
+ "bundleChange": {"size": {"uncompress": 1500}},
+ },
+ {
+ "name": "b5",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 200000}},
+ "bundleChange": {"size": {"uncompress": 50000}},
+ },
+ {
+ "name": "b4",
+ "changeType": "removed",
+ "bundleData": {"size": {"uncompress": 0}},
+ "bundleChange": {"size": {"uncompress": -15000}},
+ },
+ ],
+ "bundleData": {"size": {"uncompress": 201720}},
+ "bundleChange": {"size": {"uncompress": 36555}},
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_compare_with_compare_sha(self, get_storage_service):
+ """
+ This tests creates 3 commits C1 -> C2 -> C3
+ C1 uses Report1, C2 and C3 uses Report2
+ Normally when doing a compare of C3, it would select C2 as its parent
+ then it would show no change, as expected
+ However the difference is that in C3's Report2 it has the compareSha set to C1.commitid
+ Now when doing comparison of C3, it would now select C1 as the parent
+ therefore show correct comparison in numbers between Report1 and Report2
+ """
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ commit_1 = CommitFactory(
+ repository=self.repo,
+ commitid="6ca727b0142bf5625bb82af2555d308862063222",
+ )
+ commit_2 = CommitFactory(
+ repository=self.repo, parent_commit_id=commit_1.commitid
+ )
+ commit_3 = CommitFactory(
+ repository=self.repo, parent_commit_id=commit_2.commitid
+ )
+
+ commit_report_1 = CommitReportFactory(
+ commit=commit_1,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+
+ commit_report_2 = CommitReportFactory(
+ commit=commit_2,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+
+ commit_report_3 = CommitReportFactory(
+ commit=commit_3,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=commit_report_1.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=commit_report_2.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open(
+ "./services/tests/samples/head_bundle_report_with_compare_sha_6ca727b0142bf5625bb82af2555d308862063222.sqlite",
+ "rb",
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=commit_report_3.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": commit_report_3.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["bundleAnalysis"]["bundleAnalysisCompareWithParent"] == {
+ "__typename": "BundleAnalysisComparison",
+ "bundleData": {"size": {"uncompress": 201720}},
+ "bundleChange": {"size": {"uncompress": 36555}},
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_sqlite_file_deleted(self, get_storage_service):
+ os.system("rm -rf /tmp/bundle_analysis_*")
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ base_commit_report = CommitReportFactory(
+ commit=self.parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ self.gql_request(query, variables=variables)
+
+ for file in os.listdir("/tmp"):
+ assert not file.startswith("bundle_analysis_")
+ os.system("rm -rf /tmp/bundle_analysis_*")
+
+ @patch("graphql_api.views.os.unlink")
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_sqlite_file_not_deleted(
+ self, get_storage_service, os_unlink_mock
+ ):
+ os.system("rm -rf /tmp/bundle_analysis_*")
+ os_unlink_mock.side_effect = Exception("something went wrong")
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ base_commit_report = CommitReportFactory(
+ commit=self.parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ self.gql_request(query, variables=variables)
+
+ found = False
+ for file in os.listdir("/tmp"):
+ if file.startswith("bundle_analysis_"):
+ found = True
+ break
+ assert found
+ os.system("rm -rf /tmp/bundle_analysis_*")
+
+ def test_bundle_analysis_missing_report(self):
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on MissingHeadReport {
+ message
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "MissingHeadReport",
+ "message": "Missing head report",
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundles {
+ name
+ assets {
+ normalizedName
+ }
+ asset(name: "not_exist") {
+ normalizedName
+ }
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ isCached
+ cacheConfig
+ }
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ bundle(name: "not_exist") {
+ name
+ isCached
+ }
+ isCached
+ }
+ ... on MissingHeadReport {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundles": [
+ {
+ "name": "b1",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 0,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 20,
+ },
+ },
+ "isCached": False,
+ "cacheConfig": False,
+ },
+ {
+ "name": "b2",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 200,
+ },
+ },
+ "isCached": False,
+ "cacheConfig": False,
+ },
+ {
+ "name": "b3",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 16,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 1500,
+ },
+ },
+ "isCached": False,
+ "cacheConfig": False,
+ },
+ {
+ "name": "b5",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2133,
+ "highSpeed": 53,
+ },
+ "size": {
+ "gzip": 200,
+ "uncompress": 200000,
+ },
+ },
+ "isCached": False,
+ "cacheConfig": False,
+ },
+ ],
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2151,
+ "highSpeed": 53,
+ },
+ "size": {
+ "gzip": 201,
+ "uncompress": 201720,
+ },
+ },
+ "bundle": None,
+ "isCached": False,
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_first_after(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ first: 2,
+ after: "5",
+ ){
+ totalCount
+ edges {
+ cursor
+ node {
+ normalizedName
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "ordering": "NAME",
+ "orderingDirection": "ASC",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "assetsPaginated": {
+ "totalCount": 5,
+ "edges": [
+ {
+ "cursor": "4",
+ "node": {
+ "normalizedName": "assets/index-*.js",
+ },
+ },
+ {
+ "cursor": "2",
+ "node": {
+ "normalizedName": "assets/index-*.css",
+ },
+ },
+ ],
+ "pageInfo": {
+ "hasNextPage": True,
+ "hasPreviousPage": False,
+ "startCursor": "4",
+ "endCursor": "2",
+ },
+ }
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_first_after_non_existing_cursor(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ first: 2,
+ after: "notanumber",
+ ){
+ totalCount
+ edges {
+ cursor
+ node {
+ normalizedName
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "ordering": "NAME",
+ "orderingDirection": "ASC",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "assetsPaginated": {
+ "totalCount": 5,
+ "edges": [
+ {
+ "cursor": "3",
+ "node": {
+ "normalizedName": "assets/LazyComponent-*.js",
+ },
+ },
+ {
+ "cursor": "5",
+ "node": {
+ "normalizedName": "assets/index-*.js",
+ },
+ },
+ ],
+ "pageInfo": {
+ "hasNextPage": True,
+ "hasPreviousPage": False,
+ "startCursor": "3",
+ "endCursor": "5",
+ },
+ }
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_last_before(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ last: 2,
+ before: "1",
+ ){
+ totalCount
+ edges {
+ cursor
+ node {
+ normalizedName
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "ordering": "NAME",
+ "orderingDirection": "ASC",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "assetsPaginated": {
+ "totalCount": 5,
+ "edges": [
+ {
+ "cursor": "4",
+ "node": {
+ "normalizedName": "assets/index-*.js",
+ },
+ },
+ {
+ "cursor": "2",
+ "node": {
+ "normalizedName": "assets/index-*.css",
+ },
+ },
+ ],
+ "pageInfo": {
+ "hasNextPage": False,
+ "hasPreviousPage": True,
+ "startCursor": "4",
+ "endCursor": "2",
+ },
+ }
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_last_before_non_existing_cursor(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ last: 2,
+ before: "99999",
+ ){
+ totalCount
+ edges {
+ cursor
+ node {
+ normalizedName
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "ordering": "NAME",
+ "orderingDirection": "ASC",
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "assetsPaginated": {
+ "totalCount": 5,
+ "edges": [
+ {
+ "cursor": "2",
+ "node": {
+ "normalizedName": "assets/index-*.css",
+ },
+ },
+ {
+ "cursor": "1",
+ "node": {
+ "normalizedName": "assets/react-*.svg",
+ },
+ },
+ ],
+ "pageInfo": {
+ "hasNextPage": False,
+ "hasPreviousPage": True,
+ "startCursor": "2",
+ "endCursor": "1",
+ },
+ }
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_before_and_after_error(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ before: "1",
+ after: "2",
+ ){
+ totalCount
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, with_errors=True, variables=variables)
+ commit = data["data"]["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {"assetsPaginated": None},
+ }
+
+ assert (
+ data["errors"][0]["message"]
+ == "After and before can not be used at the same time"
+ )
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_assets_paginated_first_and_last_error(
+ self, get_storage_service
+ ):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit(
+ $org: String!,
+ $repo: String!,
+ $commit: String!,
+ $ordering: AssetOrdering,
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b1") {
+ assetsPaginated (
+ ordering: $ordering,
+ orderingDirection: $orderingDirection,
+ first: 1,
+ last: 2,
+ ){
+ totalCount
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, with_errors=True, variables=variables)
+ commit = data["data"]["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {"assetsPaginated": None},
+ }
+
+ assert (
+ data["errors"][0]["message"]
+ == "First and last can not be used at the same time"
+ )
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_asset(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open(
+ "./services/tests/samples/bundle_with_assets_and_modules.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b5") {
+ moduleCount
+ asset(name: "assets/LazyComponent-fcbb0922.js") {
+ name
+ normalizedName
+ extension
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ modules {
+ name
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ bundle_report = commit["bundleAnalysis"]["bundleAnalysisReport"]["bundle"]
+ asset_report = bundle_report["asset"]
+
+ assert bundle_report is not None
+ assert bundle_report["moduleCount"] == 33
+
+ assert asset_report is not None
+ assert asset_report["name"] == "assets/LazyComponent-fcbb0922.js"
+ assert asset_report["normalizedName"] == "assets/LazyComponent-*.js"
+ assert asset_report["extension"] == "js"
+ assert asset_report["bundleData"] == {
+ "loadTime": {
+ "threeG": 320,
+ "highSpeed": 8,
+ },
+ "size": {
+ "gzip": 30,
+ "uncompress": 30000,
+ },
+ }
+
+ modules = sorted(asset_report["modules"], key=lambda m: m["name"])
+
+ assert modules and len(modules) == 3
+ assert modules[0] == {
+ "name": "./src/LazyComponent/LazyComponent",
+ "bundleData": {
+ "loadTime": {
+ "threeG": 64,
+ "highSpeed": 1,
+ },
+ "size": {
+ "gzip": 6,
+ "uncompress": 6000,
+ },
+ },
+ }
+ assert modules[1] == {
+ "name": "./src/LazyComponent/LazyComponent.tsx",
+ "bundleData": {
+ "loadTime": {
+ "threeG": 53,
+ "highSpeed": 1,
+ },
+ "size": {
+ "gzip": 5,
+ "uncompress": 5000,
+ },
+ },
+ }
+ assert modules[2] == {
+ "name": "./src/LazyComponent/LazyComponent.tsx?module",
+ "bundleData": {
+ "loadTime": {
+ "threeG": 53,
+ "highSpeed": 1,
+ },
+ "size": {
+ "gzip": 4,
+ "uncompress": 4970,
+ },
+ },
+ }
+
+ @patch("shared.bundle_analysis.BundleReport.asset_reports")
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_asset_filtering(
+ self, get_storage_service, asset_reports_mock
+ ):
+ storage = MemoryStorageService({})
+
+ get_storage_service.return_value = storage
+ asset_reports_mock.return_value = []
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open(
+ "./services/tests/samples/bundle_with_assets_and_modules.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!, $filters: BundleAnalysisReportFilters) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b5", filters: $filters) {
+ moduleCount
+ assets {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "filters": {},
+ }
+
+ configurations = [
+ # No filters
+ (
+ {"loadTypes": None, "reportGroups": None},
+ {"asset_types": None, "chunk_entry": None, "chunk_initial": None},
+ ),
+ ({}, {"asset_types": None, "chunk_entry": None, "chunk_initial": None}),
+ # Just report groups
+ (
+ {"reportGroups": ["JAVASCRIPT", "FONT"]},
+ {
+ "asset_types": ["JAVASCRIPT", "FONT"],
+ "chunk_entry": None,
+ "chunk_initial": None,
+ },
+ ),
+ # Load types -> chunk_entry cancels out
+ (
+ {"loadTypes": ["ENTRY", "INITIAL"]},
+ {"asset_types": None, "chunk_entry": None, "chunk_initial": True},
+ ),
+ # Load types -> chunk_entry = True
+ (
+ {"loadTypes": ["ENTRY"]},
+ {"asset_types": None, "chunk_entry": True, "chunk_initial": None},
+ ),
+ # Load types -> chunk_lazy = False
+ (
+ {"loadTypes": ["LAZY"]},
+ {"asset_types": None, "chunk_entry": False, "chunk_initial": False},
+ ),
+ # Load types -> chunk_initial cancels out
+ (
+ {"loadTypes": ["LAZY", "INITIAL"]},
+ {"asset_types": None, "chunk_entry": False, "chunk_initial": None},
+ ),
+ # Load types -> chunk_initial = True
+ (
+ {"loadTypes": ["INITIAL"]},
+ {"asset_types": None, "chunk_entry": False, "chunk_initial": True},
+ ),
+ # Load types -> chunk_initial = False
+ (
+ {"loadTypes": ["LAZY"]},
+ {"asset_types": None, "chunk_entry": False, "chunk_initial": False},
+ ),
+ ]
+
+ for config in configurations:
+ input_d, output_d = config
+ variables["filters"] = input_d
+ data = self.gql_request(query, variables=variables)
+ assert (
+ data["owner"]["repository"]["commit"]["bundleAnalysis"][
+ "bundleAnalysisReport"
+ ]["bundle"]
+ is not None
+ )
+ asset_reports_mock.assert_called_with(**output_d)
+
+ def test_compare_with_parent_missing_change_coverage(self):
+ CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ )
+ ReportLevelTotalsFactory(
+ report=CommitReportFactory(commit=self.parent_commit),
+ coverage=75.0,
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ branches=0,
+ methods=0,
+ )
+
+ query = (
+ query_commit % "compareWithParent { ... on Comparison { changeCoverage } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["compareWithParent"]["changeCoverage"] is None
+
+ def test_commit_yaml_errors(self):
+ CommitErrorFactory(commit=self.commit, error_code="invalid_yaml")
+ CommitErrorFactory(commit=self.commit, error_code="yaml_client_error")
+ query = (
+ query_commit
+ % "errors(errorType: YAML_ERROR) { edges { node { errorCode } } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ errors = paginate_connection(commit["errors"])
+ assert errors == [
+ {"errorCode": "invalid_yaml"},
+ {"errorCode": "yaml_client_error"},
+ ]
+
+ def test_commit_bot_errors(self):
+ CommitErrorFactory(commit=self.commit, error_code="repo_bot_invalid")
+ CommitErrorFactory(commit=self.commit, error_code="repo_bot_invalid")
+ query = (
+ query_commit
+ % "errors(errorType: BOT_ERROR) { edges { node { errorCode } } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ errors = paginate_connection(commit["errors"])
+ assert errors == [
+ {"errorCode": "repo_bot_invalid"},
+ {"errorCode": "repo_bot_invalid"},
+ ]
+
+ def test_fetch_upload_name(self):
+ UploadFactory(
+ name="First Upload",
+ report=self.report,
+ job_code=123,
+ build_code=456,
+ )
+ query = query_commit % "uploads { edges { node { name } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+ assert uploads == [
+ {
+ "name": "First Upload",
+ }
+ ]
+
+ @patch("services.comparison.Comparison.validate")
+ def test_has_different_number_of_head_and_base_reports_with_invalid_comparison(
+ self, mock_compare_validate
+ ):
+ CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ )
+ mock_compare_validate.side_effect = MissingComparisonReport()
+ query = (
+ query_commit
+ % "compareWithParent { ... on Comparison { hasDifferentNumberOfHeadAndBaseReports } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "compareWithParent": {
+ "hasDifferentNumberOfHeadAndBaseReports": False
+ }
+ }
+ }
+ }
+ }
+
+ def test_fetch_upload_name_is_none(self):
+ UploadFactory(
+ report=self.report,
+ job_code=123,
+ build_code=456,
+ )
+ query = query_commit % "uploads { edges { node { name } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ uploads = paginate_connection(commit["uploads"])
+ assert uploads == [
+ {
+ "name": None,
+ }
+ ]
+
+ def test_fetch_uploads_number(self):
+ for i in range(25):
+ UploadFactory(
+ report=self.report,
+ job_code=123,
+ build_code=456,
+ )
+ query = query_commit % "totalUploads"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data["owner"]["repository"]["commit"]["totalUploads"] == 25
+
+ def test_fetch_all_uploads_is_the_default(self):
+ for i in range(100):
+ UploadFactory(
+ report=self.report,
+ job_code=123,
+ build_code=456,
+ )
+ query = query_commit % "uploads { edges { node { state } } }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert len(data["owner"]["repository"]["commit"]["uploads"]["edges"]) == 100
+
+ def test_fetch_paginated_uploads(self):
+ for i in range(99):
+ UploadFactory(
+ report=self.report,
+ job_code=123,
+ build_code=456,
+ )
+ query = (
+ query_commit
+ % "totalUploads, uploads(first: 25) { edges { node { state } } }"
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert (data["owner"]["repository"]["commit"]["totalUploads"]) == 99
+ assert len(data["owner"]["repository"]["commit"]["uploads"]["edges"]) == 25
+
+ def test_fetch_commit_status_no_reports(self):
+ query = (
+ query_commit
+ % """
+ coverageStatus
+ bundleStatus
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageStatus"] is None
+ assert commit["bundleStatus"] is None
+
+ def test_fetch_commit_status_no_sessions(self):
+ CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+ CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ query = (
+ query_commit
+ % """
+ coverageStatus
+ bundleStatus
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageStatus"] is None
+ assert commit["bundleStatus"] is None
+
+ def test_fetch_commit_status_completed(self):
+ coverage_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=coverage_report, state="processed")
+ UploadFactory(report=coverage_report, state="processed")
+ UploadFactory(report=coverage_report, state="fully_overwritten")
+ UploadFactory(report=coverage_report, state="partially_overwritten")
+
+ ba_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ UploadFactory(report=ba_report, state="processed")
+ UploadFactory(report=ba_report, state="processed")
+ UploadFactory(report=ba_report, state="fully_overwritten")
+ UploadFactory(report=ba_report, state="partially_overwritten")
+
+ query = (
+ query_commit
+ % """
+ coverageStatus
+ bundleStatus
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageStatus"] == CommitStatus.COMPLETED.value
+ assert commit["bundleStatus"] == CommitStatus.COMPLETED.value
+
+ def test_fetch_commit_status_error(self):
+ coverage_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=coverage_report, state="processed")
+ UploadFactory(report=coverage_report, state="error")
+ UploadFactory(report=coverage_report, state="uploaded")
+ UploadFactory(report=coverage_report, state="partially_overwritten")
+
+ ba_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ UploadFactory(report=ba_report, state="processed")
+ UploadFactory(report=ba_report, state="error")
+ UploadFactory(report=ba_report, state="uploaded")
+ UploadFactory(report=ba_report, state="partially_overwritten")
+
+ query = (
+ query_commit
+ % """
+ coverageStatus
+ bundleStatus
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageStatus"] == CommitStatus.ERROR.value
+ assert commit["bundleStatus"] == CommitStatus.ERROR.value
+
+ def test_fetch_commit_status_pending(self):
+ coverage_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.COVERAGE
+ )
+ UploadFactory(report=coverage_report, state="processed")
+ UploadFactory(report=coverage_report, state="processed")
+ UploadFactory(report=coverage_report, state="uploaded")
+ UploadFactory(report=coverage_report, state="partially_overwritten")
+
+ ba_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ UploadFactory(report=ba_report, state="processed")
+ UploadFactory(report=ba_report, state="processed")
+ UploadFactory(report=ba_report, state="uploaded")
+ UploadFactory(report=ba_report, state="partially_overwritten")
+
+ query = (
+ query_commit
+ % """
+ coverageStatus
+ bundleStatus
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageStatus"] == CommitStatus.PENDING.value
+ assert commit["bundleStatus"] == CommitStatus.PENDING.value
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_gzip_size_total(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open(
+ "./services/tests/samples/head_bundle_report_with_gzip_size.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundles {
+ name
+ bundleData {
+ size {
+ gzip
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundles": [
+ {
+ # All assets non compressible
+ "name": "b1",
+ "bundleData": {
+ "size": {
+ "gzip": 20,
+ "uncompress": 20,
+ },
+ },
+ },
+ {
+ # Some assets non compressible
+ "name": "b2",
+ "bundleData": {
+ "size": {
+ "gzip": 198,
+ "uncompress": 200,
+ },
+ },
+ },
+ {
+ # All assets non compressible
+ "name": "b3",
+ "bundleData": {
+ "size": {
+ "gzip": 1495,
+ "uncompress": 1500,
+ },
+ },
+ },
+ {
+ # All assets non compressible
+ "name": "b5",
+ "bundleData": {
+ "size": {
+ "gzip": 199995,
+ "uncompress": 200000,
+ },
+ },
+ },
+ ],
+ }
+
+ def test_coverage_totals(self):
+ ReportLevelTotalsFactory(report=self.report, coverage=12)
+ query = query_commit % "coverageAnalytics { totals { percentCovered } } "
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["coverageAnalytics"]["totals"]["percentCovered"] == 12
+
+ @patch("core.commands.commit.commit.CommitCommands.get_file_content")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_coverage_coverage_file(
+ self,
+ report_mock,
+ content_mock,
+ ):
+ query = (
+ query_commit
+ % 'coverageAnalytics { coverageFile(path: "path") { hashedPath, content, coverage { line,coverage }, totals { percentCovered } } }'
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ fake_coverage = {
+ "content": "file content",
+ "coverage": [
+ {"line": 0, "coverage": "P"},
+ {"line": 1, "coverage": "H"},
+ {"line": 2, "coverage": "M"},
+ ],
+ "totals": {"percentCovered": 83.0},
+ }
+ content_mock.return_value = "file content"
+
+ report_mock.return_value = MockReport()
+ data = self.gql_request(query, variables=variables)
+ coverageFile = data["owner"]["repository"]["commit"]["coverageAnalytics"][
+ "coverageFile"
+ ]
+ assert coverageFile["content"] == fake_coverage["content"]
+ assert coverageFile["coverage"] == fake_coverage["coverage"]
+ assert coverageFile["totals"] == fake_coverage["totals"]
+ assert coverageFile["hashedPath"] == hashlib.md5("path".encode()).hexdigest()
+
+ @patch("services.components.component_filtered_report")
+ @patch("services.components.commit_components")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_coverage_coverage_file_with_components(
+ self, report_mock, commit_components_mock, filtered_mock
+ ):
+ components = ["Global"]
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ "components": components,
+ }
+
+ report_mock.return_value = MockReport()
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "c1",
+ "name": "ComponentOne",
+ "paths": ["fileA.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "c2",
+ "name": "ComponentTwo",
+ "paths": ["fileB.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "global",
+ "name": "Global",
+ "paths": ["**/*.py"],
+ }
+ ),
+ ]
+ filtered_mock.return_value = MockReport()
+
+ query_files = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!, $components: [String!]!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ coverageAnalytics {
+ coverageFile(path: "path", components: $components) {
+ hashedPath, content, coverage { line,coverage }, totals { percentCovered }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ data = self.gql_request(query_files, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "coverageFile": {
+ "content": None,
+ "coverage": [
+ {"coverage": "P", "line": 0},
+ {"coverage": "H", "line": 1},
+ {"coverage": "M", "line": 2},
+ ],
+ "hashedPath": "d6fe1d0be6347b8ef2427fa629c04485",
+ "totals": {"percentCovered": 83.0},
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @patch("core.commands.commit.commit.CommitCommands.get_file_content")
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_fetch_commit_coverage_with_no_coverage_data(
+ self,
+ report_mock,
+ content_mock,
+ ):
+ query = (
+ query_commit
+ % 'coverageAnalytics { coverageFile(path: "path") { content, coverage { line,coverage }, totals { percentCovered } }}'
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ fake_coverage = {"content": "file content", "coverage": [], "totals": None}
+ content_mock.return_value = "file content"
+
+ report_mock.return_value = EmptyReport()
+ data = self.gql_request(query, variables=variables)
+ coverageFile = data["owner"]["repository"]["commit"]["coverageAnalytics"][
+ "coverageFile"
+ ]
+ assert coverageFile["content"] == fake_coverage["content"]
+ assert coverageFile["coverage"] == fake_coverage["coverage"]
+ assert coverageFile["totals"] == fake_coverage["totals"]
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_coverage_flag_names(self, report_mock):
+ query = query_commit % "coverageAnalytics { flagNames }"
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "path": "path",
+ }
+ report_mock.return_value = MockReport()
+ data = self.gql_request(query, variables=variables)
+ flags = data["owner"]["repository"]["commit"]["coverageAnalytics"]["flagNames"]
+ assert flags == ["flag_a", "flag_b"]
+
+ def test_coverage_bundle_analysis_missing_report(self):
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on MissingHeadReport {
+ message
+ }
+ }
+ }
+ """
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "MissingHeadReport",
+ "message": "Missing head report",
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_coverage_bundle_analysis_report(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundles {
+ name
+ assets {
+ normalizedName
+ }
+ asset(name: "not_exist") {
+ normalizedName
+ }
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ isCached
+ }
+ bundleData {
+ loadTime {
+ threeG
+ highSpeed
+ }
+ size {
+ gzip
+ uncompress
+ }
+ }
+ bundle(name: "not_exist") {
+ name
+ isCached
+ }
+ isCached
+ }
+ ... on MissingHeadReport {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundles": [
+ {
+ "name": "b1",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 0,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 20,
+ },
+ },
+ "isCached": False,
+ },
+ {
+ "name": "b2",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 200,
+ },
+ },
+ "isCached": False,
+ },
+ {
+ "name": "b3",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 16,
+ "highSpeed": 0,
+ },
+ "size": {
+ "gzip": 0,
+ "uncompress": 1500,
+ },
+ },
+ "isCached": False,
+ },
+ {
+ "name": "b5",
+ "assets": [
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/index-*.js"},
+ {"normalizedName": "assets/LazyComponent-*.js"},
+ {"normalizedName": "assets/index-*.css"},
+ {"normalizedName": "assets/react-*.svg"},
+ ],
+ "asset": None,
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2133,
+ "highSpeed": 53,
+ },
+ "size": {
+ "gzip": 200,
+ "uncompress": 200000,
+ },
+ },
+ "isCached": False,
+ },
+ ],
+ "bundleData": {
+ "loadTime": {
+ "threeG": 2151,
+ "highSpeed": 53,
+ },
+ "size": {
+ "gzip": 201,
+ "uncompress": 201720,
+ },
+ },
+ "bundle": None,
+ "isCached": False,
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_coverage_bundle_analysis_compare(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ base_commit_report = CommitReportFactory(
+ commit=self.parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = (
+ query_commit
+ % """
+ bundleAnalysis {
+ bundleAnalysisCompareWithParent {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundles {
+ name
+ changeType
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ """
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["bundleAnalysis"]["bundleAnalysisCompareWithParent"] == {
+ "__typename": "BundleAnalysisComparison",
+ "bundles": [
+ {
+ "name": "b1",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 20}},
+ "bundleChange": {"size": {"uncompress": 5}},
+ },
+ {
+ "name": "b2",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 200}},
+ "bundleChange": {"size": {"uncompress": 50}},
+ },
+ {
+ "name": "b3",
+ "changeType": "added",
+ "bundleData": {"size": {"uncompress": 1500}},
+ "bundleChange": {"size": {"uncompress": 1500}},
+ },
+ {
+ "name": "b5",
+ "changeType": "changed",
+ "bundleData": {"size": {"uncompress": 200000}},
+ "bundleChange": {"size": {"uncompress": 50000}},
+ },
+ {
+ "name": "b4",
+ "changeType": "removed",
+ "bundleData": {"size": {"uncompress": 0}},
+ "bundleChange": {"size": {"uncompress": -15000}},
+ },
+ ],
+ "bundleData": {"size": {"uncompress": 201720}},
+ "bundleChange": {"size": {"uncompress": 36555}},
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_size_filtered(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ with open(
+ "./services/tests/samples/head_bundle_report_with_gzip_size.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!, $bundleFilters: BundleReportFilters) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name:"b1") {
+ name
+ bundleDataFiltered(filters: $bundleFilters) {
+ size {
+ gzip
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "bundleFilters": {"reportGroup": "JAVASCRIPT"},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "name": "b1",
+ "bundleDataFiltered": {"size": {"gzip": 6, "uncompress": 6}},
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_size_filtered_no_value(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+ with open(
+ "./services/tests/samples/head_bundle_report_with_gzip_size.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!, $bundleFilters: BundleReportFilters) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name:"b1") {
+ name
+ bundleDataFiltered(filters: $bundleFilters) {
+ size {
+ gzip
+ uncompress
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "bundleFilters": {},
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+ assert commit["bundleAnalysis"]["bundleAnalysisReport"] == {
+ "__typename": "BundleAnalysisReport",
+ "bundle": {
+ "name": "b1",
+ "bundleDataFiltered": {"size": {"gzip": 20, "uncompress": 20}},
+ },
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_asset_routes(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open(
+ "./services/tests/samples/bundle_with_asset_routes.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "@codecov/example-sveltekit-app-client-esm") {
+ assets {
+ name
+ normalizedName
+ routes
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ asset_reports = commit["bundleAnalysis"]["bundleAnalysisReport"]["bundle"][
+ "assets"
+ ]
+
+ assert asset_reports == [
+ {
+ "name": "_app/immutable/assets/svelte-welcome.VNiyy3gC.png",
+ "normalizedName": "_app/immutable/assets/svelte-welcome.*.png",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/svelte-welcome.0pIiHnVF.webp",
+ "normalizedName": "_app/immutable/assets/svelte-welcome.*.webp",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-all-400-normal.B2mvLtSD.woff",
+ "normalizedName": "_app/immutable/assets/fira-mono-all-400-normal.*.woff",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/chunks/entry.BaWB2kHj.js",
+ "normalizedName": "_app/immutable/chunks/entry.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/4.CcjRtXvw.js",
+ "normalizedName": "_app/immutable/nodes/4.*.js",
+ "routes": ["/sverdle"],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-latin-400-normal.DKjLVgQi.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-latin-400-normal.*.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-cyrillic-ext-400-normal.B04YIrm4.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-cyrillic-ext-400-normal.*.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-latin-ext-400-normal.D6XfiR-_.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-latin-ext-400-normal.D6XfiR-_.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-greek-400-normal.C3zng6O6.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-greek-400-normal.*.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-cyrillic-400-normal.36-45Uyg.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-cyrillic-400-normal.*.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/0.CL_S-12h.js",
+ "normalizedName": "_app/immutable/nodes/0.CL_S-12h.js",
+ "routes": ["/"],
+ },
+ {
+ "name": "_app/immutable/assets/fira-mono-greek-ext-400-normal.CsqI23CO.woff2",
+ "normalizedName": "_app/immutable/assets/fira-mono-greek-ext-400-normal.*.woff2",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/chunks/index.DDRweiI9.js",
+ "normalizedName": "_app/immutable/chunks/index.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/entry/app.Dd9ByE1Q.js",
+ "normalizedName": "_app/immutable/entry/app.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/2.BMQFqo-e.js",
+ "normalizedName": "_app/immutable/nodes/2.*.js",
+ "routes": ["/"],
+ },
+ {
+ "name": "_app/immutable/assets/0.CT0x_Q5c.css",
+ "normalizedName": "_app/immutable/assets/0.CT0x_Q5c.css",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/4.DOkkq0IA.css",
+ "normalizedName": "_app/immutable/assets/4.*.css",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/5.CwxmUzn6.js",
+ "normalizedName": "_app/immutable/nodes/5.*.js",
+ "routes": ["/sverdle/how-to-play"],
+ },
+ {
+ "name": "_app/immutable/chunks/scheduler.Dk-snqIU.js",
+ "normalizedName": "_app/immutable/chunks/scheduler.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/3.BqQOub2U.js",
+ "normalizedName": "_app/immutable/nodes/3.*.js",
+ "routes": ["/about"],
+ },
+ {
+ "name": "_app/immutable/assets/2.Cs8KR-Bb.css",
+ "normalizedName": "_app/immutable/assets/2.*.css",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/nodes/1.stWWSe4n.js",
+ "normalizedName": "_app/immutable/nodes/1.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/assets/5.CU6psp88.css",
+ "normalizedName": "_app/immutable/assets/5.*.css",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/chunks/index.Ice1EKvx.js",
+ "normalizedName": "_app/immutable/chunks/index.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/chunks/stores.BrqGIpx3.js",
+ "normalizedName": "_app/immutable/chunks/stores.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/entry/start.B1Q1eB84.js",
+ "normalizedName": "_app/immutable/entry/start.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/immutable/chunks/index.R8ovVqwX.js",
+ "normalizedName": "_app/immutable/chunks/index.*.js",
+ "routes": [],
+ },
+ {
+ "name": "_app/version.json",
+ "normalizedName": "_app/version.json",
+ "routes": [],
+ },
+ ]
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report_info(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ head_commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ with open(
+ "./services/tests/samples/bundle_with_assets_and_modules.sqlite", "rb"
+ ) as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ bundleAnalysis {
+ bundleAnalysisReport {
+ __typename
+ ... on BundleAnalysisReport {
+ bundle(name: "b5") {
+ info {
+ version
+ pluginName
+ pluginVersion
+ builtAt
+ duration
+ bundlerName
+ bundlerVersion
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ bundle_info = commit["bundleAnalysis"]["bundleAnalysisReport"]["bundle"]["info"]
+
+ assert bundle_info["version"] == "1"
+ assert bundle_info["pluginName"] == "codecov-vite-bundle-analysis-plugin"
+ assert bundle_info["pluginVersion"] == "1.0.0"
+ assert bundle_info["builtAt"] == "2023-12-01 17:17:28.604000"
+ assert bundle_info["duration"] == 331
+ assert bundle_info["bundlerName"] == "rollup"
+ assert bundle_info["bundlerVersion"] == "3.29.4"
+
+ def test_latest_upload_error(self):
+ commit = CommitFactory(repository=self.repo)
+ report = CommitReportFactory(
+ commit=commit, report_type=CommitReport.ReportType.TEST_RESULTS
+ )
+ upload = UploadFactory(report=report)
+ UploadErrorFactory(
+ report_session=upload,
+ error_code=UploadErrorEnum.UNKNOWN_PROCESSING,
+ error_params={"error_message": "Unknown processing error"},
+ )
+
+ query = """
+ query FetchCommit($org: String!, $repo: String!, $commit: String!) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ latestUploadError {
+ errorCode
+ errorMessage
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": commit.commitid,
+ }
+ data = self.gql_request(query, variables=variables)
+ commit = data["owner"]["repository"]["commit"]
+
+ assert commit["latestUploadError"] == {
+ "errorCode": "UNKNOWN_PROCESSING",
+ "errorMessage": "Unknown processing error",
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_components.py b/apps/codecov-api/graphql_api/tests/test_components.py
new file mode 100644
index 0000000000..50c4d777c7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_components.py
@@ -0,0 +1,1494 @@
+from unittest.mock import PropertyMock, patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory, ComponentComparisonFactory
+from services.comparison import MissingComparisonReport
+from services.components import Component
+from timeseries.models import MeasurementName
+from timeseries.tests.factories import DatasetFactory, MeasurementFactory
+
+from .helper import GraphQLTestHelper
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+query_commit_components = """
+ query CommitComponents(
+ $org: String!
+ $repo: String!
+ $sha: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $sha) {
+ coverageAnalytics {
+ components {
+ id
+ name
+ totals {
+ hitsCount
+ missesCount
+ percentCovered
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+query_repo = """
+query Repo(
+ $org: String!
+ $repo: String!
+ $sha: String!
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ componentsMeasurementsActive
+ componentsMeasurementsBackfilled
+ componentsCount
+ }
+ commit(id: $sha) {
+ coverageAnalytics {
+ components {
+ id
+ name
+ totals {
+ hitsCount
+ missesCount
+ percentCovered
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+query_commit_coverage_components = """
+ query CommitComponents(
+ $org: String!
+ $repo: String!
+ $sha: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $sha) {
+ coverageAnalytics {
+ components {
+ id
+ name
+ totals {
+ hitsCount
+ missesCount
+ percentCovered
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+
+class TestCommitCoverageComponents(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.commit = CommitFactory(repository=self.repo)
+
+ def test_no_components(self):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ }
+ data = self.gql_request(
+ query_commit_coverage_components, variables=variables, owner=OwnerFactory()
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [],
+ }
+ }
+ }
+ }
+ }
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("services.components.commit_components")
+ def test_components(self, commit_components_mock, full_report_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "python",
+ "paths": [".*/*.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ ),
+ ]
+
+ full_report_mock.return_value = sample_report()
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ }
+ data = self.gql_request(
+ query_commit_coverage_components,
+ variables=variables,
+ owner=OwnerFactory(),
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "id": "python",
+ "name": "python",
+ "totals": {
+ "hitsCount": 1,
+ "missesCount": 0,
+ "percentCovered": 50.0,
+ },
+ },
+ {
+ "id": "golang",
+ "name": "golang",
+ "totals": {
+ "hitsCount": 5,
+ "missesCount": 3,
+ "percentCovered": 62.5,
+ },
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("services.components.commit_components")
+ def test_components_filtering(self, commit_components_mock, full_report_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "python1.1",
+ "name": "Python",
+ "paths": [".*/*.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "golang1.2",
+ "name": "Golang",
+ "paths": [".*/*.go"],
+ }
+ ),
+ ]
+
+ full_report_mock.return_value = sample_report()
+
+ query_commit_coverage_components = """
+ query CommitComponents(
+ $org: String!
+ $repo: String!
+ $sha: String!
+ $filter: ComponentsFilters
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $sha) {
+ coverageAnalytics {
+ components (filters: $filter) {
+ id
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # Find one item
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ "filter": {"components": ["Python"]},
+ }
+ data = self.gql_request(
+ query_commit_coverage_components, variables=variables, owner=OwnerFactory()
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "id": "python1.1",
+ "name": "Python",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ # Find no items
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ "filter": {"components": ["C++"]},
+ }
+ data = self.gql_request(query_commit_coverage_components, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {"commit": {"coverageAnalytics": {"components": []}}}
+ }
+ }
+
+ # Find all items
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ "filter": {"components": []},
+ }
+ data = self.gql_request(query_commit_coverage_components, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "id": "python1.1",
+ "name": "Python",
+ },
+ {
+ "id": "golang1.2",
+ "name": "Golang",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ # Find some items
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ "filter": {"components": ["C", "Golang"]},
+ }
+ data = self.gql_request(
+ query_commit_coverage_components, variables=variables, owner=OwnerFactory()
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "id": "golang1.2",
+ "name": "Golang",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("services.components.commit_components")
+ def test_components_filtering_case_insensitive(
+ self, commit_components_mock, full_report_mock
+ ):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "cpython",
+ "name": "PyThOn",
+ "paths": [".*/*.py"],
+ }
+ ),
+ ]
+
+ full_report_mock.return_value = sample_report()
+
+ query_commit_coverage_components = """
+ query CommitComponents(
+ $org: String!
+ $repo: String!
+ $sha: String!
+ $filter: ComponentsFilters
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $sha) {
+ coverageAnalytics {
+ components (filters: $filter) {
+ id
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ "filter": {"components": ["pYtHoN"]},
+ }
+ data = self.gql_request(
+ query_commit_coverage_components, variables=variables, owner=OwnerFactory()
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "id": "cpython",
+ "name": "PyThOn",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+
+query_components_comparison = """
+ query ComponentsComparison(
+ $org: String!
+ $repo: String!
+ $pullid: Int!
+ $filters: ComponentsFilters
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ pull(id: $pullid) {
+ compareWithBase {
+ __typename
+ ... on Comparison {
+ componentComparisonsCount
+ componentComparisons(filters: $filters) {
+ id
+ name
+ baseTotals {
+ percentCovered
+ }
+ headTotals {
+ percentCovered
+ }
+ patchTotals {
+ percentCovered
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+
+class TestComponentsComparison(GraphQLTestHelper, TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.base = CommitFactory(repository=self.repo)
+ self.head = CommitFactory(
+ repository=self.repo, parent_commit_id=self.base.commitid
+ )
+ self.pull = PullFactory(
+ pullid=2,
+ repository=self.repo,
+ base=self.base.commitid,
+ head=self.head.commitid,
+ compared_to=self.base.commitid,
+ )
+ self.comparison = CommitComparisonFactory(
+ base_commit=self.base,
+ compare_commit=self.head,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ )
+ self.commit = CommitFactory(repository=self.repo)
+
+ # mock reports
+ self.head_report_patcher = patch(
+ "services.comparison.Comparison.head_report", new_callable=PropertyMock
+ )
+ self.head_report = self.head_report_patcher.start()
+ self.head_report.return_value = sample_report()
+ self.addCleanup(self.head_report_patcher.stop)
+ self.base_report_patcher = patch(
+ "services.comparison.Comparison.base_report", new_callable=PropertyMock
+ )
+ self.base_report = self.base_report_patcher.start()
+ self.base_report.return_value = sample_report()
+ self.addCleanup(self.base_report_patcher.stop)
+
+ def test_no_components_in_pull_request(self):
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisons": [],
+ "componentComparisonsCount": 0,
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ def test_components_invalid_comparison_object(self, mock_compare_validate):
+ mock_compare_validate.side_effect = MissingComparisonReport()
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisons": [],
+ "componentComparisonsCount": 0,
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.commit_components")
+ def test_components(self, commit_components_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {
+ "component_id": "python",
+ "paths": [".*/*.py"],
+ }
+ ),
+ Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ ),
+ ]
+
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="python",
+ )
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="golang",
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisonsCount": 2,
+ "componentComparisons": [
+ {
+ "id": "python",
+ "name": "python",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ {
+ "id": "golang",
+ "name": "golang",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.commit_components")
+ def test_components_filter(self, commit_components_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {"component_id": "python", "paths": [".*/*.py"], "name": "python"}
+ ),
+ Component.from_dict(
+ {"component_id": "golang", "paths": [".*/*.go"], "name": "golang"}
+ ),
+ ]
+
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="python",
+ )
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="golang",
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ "filters": {"components": ["python"]},
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisonsCount": 2,
+ "componentComparisons": [
+ {
+ "id": "python",
+ "name": "python",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.commit_components")
+ def test_components_multi_filter(self, commit_components_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {"component_id": "python", "paths": [".*/*.py"], "name": "python"}
+ ),
+ Component.from_dict(
+ {"component_id": "golang", "paths": [".*/*.go"], "name": "golang"}
+ ),
+ Component.from_dict(
+ {"component_id": "js", "paths": [".*/*.js"], "name": "javascript"}
+ ),
+ ]
+
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="python",
+ )
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="golang",
+ )
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="js",
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ "filters": {"components": ["python", "javascript"]},
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisonsCount": 3,
+ "componentComparisons": [
+ {
+ "id": "python",
+ "name": "python",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ {
+ "id": "js",
+ "name": "javascript",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.components.commit_components")
+ def test_components_filter_case_insensitive(self, commit_components_mock):
+ commit_components_mock.return_value = [
+ Component.from_dict(
+ {"component_id": "python1.1", "paths": [".*/*.py"], "name": "PYThon"}
+ ),
+ Component.from_dict(
+ {"component_id": "golang1.2", "paths": [".*/*.go"], "name": "GOLang"}
+ ),
+ ]
+
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="python1.1",
+ )
+ ComponentComparisonFactory(
+ commit_comparison=self.comparison,
+ component_id="golang1.2",
+ )
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pullid": self.pull.pullid,
+ "filters": {"components": ["PYThon", "golANG"]},
+ }
+ data = self.gql_request(query_components_comparison, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "componentComparisonsCount": 2,
+ "componentComparisons": [
+ {
+ "id": "python1.1",
+ "name": "PYThon",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ {
+ "id": "golang1.2",
+ "name": "GOLang",
+ "baseTotals": {"percentCovered": 72.92638},
+ "headTotals": {"percentCovered": 85.71429},
+ "patchTotals": {"percentCovered": 28.57143},
+ },
+ ],
+ }
+ }
+ }
+ }
+ }
+
+ def test_repository_components_metadata_inactive(self):
+ data = self.gql_request(
+ query_repo,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ },
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsActive"
+ ]
+ == False
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ def test_repository_components_metadata_active(self):
+ DatasetFactory(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ data = self.gql_request(
+ query_repo,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ },
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsActive"
+ ]
+ == True
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_repository_components_metadata_backfilled_true(self, is_backfilled):
+ is_backfilled.return_value = True
+
+ DatasetFactory(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ data = self.gql_request(
+ query_repo,
+ variables={
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "sha": self.commit.commitid,
+ },
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsActive"
+ ]
+ == True
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsBackfilled"
+ ]
+ == True
+ )
+
+
+query_component_measurements = """
+query ComponentMeasurements(
+ $name: String!
+ $repo: String!
+ $interval: MeasurementInterval!
+ $after: DateTime!
+ $before: DateTime!
+ $branch: String
+ $filters: ComponentMeasurementsSetFilters
+ $orderingDirection: OrderingDirection
+) {
+ owner(username: $name) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ components(filters: $filters, orderingDirection: $orderingDirection, after: $after, before: $before, branch: $branch, interval: $interval) {
+ __typename
+ ... on ComponentMeasurements {
+ name
+ percentCovered
+ percentChange
+ measurements {
+ avg
+ min
+ max
+ timestamp
+ }
+ lastUploaded
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class TestComponentMeasurements(GraphQLTestHelper, TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(
+ author=self.org,
+ private=False,
+ yaml={
+ "component_management": {
+ "default_rules": {},
+ "individual_components": [
+ {
+ "component_id": "python",
+ "name": "pythonName",
+ "paths": [".*/*.py"],
+ },
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ },
+ ],
+ }
+ },
+ )
+ self.commit = CommitFactory(repository=self.repo)
+
+ def test_component_measurements_with_measurements(self):
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=95.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ }
+ data = self.gql_request(query_component_measurements, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "golang",
+ "percentCovered": 90.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ },
+ {
+ "avg": 85.0,
+ "min": 85.0,
+ "max": 85.0,
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ },
+ {
+ "avg": 90.0,
+ "min": 85.0,
+ "max": 95.0,
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ },
+ ],
+ "lastUploaded": "2022-06-22T01:00:00+00:00",
+ },
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "pythonName",
+ "percentCovered": 80.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ },
+ {
+ "avg": 75.0,
+ "min": 75.0,
+ "max": 75.0,
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ },
+ {
+ "avg": 80.0,
+ "min": 75.0,
+ "max": 85.0,
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ },
+ ],
+ "lastUploaded": "2022-06-22T01:00:00+00:00",
+ },
+ ]
+ }
+ }
+ }
+ }
+
+ def test_component_measurements_no_measurements(self):
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ }
+ data = self.gql_request(query_component_measurements, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "golang",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ "lastUploaded": None,
+ },
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "pythonName",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ "lastUploaded": None,
+ },
+ ]
+ }
+ }
+ }
+ }
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_component_measurements_timeseries_not_enabled(self):
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ }
+ data = self.gql_request(query_component_measurements, variables=variables)
+ assert data == {
+ "owner": {"repository": {"coverageAnalytics": {"components": []}}}
+ }
+
+ def test_component_measurements_with_filter(self):
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=95.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ "filters": {"components": ["python"]},
+ }
+ data = self.gql_request(query_component_measurements, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "pythonName",
+ "percentCovered": 80.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ },
+ {
+ "avg": 75.0,
+ "min": 75.0,
+ "max": 75.0,
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ },
+ {
+ "avg": 80.0,
+ "min": 75.0,
+ "max": 85.0,
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ },
+ ],
+ "lastUploaded": "2022-06-22T01:00:00+00:00",
+ },
+ ]
+ }
+ }
+ }
+ }
+
+ def test_component_measurements_with_branch(self):
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=95.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ "branch": "dev",
+ }
+ data = self.gql_request(query_component_measurements, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "golang",
+ "percentCovered": 90.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ },
+ {
+ "avg": 85.0,
+ "min": 85.0,
+ "max": 85.0,
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ },
+ {
+ "avg": 90.0,
+ "min": 85.0,
+ "max": 95.0,
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ },
+ {
+ "avg": None,
+ "min": None,
+ "max": None,
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ },
+ ],
+ "lastUploaded": "2022-06-22T01:00:00+00:00",
+ },
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "pythonName",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ "lastUploaded": None,
+ },
+ ]
+ }
+ }
+ }
+ }
+
+ def test_component_measurements_id_fallback(self):
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id="python",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=95.0,
+ )
+ MeasurementFactory(
+ name="component_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="dev",
+ measurable_id="golang",
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+
+ query = """
+ query ComponentMeasurements(
+ $name: String!
+ $repo: String!
+ $interval: MeasurementInterval!
+ $after: DateTime!
+ $before: DateTime!
+ $branch: String
+ $filters: ComponentMeasurementsSetFilters
+ $orderingDirection: OrderingDirection
+ ) {
+ owner(username: $name) {
+ repository: repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ components(filters: $filters, orderingDirection: $orderingDirection, after: $after, before: $before, branch: $branch, interval: $interval) {
+ __typename
+ ... on ComponentMeasurements {
+ name
+ componentId
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {
+ "name": self.org.username,
+ "repo": self.repo.name,
+ "interval": "INTERVAL_1_DAY",
+ "after": timezone.datetime(2022, 6, 20),
+ "before": timezone.datetime(2022, 6, 23),
+ "branch": "dev",
+ }
+ data = self.gql_request(query, variables=variables)
+
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "components": [
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "golang",
+ "componentId": "golang",
+ },
+ {
+ "__typename": "ComponentMeasurements",
+ "name": "pythonName",
+ "componentId": "python",
+ },
+ ]
+ }
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_config.py b/apps/codecov-api/graphql_api/tests/test_config.py
new file mode 100644
index 0000000000..6aff4341c1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_config.py
@@ -0,0 +1,318 @@
+from datetime import datetime
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.license import LicenseInformation
+
+from .helper import GraphQLTestHelper
+
+
+class TestConfigType(GraphQLTestHelper, TestCase):
+ @override_settings(
+ GITHUB_CLIENT_ID="Github",
+ GITHUB_ENTERPRISE_CLIENT_ID="Github Enterprise",
+ GITLAB_CLIENT_ID="Gitlab",
+ GITLAB_ENTERPRISE_CLIENT_ID="Gitlab Enterprise",
+ BITBUCKET_CLIENT_ID="Bitbucket",
+ BITBUCKET_SERVER_CLIENT_ID="Bitbucket Server",
+ OKTA_OAUTH_CLIENT_ID="Okta",
+ )
+ def test_login_providers(self):
+ data = self.gql_request("query { config { loginProviders }}")
+ assert data == {
+ "config": {
+ "loginProviders": [
+ "GITHUB",
+ "GITHUB_ENTERPRISE",
+ "GITLAB",
+ "GITLAB_ENTERPRISE",
+ "BITBUCKET",
+ "BITBUCKET_SERVER",
+ "OKTA",
+ ],
+ },
+ }
+
+ @override_settings(
+ GITHUB_CLIENT_ID="Github",
+ GITHUB_ENTERPRISE_CLIENT_ID="Github Enterprise",
+ OKTA_OAUTH_CLIENT_ID="Okta",
+ DISABLE_GIT_BASED_LOGIN=True,
+ )
+ def test_login_providers_no_git(self):
+ data = self.gql_request("query { config { loginProviders }}")
+ assert data == {
+ "config": {
+ "loginProviders": [
+ "OKTA",
+ ],
+ },
+ }
+
+ def test_seats_used(self):
+ data = self.gql_request("query { config { seatsUsed }}")
+ assert data == {
+ "config": {
+ "seatsUsed": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.activated_owners")
+ def test_seats_used_self_hosted(self, activated_owners):
+ activated_owners.count.return_value = 1
+ data = self.gql_request("query { config { seatsUsed }}")
+ assert data == {
+ "config": {
+ "seatsUsed": 1,
+ },
+ }
+
+ def test_plan_auto_activate(self):
+ data = self.gql_request("query { config { planAutoActivate }}")
+ assert data == {
+ "config": {
+ "planAutoActivate": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.is_autoactivation_enabled")
+ def test_plan_auto_activate_self_hosted(self, is_autoactivation_enabled):
+ is_autoactivation_enabled.return_value = True
+
+ data = self.gql_request("query { config { planAutoActivate }}")
+ assert data == {
+ "config": {
+ "planAutoActivate": True,
+ },
+ }
+
+ def test_seats_limit(self):
+ data = self.gql_request("query { config { seatsLimit }}")
+ assert data == {
+ "config": {
+ "seatsLimit": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.license_seats")
+ def test_seats_limit_self_hosted(self, license_seats):
+ license_seats.return_value = 123
+ data = self.gql_request("query { config { seatsLimit }}")
+ assert data == {
+ "config": {
+ "seatsLimit": 123,
+ },
+ }
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_timeseries_enabled(self):
+ data = self.gql_request("query { config { isTimescaleEnabled }}")
+ assert data == {
+ "config": {
+ "isTimescaleEnabled": True,
+ },
+ }
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_timeseries_enabled_is_false(self):
+ data = self.gql_request("query { config { isTimescaleEnabled }}")
+ assert data == {
+ "config": {
+ "isTimescaleEnabled": False,
+ },
+ }
+
+ @override_settings(TIMESERIES_ENABLED="true")
+ def test_timeseries_enabled_is_true_string(self):
+ data = self.gql_request("query { config { isTimescaleEnabled }}")
+ assert data == {
+ "config": {
+ "isTimescaleEnabled": True,
+ },
+ }
+
+ @override_settings(TIMESERIES_ENABLED="false")
+ def test_timeseries_enabled_is_false_string(self):
+ data = self.gql_request("query { config { isTimescaleEnabled }}")
+ assert data == {
+ "config": {
+ "isTimescaleEnabled": False,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True, ADMINS_LIST=[])
+ def test_has_admins_empty_admins_list(self):
+ data = self.gql_request("query { config { hasAdmins }}")
+ assert data == {
+ "config": {
+ "hasAdmins": False,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_has_admins_enterprise_is_false(self):
+ data = self.gql_request("query { config { hasAdmins }}")
+ assert data == {
+ "config": {
+ "hasAdmins": None,
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=True, ADMINS_LIST=[{"service": "github", "username": "Imogen"}]
+ )
+ def test_has_admins_with_enterprise_and_admins(self):
+ data = self.gql_request("query { config { hasAdmins }}")
+ assert data == {
+ "config": {
+ "hasAdmins": True,
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=True,
+ GITHUB_ENTERPRISE_CLIENT_ID="Github",
+ GITHUB_ENTERPRISE_URL="https://github.example.com",
+ )
+ def test_resolve_github_enterprise_url(self):
+ data = self.gql_request("query { config { githubEnterpriseURL }}")
+ assert data == {
+ "config": {
+ "githubEnterpriseURL": "https://github.example.com",
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=False,
+ )
+ def test_resolve_null_github_enterprise_url(self):
+ data = self.gql_request("query { config { githubEnterpriseURL }}")
+ assert data == {
+ "config": {
+ "githubEnterpriseURL": None,
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=True,
+ GITLAB_ENTERPRISE_CLIENT_ID="Gitlab",
+ GITLAB_ENTERPRISE_URL="https://gitlab.example.com",
+ )
+ def test_resolve_gitlab_enterprise_url(self):
+ data = self.gql_request("query { config { gitlabEnterpriseURL }}")
+ assert data == {
+ "config": {
+ "gitlabEnterpriseURL": "https://gitlab.example.com",
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=False,
+ )
+ def test_resolve_null_gitlab_enterprise_url(self):
+ data = self.gql_request("query { config { gitlabEnterpriseURL }}")
+ assert data == {
+ "config": {
+ "gitlabEnterpriseURL": None,
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=True,
+ BITBUCKET_SERVER_CLIENT_ID="Bitbucket",
+ BITBUCKET_SERVER_URL="https://bitbucket.example.com",
+ )
+ def test_resolve_bitbucket_server_url(self):
+ data = self.gql_request("query { config { bitbucketServerURL }}")
+ assert data == {
+ "config": {
+ "bitbucketServerURL": "https://bitbucket.example.com",
+ },
+ }
+
+ @override_settings(
+ IS_ENTERPRISE=False,
+ )
+ def test_resolve_null_bitbucket_sever_url(self):
+ data = self.gql_request("query { config { bitbucketServerURL }}")
+ assert data == {
+ "config": {
+ "bitbucketServerURL": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=False)
+ def test_self_hosted_license_returns_null_if_not_enterprise(self):
+ data = self.gql_request(
+ "query { config { selfHostedLicense { expirationDate } } }"
+ )
+ assert data == {
+ "config": {
+ "selfHostedLicense": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_current_license")
+ def test_self_hosted_license_returns_null_if_invalid_license(self, license_mock):
+ license_mock.return_value = LicenseInformation(is_valid=False)
+ data = self.gql_request(
+ "query { config { selfHostedLicense { expirationDate } } }"
+ )
+ assert data == {
+ "config": {
+ "selfHostedLicense": None,
+ },
+ }
+
+ @override_settings(IS_ENTERPRISE=True)
+ @patch("services.self_hosted.get_current_license")
+ def test_self_hosted_license_returns_expiration_date_if_valid_license(
+ self, license_mock
+ ):
+ license_mock.return_value = LicenseInformation(
+ is_valid=True,
+ message=None,
+ url=None,
+ number_allowed_users=5,
+ number_allowed_repos=None,
+ expires=datetime.strptime("2020-05-09 00:00:00", "%Y-%m-%d %H:%M:%S"),
+ is_trial=True,
+ is_pr_billing=False,
+ )
+ data = self.gql_request(
+ "query { config { selfHostedLicense { expirationDate } } }"
+ )
+ assert data == {
+ "config": {
+ "selfHostedLicense": {"expirationDate": "2020-05-09T00:00:00"},
+ },
+ }
+
+ @override_settings(
+ GITHUB_CLIENT_ID="Github",
+ GITHUB_ENTERPRISE_CLIENT_ID="Github Enterprise",
+ GITLAB_CLIENT_ID="Gitlab",
+ GITLAB_ENTERPRISE_CLIENT_ID="Gitlab Enterprise",
+ BITBUCKET_CLIENT_ID="Bitbucket",
+ BITBUCKET_SERVER_CLIENT_ID="Bitbucket Server",
+ OKTA_OAUTH_CLIENT_ID="Okta",
+ DISABLE_GIT_BASED_LOGIN=True,
+ )
+ def test_sync_providers(self):
+ data = self.gql_request("query { config { syncProviders } }")
+ assert data == {
+ "config": {
+ "syncProviders": [
+ "GITHUB",
+ "GITHUB_ENTERPRISE",
+ "GITLAB",
+ "GITLAB_ENTERPRISE",
+ "BITBUCKET",
+ "BITBUCKET_SERVER",
+ ],
+ },
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_coverage_analytics.py b/apps/codecov-api/graphql_api/tests/test_coverage_analytics.py
new file mode 100644
index 0000000000..bf31fb819e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_coverage_analytics.py
@@ -0,0 +1,526 @@
+import datetime
+from typing import Any, Dict, Optional
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from billing.helpers import mock_all_plans_and_tiers
+from core.models import Commit, Repository
+from graphql_api.tests.helper import GraphQLTestHelper
+from graphql_api.types.coverage_analytics.coverage_analytics import (
+ CoverageAnalyticsProps,
+ resolve_coverage_analytics_result_type,
+)
+from graphql_api.types.errors.errors import NotFoundError
+
+
+class TestFetchCoverageAnalytics(GraphQLTestHelper, TestCase):
+ # SETUP
+ def setUp(self) -> None:
+ self.owner = OwnerFactory(username="codecov-user")
+ self.yaml = {"test": "test"}
+
+ # some field resolvers require access to postgres or timeseries db
+ databases = {"default", "timeseries"}
+
+ # HELPERS
+ def run_gql_query(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
+ owner = self.owner
+ # Use the gql_request method from the parent class (GraphQLTestHelper)
+ return super().gql_request(query=query, owner=owner, variables=variables)
+
+ def create_repository(self, name: str) -> Repository:
+ return RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ name=name,
+ yaml=self.yaml,
+ language="erlang",
+ languages=[],
+ )
+
+ @staticmethod
+ def create_commit(
+ repository: Repository,
+ coverage_totals: Dict[str, int],
+ timestamp: Optional[datetime.datetime] = None,
+ ) -> Commit:
+ if timestamp is None:
+ timestamp = timezone.now()
+ return CommitFactory(
+ repository=repository, totals=coverage_totals, timestamp=timestamp
+ )
+
+ query_builder = """
+ query Repository($name: String!){
+ me {
+ owner {
+ repository(name: $name) {
+ __typename
+ ... on Repository {
+ coverageAnalytics {
+ %s
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ }
+ """
+
+ # TESTS
+ def test_coverage_analytics_base_fields(self) -> None:
+ """Test case where to fetch coverage analytics fields"""
+
+ # Create repo, commit, and coverage data
+ repo = self.create_repository("myname")
+ hour_ago = timezone.make_aware(datetime.datetime(2020, 12, 31, 23, 0))
+ coverage_commit = self.create_commit(
+ repository=repo,
+ coverage_totals={"c": 75, "h": 30, "m": 10, "n": 40},
+ timestamp=hour_ago,
+ )
+ self.create_commit(repository=repo, coverage_totals={"c": 85})
+ repo.updatestamp = timezone.now()
+ repo.save()
+ self.assertTrue(repo.pk, "Repository should be saved and have a primary key.")
+
+ # Set up the GraphQL query and run
+ query = """
+ query CoverageAnalytics($owner: String!, $repo: String!) {
+ owner(username: $owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository {
+ name
+ coverageAnalytics {
+ percentCovered
+ commitSha
+ hits
+ misses
+ lines
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ """
+ variables = {"owner": self.owner.username, "repo": repo.name}
+ resp = self.run_gql_query(query=query, variables=variables)
+
+ # Assert the response matches
+ expected_response = {
+ "__typename": "Repository",
+ "name": repo.name,
+ "coverageAnalytics": {
+ "percentCovered": 75,
+ "commitSha": coverage_commit.commitid,
+ "hits": 30,
+ "misses": 10,
+ "lines": 40,
+ },
+ }
+ assert resp["owner"]["repository"] == expected_response
+
+ def test_coverage_analytics_base_fields_partial(self) -> None:
+ """Test case where the query only expects one of the fields in CoverageAnalytics"""
+
+ # Create repo and a single commit
+ repo = self.create_repository("testtest")
+ hour_ago = timezone.make_aware(datetime.datetime(2020, 12, 31, 23, 0))
+ self.create_commit(
+ repository=repo,
+ coverage_totals={"c": 75, "h": 30, "m": 10, "n": 40},
+ timestamp=hour_ago,
+ )
+ repo.updatestamp = timezone.now()
+ repo.save()
+ self.assertTrue(repo.pk, "Repository should be saved and have a primary key.")
+
+ # Set up the GraphQL query and run - requests only the `percentCovered` field
+ query = """
+ query CoverageAnalytics($owner: String!, $repo: String!) {
+ owner(username: $owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository {
+ coverageAnalytics {
+ percentCovered
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ """
+ variables = {"owner": "codecov-user", "repo": repo.name}
+ resp = self.run_gql_query(query=query, variables=variables)
+
+ # Assert the response matches the expected percentCovered value
+ assert resp["owner"]["repository"]["coverageAnalytics"]["percentCovered"] == 75
+
+ def test_coverage_analytics_no_commit(self) -> None:
+ """Test case where no commits exist for coverage data"""
+
+ # Create repo without commits
+ repo = self.create_repository("empty-repo")
+ repo.updatestamp = timezone.now()
+ repo.save()
+ self.assertTrue(repo.pk, "Repository should be saved and have a primary key.")
+
+ # Set up the GraphQL query and run
+ query = """
+ query CoverageAnalytics($owner: String!, $repo: String!) {
+ owner(username: $owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository {
+ coverageAnalytics {
+ percentCovered
+ commitSha
+ hits
+ misses
+ lines
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ """
+ variables = {"owner": "codecov-user", "repo": repo.name}
+ resp = self.run_gql_query(query=query, variables=variables)
+
+ # Assert the response matches the expected structure with `None` values
+ assert resp["owner"]["repository"]["coverageAnalytics"] == {
+ "percentCovered": None,
+ "commitSha": None,
+ "hits": None,
+ "misses": None,
+ "lines": None,
+ }
+
+ def test_coverage_analytics_resolves_to_error(self) -> None:
+ """Test case where the query resolves to an error (e.g., repository not found)"""
+
+ # Set up and run the query to simulate a repository that doesn't exist
+ query = """
+ query CoverageAnalytics($owner: String!, $repo: String!) {
+ owner(username: $owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository { # Use an inline fragment for the Repository type
+ coverageAnalytics {
+ percentCovered
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ """
+ variables = {"owner": "codecov-user", "repo": "non-existent-repo"}
+ coverage_data = self.run_gql_query(query=query, variables=variables)
+
+ # Assert that the response resolves to an error
+ assert coverage_data["owner"]["repository"]["__typename"] == "NotFoundError"
+ assert coverage_data["owner"]["repository"]["message"] == "Not found"
+
+ @freeze_time("2022-01-02")
+ def test_coverage_analytics_with_interval(self):
+ """Test with interval argument to fetch coverage data in a specific time range"""
+
+ mock_all_plans_and_tiers()
+ # Create data to populate the timeseries graph
+ repo = self.create_repository("test-repo")
+ one_day_ago = timezone.make_aware(datetime.datetime(2022, 1, 1, 0, 0))
+ self.create_commit(
+ repository=repo,
+ coverage_totals={"c": 65, "h": 20, "m": 5, "n": 25},
+ timestamp=one_day_ago,
+ )
+
+ two_days_ago = timezone.make_aware(datetime.datetime(2022, 1, 2, 0, 0))
+ self.create_commit(
+ repository=repo,
+ coverage_totals={"c": 75, "h": 30, "m": 10, "n": 40},
+ timestamp=two_days_ago,
+ )
+
+ repo.updatestamp = timezone.now()
+ repo.save()
+ self.assertTrue(repo.pk, "Repository should be saved and have a primary key.")
+
+ # Set up GraphQL query and run
+ query = """
+ query CoverageAnalytics($owner:String!, $repo: String!, $interval: MeasurementInterval!) {
+ owner(username:$owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository {
+ name
+ coverageAnalytics {
+ measurements(interval: $interval) {
+ timestamp
+ avg
+ min
+ max
+ }
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ """
+ variables = {
+ "owner": "codecov-user",
+ "repo": repo.name,
+ "interval": "INTERVAL_1_DAY",
+ }
+ resp = self.run_gql_query(query=query, variables=variables)
+
+ expected_response = {
+ "__typename": "Repository",
+ "name": repo.name,
+ "coverageAnalytics": {
+ "measurements": [
+ {
+ "avg": 65.0,
+ "max": 65.0,
+ "min": 65.0,
+ "timestamp": "2022-01-01T00:00:00+00:00",
+ },
+ {
+ "avg": 75.0,
+ "max": 75.0,
+ "min": 75.0,
+ "timestamp": "2022-01-02T00:00:00+00:00",
+ },
+ ]
+ },
+ }
+
+ assert resp["owner"]["repository"] == expected_response
+
+ def test_resolve_coverage_analytics_result_type_for_coverage_analytics_props(
+ self,
+ ) -> None:
+ """Test that the resolver returns 'CoverageAnalyticsProps' when passed a CoverageAnalyticsProps object"""
+ repo = self.create_repository("test")
+ coverage_analytics_props = CoverageAnalyticsProps(repository=repo)
+ result_type = resolve_coverage_analytics_result_type(coverage_analytics_props)
+ self.assertEqual(result_type, "CoverageAnalyticsProps")
+
+ def test_resolve_coverage_analytics_result_type_for_not_found_error(self) -> None:
+ """Test that the resolver returns 'NotFoundError' when passed a NotFoundError object"""
+ result_type = resolve_coverage_analytics_result_type(NotFoundError())
+ self.assertEqual(result_type, "NotFoundError")
+
+ def test_resolve_coverage_analytics_result_type_for_unexpected_type(self) -> None:
+ """Test that the resolver returns None when passed an object of an unexpected type"""
+ unexpected_object = "unexpected_string"
+ result_type = resolve_coverage_analytics_result_type(unexpected_object)
+ self.assertIsNone(result_type)
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_repository_flags_metadata(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user)
+ data = self.gql_request(
+ self.query_builder
+ % """
+ flagsMeasurementsActive
+ flagsMeasurementsBackfilled
+ """,
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "flagsMeasurementsActive"
+ ]
+ == False
+ )
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "flagsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_repository_components_metadata(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user)
+ data = self.gql_request(
+ self.query_builder
+ % """
+ componentsMeasurementsActive
+ componentsMeasurementsBackfilled
+ """,
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsActive"
+ ]
+ == False
+ )
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "componentsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ def test_repository_has_components_count(self):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={
+ "component_management": {
+ "default_rules": {},
+ "individual_components": [
+ {"component_id": "blah", "paths": [r".*\.go"]},
+ {"component_id": "cool_rules"},
+ ],
+ }
+ },
+ )
+
+ data = self.gql_request(
+ self.query_builder
+ % """
+ componentsCount
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"]["componentsCount"]
+ == 2
+ )
+
+ def test_repository_no_components_count(self):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ data = self.gql_request(
+ self.query_builder
+ % """
+ componentsCount
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert (
+ data["me"]["owner"]["repository"]["coverageAnalytics"]["componentsCount"]
+ == 0
+ )
+
+ def test_repository_components_select(self):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={
+ "component_management": {
+ "default_rules": {},
+ "individual_components": [
+ {
+ "component_id": "blah",
+ "paths": [r".*\.go"],
+ "name": "blah_name",
+ },
+ {"component_id": "cool_rules", "name": "cool_name"},
+ ],
+ }
+ },
+ )
+
+ data = self.gql_request(
+ self.query_builder
+ % """
+ componentsYaml(termId: null) {
+ id
+ name
+ }
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "componentsYaml"
+ ] == [
+ {"id": "blah", "name": "blah_name"},
+ {"id": "cool_rules", "name": "cool_name"},
+ ]
+
+ def test_repository_components_select_with_search(self):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={
+ "component_management": {
+ "default_rules": {},
+ "individual_components": [
+ {
+ "component_id": "blah",
+ "paths": [r".*\.go"],
+ "name": "blah_name",
+ },
+ {"component_id": "cool_rules", "name": "cool_name"},
+ ],
+ }
+ },
+ )
+
+ data = self.gql_request(
+ self.query_builder
+ % """
+ componentsYaml(termId: "blah") {
+ id
+ name
+ }
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["coverageAnalytics"][
+ "componentsYaml"
+ ] == [
+ {"id": "blah", "name": "blah_name"},
+ ]
diff --git a/apps/codecov-api/graphql_api/tests/test_coverage_analytics_measurements.py b/apps/codecov-api/graphql_api/tests/test_coverage_analytics_measurements.py
new file mode 100644
index 0000000000..dc7abc1a21
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_coverage_analytics_measurements.py
@@ -0,0 +1,141 @@
+import datetime
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from shared.django_apps.core.tests.factories import (
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from timeseries.models import Interval
+
+from .helper import GraphQLTestHelper
+
+
+@patch("timeseries.helpers.repository_coverage_measurements_with_fallback")
+class TestMeasurement(TestCase, GraphQLTestHelper):
+ def _request(self, variables=None):
+ query = f"""
+ query Measurements($branch: String) {{
+ owner(username: "{self.org.username}") {{
+ repository(name: "{self.repo.name}") {{
+ ... on Repository {{
+ coverageAnalytics {{
+ measurements(
+ interval: INTERVAL_1_DAY
+ after: "2022-01-01"
+ before: "2022-01-03"
+ branch: $branch
+ ) {{
+ timestamp
+ avg
+ min
+ max
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ """
+ data = self.gql_request(query, owner=self.owner, variables=variables)
+ return data["owner"]["repository"]["coverageAnalytics"]["measurements"]
+
+ def setUp(self):
+ self.org = OwnerFactory(username="test-org")
+ self.repo = RepositoryFactory(
+ name="test-repo",
+ author=self.org,
+ private=True,
+ )
+ self.owner = OwnerFactory(permission=[self.repo.pk])
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_measurements_timeseries_enabled(
+ self, repository_coverage_measurements_with_fallback
+ ):
+ repository_coverage_measurements_with_fallback.return_value = [
+ {
+ "timestamp_bin": datetime.datetime(2022, 1, 1),
+ "min": 1,
+ "max": 2,
+ "avg": 1.5,
+ },
+ {
+ "timestamp_bin": datetime.datetime(2022, 1, 2),
+ "min": 3,
+ "max": 4,
+ "avg": 3.5,
+ },
+ ]
+
+ assert self._request() == [
+ {"timestamp": "2022-01-01T00:00:00", "min": 1.0, "max": 2.0, "avg": 1.5},
+ {"timestamp": "2022-01-02T00:00:00", "min": 3.0, "max": 4.0, "avg": 3.5},
+ {
+ "timestamp": "2022-01-03T00:00:00+00:00",
+ "min": None,
+ "max": None,
+ "avg": None,
+ },
+ ]
+
+ repository_coverage_measurements_with_fallback.assert_called_once_with(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime.datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ branch=None,
+ )
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_measurements_timeseries_not_enabled(
+ self, repository_coverage_measurements_with_fallback
+ ):
+ repository_coverage_measurements_with_fallback.return_value = [
+ {
+ "timestamp_bin": datetime.datetime(2022, 1, 1),
+ "min": 1,
+ "max": 2,
+ "avg": 1.5,
+ },
+ {
+ "timestamp_bin": datetime.datetime(2022, 1, 2),
+ "min": 3,
+ "max": 4,
+ "avg": 3.5,
+ },
+ ]
+
+ assert self._request() == [
+ {"timestamp": "2022-01-01T00:00:00", "min": 1.0, "max": 2.0, "avg": 1.5},
+ {"timestamp": "2022-01-02T00:00:00", "min": 3.0, "max": 4.0, "avg": 3.5},
+ {
+ "timestamp": "2022-01-03T00:00:00+00:00",
+ "min": None,
+ "max": None,
+ "avg": None,
+ },
+ ]
+
+ repository_coverage_measurements_with_fallback.assert_called_once_with(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime.datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ branch=None,
+ )
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_measurements_branch(self, repository_coverage_measurements_with_fallback):
+ repository_coverage_measurements_with_fallback.return_value = []
+ self._request(variables={"branch": "foo"})
+
+ repository_coverage_measurements_with_fallback.assert_called_once_with(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime.datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ branch="foo",
+ )
diff --git a/apps/codecov-api/graphql_api/tests/test_current_user_ariadne.py b/apps/codecov-api/graphql_api/tests/test_current_user_ariadne.py
new file mode 100644
index 0000000000..d22aa9f3ba
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_current_user_ariadne.py
@@ -0,0 +1,579 @@
+import datetime
+from http.cookies import SimpleCookie
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ OktaSettingsFactory,
+ UserFactory,
+)
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from codecov_auth.models import OwnerProfile
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+
+class ArianeTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ random_owner = OwnerFactory(username="random-user")
+ RepositoryFactory(author=self.owner, active=True, private=True, name="a")
+ RepositoryFactory(author=self.owner, active=True, private=False, name="b")
+ RepositoryFactory(author=random_owner, active=True, private=True, name="not")
+ self.owner.organizations = [
+ OwnerFactory(username="codecov").ownerid,
+ OwnerFactory(username="facebook").ownerid,
+ OwnerFactory(username="spotify").ownerid,
+ ]
+ self.owner.save()
+
+ def test_when_unauthenticated(self):
+ query = "{ me { user { username }} }"
+ data = self.gql_request(query)
+ assert data == {"me": None}
+
+ def test_when_authenticated(self):
+ query = "{ me { user { username avatarUrl }} }"
+ data = self.gql_request(query, owner=self.owner)
+ assert data == {
+ "me": {
+ "user": {
+ "username": self.owner.username,
+ "avatarUrl": self.owner.avatar_url,
+ }
+ }
+ }
+
+ def test_when_tracking_metadata(self):
+ query = "{ me { trackingMetadata { ownerid } } }"
+ data = self.gql_request(query, owner=self.owner)
+ assert data == {"me": {"trackingMetadata": {"ownerid": self.owner.ownerid}}}
+
+ def test_when_tracking_metadata_profile(self):
+ query = """
+ {
+ me {
+ trackingMetadata {
+ ownerid
+ profile { goals }
+ }
+ }
+ }
+ """
+ OwnerProfile.objects.filter(owner_id=self.owner.ownerid).update(
+ goals=["IMPROVE_COVERAGE"]
+ )
+ data = self.gql_request(query, owner=self.owner)
+ assert data == {
+ "me": {
+ "trackingMetadata": {
+ "ownerid": self.owner.ownerid,
+ "profile": {"goals": ["IMPROVE_COVERAGE"]},
+ }
+ }
+ }
+
+ def test_when_tracking_metadata_no_profile(self):
+ query = """
+ {
+ me {
+ trackingMetadata {
+ ownerid
+ profile { goals }
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ assert data == {
+ "me": {
+ "trackingMetadata": {
+ "ownerid": self.owner.ownerid,
+ "profile": {"goals": []},
+ }
+ }
+ }
+
+ # Applies for old users that didn't get their owner profiles created w/ their owner
+ def test_when_owner_profile_doesnt_exist(self):
+ query = """
+ {
+ me {
+ trackingMetadata {
+ ownerid
+ profile { goals }
+ }
+ }
+ }
+ """
+ owner = OwnerFactory(username="another-user")
+ owner.profile.delete()
+ data = self.gql_request(query, owner=owner)
+ assert data == {
+ "me": {
+ "trackingMetadata": {
+ "ownerid": owner.ownerid,
+ "profile": None,
+ }
+ }
+ }
+
+ def test_private_access_when_private_access_field_is_null(self):
+ current_user = OwnerFactory(private_access=None)
+ query = """{
+ me {
+ privateAccess
+ }
+ }
+ """
+ data = self.gql_request(query, owner=current_user)
+ assert data == {"me": {"privateAccess": False}}
+
+ def test_private_access_when_private_access_field_is_false(self):
+ current_user = OwnerFactory(private_access=False)
+ query = """{
+ me {
+ privateAccess
+ }
+ }
+ """
+ data = self.gql_request(query, owner=current_user)
+ assert data == {"me": {"privateAccess": False}}
+
+ def test_private_access_when_private_access_field_is_true(self):
+ current_user = OwnerFactory(private_access=True)
+ query = """{
+ me {
+ privateAccess
+ }
+ }
+ """
+ data = self.gql_request(query, owner=current_user)
+ assert data == {"me": {"privateAccess": True}}
+
+ def test_fetch_terms_agreement_and_business_email_when_owner_profile_and_user_is_not_null(
+ self,
+ ):
+ current_user = OwnerFactory(
+ private_access=True, business_email="testEmail@gmail.com"
+ )
+ current_user.user.terms_agreement = True
+ current_user.user.save()
+ query = """{
+ me {
+ businessEmail
+ termsAgreement
+ }
+ }
+ """
+ data = self.gql_request(query, owner=current_user)
+ assert data == {
+ "me": {"businessEmail": "testEmail@gmail.com", "termsAgreement": True}
+ }
+
+ def test_fetch_terms_agreement_and_business_email_when_owner_profile_is_null(self):
+ current_user = OwnerFactory(private_access=True, business_email=None)
+ current_user.profile.delete()
+ query = """{
+ me {
+ businessEmail
+ termsAgreement
+ }
+ }
+ """
+ data = self.gql_request(query, owner=current_user)
+ assert data == {"me": {"businessEmail": None, "termsAgreement": False}}
+
+ def test_fetch_null_terms_agreement_for_user_without_owner(self):
+ # There is an edge where an owner without user can call the "me" endpoint
+ # via impersonation, in that case return null for terms agreement
+ owner_to_impersonate = OwnerFactory()
+ owner_to_impersonate.user.delete()
+ self.client.cookies = SimpleCookie({"staff_user": owner_to_impersonate.pk})
+ self.client.force_login(user=UserFactory(is_staff=True))
+
+ query = """{
+ me {
+ termsAgreement
+ }
+ }
+ """
+ res = self.client.post(
+ "/graphql/gh",
+ {"query": query},
+ content_type="application/json",
+ )
+ assert res.json()["data"]["me"] == {"termsAgreement": None}
+
+ def test_fetching_viewable_repositories(self):
+ org_1 = OwnerFactory()
+ org_2 = OwnerFactory()
+
+ authed_account = AccountFactory()
+ OktaSettingsFactory(account=authed_account, enforced=True)
+ okta_enforced_authenticated = OwnerFactory(account=authed_account)
+
+ unauthed_account = AccountFactory()
+ okta_enforced_org_unauth = OwnerFactory(account=unauthed_account)
+ OktaSettingsFactory(account=unauthed_account, enforced=True)
+
+ current_user = OwnerFactory(
+ organizations=[
+ org_1.ownerid,
+ okta_enforced_authenticated.ownerid,
+ okta_enforced_org_unauth.ownerid,
+ ]
+ )
+
+ repos_in_db = [
+ RepositoryFactory(private=True, name="0"),
+ RepositoryFactory(author=org_1, private=False, name="1"),
+ RepositoryFactory(author=org_1, private=True, name="2"),
+ RepositoryFactory(author=org_2, private=False, name="3"),
+ RepositoryFactory(author=org_2, private=True, name="4"),
+ RepositoryFactory(private=True, name="5"),
+ RepositoryFactory(author=current_user, private=True, name="6"),
+ RepositoryFactory(author=current_user, private=False, name="7"),
+ RepositoryFactory(
+ author=okta_enforced_authenticated,
+ private=True,
+ name="okta_enforced_repo_authed",
+ ),
+ RepositoryFactory(
+ author=okta_enforced_org_unauth,
+ private=True,
+ name="okta_enforced_repo_unauthed",
+ ),
+ ]
+ current_user.permission = [
+ repos_in_db[2].repoid,
+ repos_in_db[8].repoid,
+ repos_in_db[9].repoid,
+ ]
+ current_user.save()
+ query = """{
+ me {
+ viewableRepositories {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(
+ query, owner=current_user, okta_signed_in_accounts=[authed_account.id]
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ assert (
+ sorted(repos_name)
+ == [
+ "1", # public repo in org of user
+ "2", # private repo in org of user and in user permission
+ "6", # personal private repo
+ "7", # personal public repo
+ "okta_enforced_repo_authed", # private repo in org with Okta Enforced permissions
+ ]
+ )
+
+ # Test with impersonation
+ data = self.gql_request(query, owner=current_user, impersonate_owner=True)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ assert (
+ sorted(repos_name)
+ == [
+ "1", # public repo in org of user
+ "2", # private repo in org of user and in user permission
+ "6", # personal private repo
+ "7", # personal public repo
+ "okta_enforced_repo_authed", # Okta repo should show up for impersonated users
+ "okta_enforced_repo_unauthed", # Okta repo should show up for impersonated users
+ ]
+ )
+
+ def test_fetching_viewable_repositories_ordering(self):
+ current_user = OwnerFactory()
+ query = """
+ query MeOrderingRespoitories($orderingDirection: OrderingDirection, $ordering: RepositoryOrdering) {
+ me {
+ viewableRepositories(orderingDirection: $orderingDirection, ordering: $ordering) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ """
+
+ repo_1 = RepositoryFactory(author=current_user, name="A")
+ repo_2 = RepositoryFactory(author=current_user, name="B")
+ RepositoryFactory(author=current_user, name="C")
+
+ with self.subTest("No ordering (defaults to order by repoid)"):
+ with self.subTest("no ordering Direction"):
+ data = self.gql_request(query, owner=current_user)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("ASC"):
+ data = self.gql_request(
+ query, owner=current_user, variables={"orderingDirection": "ASC"}
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("DESC"):
+ data = self.gql_request(
+ query, owner=current_user, variables={"orderingDirection": "DESC"}
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["C", "B", "A"])
+
+ with self.subTest("NAME"):
+ with self.subTest("no ordering Direction"):
+ data = self.gql_request(
+ query, owner=current_user, variables={"ordering": "NAME"}
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("ASC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "NAME", "orderingDirection": "ASC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("DESC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "NAME", "orderingDirection": "DESC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["C", "B", "A"])
+
+ with self.subTest("COMMIT_DATE"):
+ with self.subTest("no ordering Direction"):
+ data = self.gql_request(
+ query, owner=current_user, variables={"ordering": "COMMIT_DATE"}
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("ASC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "COMMIT_DATE", "orderingDirection": "ASC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["A", "B", "C"])
+
+ with self.subTest("DESC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "COMMIT_DATE", "orderingDirection": "DESC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["C", "B", "A"])
+
+ with self.subTest("COVERAGE"):
+ hour_ago = datetime.datetime.now() - datetime.timedelta(hours=1)
+ CommitFactory(repository=repo_1, totals={"c": "42"}, timestamp=hour_ago)
+ CommitFactory(repository=repo_2, totals={"c": "100.2"}, timestamp=hour_ago)
+
+ # too recent, should not be considered
+ CommitFactory(repository=repo_2, totals={"c": "10"})
+
+ with self.subTest("no ordering Direction"):
+ data = self.gql_request(
+ query, owner=current_user, variables={"ordering": "COVERAGE"}
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["C", "A", "B"])
+
+ with self.subTest("ASC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "COVERAGE", "orderingDirection": "ASC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["C", "A", "B"])
+
+ with self.subTest("DESC"):
+ data = self.gql_request(
+ query,
+ owner=current_user,
+ variables={"ordering": "COVERAGE", "orderingDirection": "DESC"},
+ )
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ repos_name = [repo["name"] for repo in repos]
+ self.assertEqual(repos_name, ["B", "A", "C"])
+
+ def test_fetching_viewable_repositories_text_search(self):
+ query = """{
+ me {
+ viewableRepositories(filters: { term: "a"}) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ assert repos == [{"name": "a"}]
+
+ def test_fetching_viewable_repositories_with_repo_names_search(self):
+ query = """{
+ me {
+ viewableRepositories (filters: { repoNames: ["a", "b"] }) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ assert repos == [{"name": "a"}, {"name": "b"}]
+
+ def test_fetching_viewable_repositories_with_is_public(self):
+ query = """{{
+ me {{
+ viewableRepositories (filters: {{ {0} }}) {{
+ edges {{
+ node {{
+ name
+ }}
+ }}
+ }}
+ }}
+ }}
+ """
+ # isPublic=True (only public) -> b
+ data = self.gql_request(query.format("isPublic: true"), owner=self.owner)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ assert repos == [{"name": "b"}]
+ # isPublic=False (only private) -> a
+ data = self.gql_request(query.format("isPublic: false"), owner=self.owner)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ assert repos == [{"name": "a"}]
+ # isPublic is not specified (both public and private) -> a,b
+ data = self.gql_request(query.format(""), owner=self.owner)
+ repos = paginate_connection(data["me"]["viewableRepositories"])
+ assert repos == [{"name": "a"}, {"name": "b"}]
+
+ def test_fetching_my_orgs(self):
+ query = """{
+ me {
+ myOrganizations {
+ edges {
+ node {
+ username
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ orgs = paginate_connection(data["me"]["myOrganizations"])
+ assert orgs == [
+ {"username": "spotify"},
+ {"username": "facebook"},
+ {"username": "codecov"},
+ ]
+
+ def test_fetching_my_orgs_with_search(self):
+ query = """{
+ me {
+ myOrganizations(filters: { term: "spot"}) {
+ edges {
+ node {
+ username
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ orgs = paginate_connection(data["me"]["myOrganizations"])
+
+ assert orgs == [{"username": "spotify"}]
+
+ def test_sync_repo_not_authenticated(self):
+ mutation = """
+ mutation {
+ syncWithGitProvider {
+ error {
+ __typename
+ }
+ }
+ }
+ """
+ mutation_data = self.gql_request(mutation)
+ assert (
+ mutation_data["syncWithGitProvider"]["error"]["__typename"]
+ == "UnauthenticatedError"
+ )
+
+ @patch("codecov_auth.commands.owner.owner.OwnerCommands.is_syncing")
+ @patch("codecov_auth.commands.owner.owner.TriggerSyncInteractor.execute")
+ def test_sync_repo(self, mock_trigger_refresh, mock_is_refreshing):
+ mock_is_refreshing.return_value = True
+ query = """{
+ me {
+ isSyncingWithGitProvider
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.owner)
+ assert data["me"]["isSyncingWithGitProvider"] == True
+ mutation = """
+ mutation {
+ syncWithGitProvider {
+ error {
+ __typename
+ }
+ }
+ }
+ """
+ mutation_data = self.gql_request(mutation, owner=self.owner)
+ assert mutation_data["syncWithGitProvider"]["error"] is None
+ mock_trigger_refresh.assert_called()
diff --git a/apps/codecov-api/graphql_api/tests/test_flags.py b/apps/codecov-api/graphql_api/tests/test_flags.py
new file mode 100644
index 0000000000..d8b21d85a2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_flags.py
@@ -0,0 +1,847 @@
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from reports.tests.factories import RepositoryFlagFactory
+from timeseries.models import MeasurementName
+from timeseries.tests.factories import DatasetFactory, MeasurementFactory
+
+from .helper import GraphQLTestHelper
+
+query_flags = """
+query Flags(
+ $org: String!
+ $repo: String!
+ $measurementsAfter: DateTime!
+ $measurementsBefore: DateTime!
+ $measurementsInterval: MeasurementInterval!
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flagsCount
+ flags {
+ edges {
+ node {
+ ...FlagFragment
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fragment FlagFragment on Flag {
+ name
+ percentCovered
+ percentChange
+ measurements(
+ interval: $measurementsInterval
+ after: $measurementsAfter
+ before: $measurementsBefore
+ ) {
+ timestamp
+ avg
+ min
+ max
+ }
+}
+"""
+
+query_repo = """
+query Repo(
+ $org: String!
+ $repo: String!
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flagsCount
+ flagsMeasurementsActive
+ flagsMeasurementsBackfilled
+ flags {
+ edges {
+ node {
+ measurements(
+ interval: INTERVAL_1_DAY
+ after: "2022-01-01",
+ before: "2022-12-31",
+ ) {
+ timestamp
+ avg
+ min
+ max
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class TestFlags(GraphQLTestHelper, TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.commit = CommitFactory(repository=self.repo)
+
+ def test_fetch_flags_no_measurements(self):
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag3", deleted=True)
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "measurementsAfter": timezone.datetime(2022, 1, 1),
+ "measurementsBefore": timezone.datetime(2022, 12, 31),
+ "measurementsInterval": "INTERVAL_1_DAY",
+ }
+ data = self.gql_request(query_flags, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flagsCount": 2,
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ }
+ },
+ {
+ "node": {
+ "name": "flag2",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ }
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_fetch_flags_timeseries_not_enabled(self):
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag3", deleted=True)
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "measurementsAfter": timezone.datetime(2022, 1, 1),
+ "measurementsBefore": timezone.datetime(2022, 12, 31),
+ "measurementsInterval": "INTERVAL_1_DAY",
+ }
+ data = self.gql_request(query_flags, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flagsCount": 2,
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ }
+ },
+ {
+ "node": {
+ "name": "flag2",
+ "percentCovered": None,
+ "percentChange": None,
+ "measurements": [],
+ }
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_with_measurements(self):
+ flag1 = RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ flag2 = RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag3", deleted=True)
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag1.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag1.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=75.0,
+ )
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag1.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag2.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-21T00:00:00",
+ value=85.0,
+ )
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag2.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T00:00:00",
+ value=95.0,
+ )
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag2.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2022-06-22T01:00:00",
+ value=85.0,
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "measurementsAfter": timezone.datetime(2022, 6, 20),
+ "measurementsBefore": timezone.datetime(2022, 6, 23),
+ "measurementsInterval": "INTERVAL_1_DAY",
+ }
+ data = self.gql_request(query_flags, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flagsCount": 2,
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": 80.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ {
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ "avg": 75.0,
+ "min": 75.0,
+ "max": 75.0,
+ },
+ {
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ "avg": 80.0,
+ "min": 75.0,
+ "max": 85.0,
+ },
+ {
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ],
+ }
+ },
+ {
+ "node": {
+ "name": "flag2",
+ "percentCovered": 90.0,
+ "percentChange": 5.0,
+ "measurements": [
+ {
+ "timestamp": "2022-06-20T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ {
+ "timestamp": "2022-06-21T00:00:00+00:00",
+ "avg": 85.0,
+ "min": 85.0,
+ "max": 85.0,
+ },
+ {
+ "timestamp": "2022-06-22T00:00:00+00:00",
+ "avg": 90.0,
+ "min": 85.0,
+ "max": 95.0,
+ },
+ {
+ "timestamp": "2022-06-23T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ],
+ }
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_with_measurements_day_alignment_30day(self):
+ flag = RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2021-04-09T00:00:00",
+ value=75.0,
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "measurementsAfter": "2021-04-10T00:00:00", # this is in the middle of bin 1
+ "measurementsBefore": "2021-04-20T00:00:00", # this is in the middle of bin 2
+ "measurementsInterval": "INTERVAL_30_DAY",
+ }
+ data = self.gql_request(query_flags, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flagsCount": 1,
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": 75,
+ "percentChange": None,
+ "measurements": [
+ {
+ "timestamp": "2021-03-13T00:00:00+00:00",
+ "avg": 75.0,
+ "min": 75.0,
+ "max": 75.0,
+ },
+ {
+ "timestamp": "2021-04-12T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ],
+ }
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_with_measurements_day_alignment_7day(self):
+ flag = RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ MeasurementFactory(
+ name="flag_coverage",
+ owner_id=self.org.pk,
+ repo_id=self.repo.pk,
+ branch="main",
+ measurable_id=str(flag.pk),
+ commit_sha=self.commit.pk,
+ timestamp="2021-04-09T00:00:00",
+ value=75.0,
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "measurementsAfter": "2021-04-7T00:00:00", # this is in the middle of bin 1
+ "measurementsBefore": "2021-04-15T00:00:00", # this is in the middle of bin 2
+ "measurementsInterval": "INTERVAL_7_DAY",
+ }
+ data = self.gql_request(query_flags, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flagsCount": 1,
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": 75,
+ "percentChange": None,
+ "measurements": [
+ {
+ "timestamp": "2021-04-05T00:00:00+00:00",
+ "avg": 75.0,
+ "min": 75.0,
+ "max": 75.0,
+ },
+ {
+ "timestamp": "2021-04-12T00:00:00+00:00",
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ],
+ }
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_without_measurements(self):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags {
+ edges {
+ node {
+ name
+ percentCovered
+ percentChange
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ "percentCovered": None,
+ "percentChange": None,
+ }
+ },
+ {
+ "node": {
+ "name": "flag2",
+ "percentCovered": None,
+ "percentChange": None,
+ }
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_term_filter(self):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ $filters: FlagSetFilters!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags(filters: $filters) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ RepositoryFlagFactory(
+ repository=self.repo, flag_name="flag1-deleted", deleted=True
+ )
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "filters": {"term": "ag1"},
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ }
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_filter_by_flags_names(self):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ $filters: FlagSetFilters!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags(filters: $filters) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag3")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag4", deleted=True)
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "filters": {"flagsNames": ["flag1", "flag3", "flag4"]},
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ }
+ },
+ {
+ "node": {
+ "name": "flag3",
+ }
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_ordering_direction(self):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags(orderingDirection: DESC) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag2",
+ }
+ },
+ {
+ "node": {
+ "name": "flag1",
+ }
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ def test_fetch_flags_pagination(self):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ $after: String
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags(after: $after) {
+ edges {
+ node {
+ name
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag1",
+ },
+ "cursor": "ZmxhZzE=",
+ },
+ {
+ "node": {
+ "name": "flag2",
+ },
+ "cursor": "ZmxhZzI=",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "after": "ZmxhZzE=",
+ }
+ data = self.gql_request(query, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "coverageAnalytics": {
+ "flags": {
+ "edges": [
+ {
+ "node": {
+ "name": "flag2",
+ },
+ "cursor": "ZmxhZzI=",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ @patch("timeseries.models.MeasurementSummary.agg_by")
+ def test_fetch_flags_empty_lookahead(self, agg_by):
+ query = """
+ query Flags(
+ $org: String!
+ $repo: String!
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ coverageAnalytics {
+ flags {
+ __typename
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag1")
+ RepositoryFlagFactory(repository=self.repo, flag_name="flag2")
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ }
+ self.gql_request(query, variables=variables)
+ assert agg_by.call_count == 0
+
+ def test_repository_flags_metadata_inactive(self):
+ data = self.gql_request(
+ query_repo,
+ variables={"org": self.org.username, "repo": self.repo.name},
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"]["flagsMeasurementsActive"]
+ == False
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "flagsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ def test_repository_flags_metadata_active(self):
+ DatasetFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ data = self.gql_request(
+ query_repo,
+ variables={"org": self.org.username, "repo": self.repo.name},
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"]["flagsMeasurementsActive"]
+ == True
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "flagsMeasurementsBackfilled"
+ ]
+ == False
+ )
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_repository_flags_metadata_backfilled_true(self, is_backfilled):
+ is_backfilled.return_value = True
+
+ DatasetFactory(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ data = self.gql_request(
+ query_repo,
+ variables={"org": self.org.username, "repo": self.repo.name},
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"]["flagsMeasurementsActive"]
+ == True
+ )
+ assert (
+ data["owner"]["repository"]["coverageAnalytics"][
+ "flagsMeasurementsBackfilled"
+ ]
+ == True
+ )
diff --git a/apps/codecov-api/graphql_api/tests/test_impacted_file.py b/apps/codecov-api/graphql_api/tests/test_impacted_file.py
new file mode 100644
index 0000000000..f81ca5ac2f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_impacted_file.py
@@ -0,0 +1,860 @@
+import hashlib
+from dataclasses import dataclass, field
+from typing import Callable
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.torngit.exceptions import (
+ TorngitClientGeneralError,
+ TorngitObjectNotFoundError,
+)
+from shared.utils.sessions import Session
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory
+from services.comparison import ComparisonReport, ImpactedFile, MissingComparisonReport
+
+from .helper import GraphQLTestHelper
+
+query_impacted_files = """
+query ImpactedFiles(
+ $org: String!
+ $repo: String!
+ $commit: String!
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ compareWithParent {
+ ... on Comparison {
+ impactedFilesCount
+ indirectChangedFilesCount
+ impactedFiles {
+ ... on ImpactedFiles {
+ results {
+ fileName
+ headName
+ baseName
+ isNewFile
+ isRenamedFile
+ isDeletedFile
+ baseCoverage {
+ percentCovered
+ }
+ headCoverage {
+ percentCovered
+ }
+ patchCoverage {
+ percentCovered
+ }
+ changeCoverage
+ missesCount
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+"""
+
+query_direct_changed_files_count = """
+query ImpactedFiles(
+ $org: String!
+ $repo: String!
+ $commit: String!
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ compareWithParent {
+ ... on Comparison {
+ directChangedFilesCount
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+query_impacted_file_through_pull = """
+query ImpactedFile(
+ $org: String!
+ $repo: String!
+ $pull: Int!
+ $path: String!
+ $filters: SegmentsFilters
+) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ pull(id: $pull) {
+ compareWithBase {
+ ... on Comparison {
+ state
+ impactedFile(path: $path) {
+ headName
+ baseName
+ hashedPath
+ baseCoverage {
+ percentCovered
+ }
+ headCoverage {
+ percentCovered
+ }
+ patchCoverage {
+ percentCovered
+ }
+ segments(filters: $filters) {
+ ... on SegmentComparisons {
+ results {
+ hasUnintendedChanges
+ }
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+mock_data_from_archive = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"]
+ ],
+ "unexpected_line_changes": []
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"h"],
+ [13,"h"],
+ [14,"h"],
+ [15,"h"],
+ [16,"m"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "m"]]]
+ }]
+}
+"""
+
+
+@dataclass
+class MockSegment:
+ has_diff_changes: bool = False
+ has_unintended_changes: bool = False
+ remove_unintended_changes: Callable[[], None] = field(default=lambda: None)
+
+
+class MockFileComparison(object):
+ def __init__(self):
+ self.segments = [
+ MockSegment(has_unintended_changes=True, has_diff_changes=False),
+ MockSegment(has_unintended_changes=False, has_diff_changes=True),
+ MockSegment(has_unintended_changes=True, has_diff_changes=True),
+ ]
+
+
+def sample_report():
+ report = Report(flags={"flag1": True})
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ third_file = ReportFile("file3.py")
+ third_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.append(third_file)
+ report.add_session(Session(flags=["flag1"]))
+ return report
+
+
+class TestImpactedFileFiltering(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", private=False)
+ self.author = OwnerFactory()
+ self.parent_commit = CommitFactory(repository=self.repo)
+ self.commit = CommitFactory(
+ repository=self.repo,
+ totals={"c": "12", "diff": [0, 0, 0, 0, 0, "14"]},
+ parent_commit_id=self.parent_commit.commitid,
+ )
+ self.pull = PullFactory(
+ pullid=44,
+ repository=self.commit.repository,
+ head=self.commit.commitid,
+ base=self.parent_commit.commitid,
+ compared_to=self.parent_commit.commitid,
+ )
+ self.comparison = CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ report_storage_path="v4/test.json",
+ )
+ self.comparison_report = ComparisonReport(self.comparison)
+
+ self.query_impacted_files = """
+ query ImpactedFiles(
+ $org: String!
+ $repo: String!
+ $commit: String!
+ $filters: ImpactedFilesFilters
+ ) {
+ owner(username: $org) {
+ repository(name: $repo) {
+ ... on Repository {
+ commit(id: $commit) {
+ compareWithParent {
+ ... on Comparison {
+ impactedFiles(filters: $filters) {
+ ... on ImpactedFiles {
+ results {
+ fileName
+ }
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.comparison.Comparison.git_comparison")
+ def test_filtering_with_successful_flags(
+ self, git_comparison_mock, read_file, build_report_from_commit
+ ):
+ git_comparison_mock.return_value = None
+ build_report_from_commit.return_value = sample_report()
+ read_file.return_value = mock_data_from_archive
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "filters": {"flags": ["flag1"]},
+ }
+
+ data = self.gql_request(self.query_impacted_files, variables=variables)
+ assert (
+ "results"
+ in data["owner"]["repository"]["commit"]["compareWithParent"][
+ "impactedFiles"
+ ]
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ @patch("services.comparison.Comparison.git_comparison")
+ def test_filtering_with_unknown_flags(
+ self, git_comparison_mock, read_file, build_report_from_commit
+ ):
+ git_comparison_mock.return_value = None
+ build_report_from_commit.return_value = sample_report()
+ read_file.return_value = mock_data_from_archive
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ "filters": {"flags": ["fake_flag"]},
+ }
+
+ data = self.gql_request(self.query_impacted_files, variables=variables)
+ assert data["owner"]["repository"]["commit"]["compareWithParent"][
+ "impactedFiles"
+ ] == {"message": "No coverage with chosen flags"}
+
+
+class TestImpactedFile(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.org = OwnerFactory(username="codecov")
+ self.repo = RepositoryFactory(author=self.org, name="gazebo", private=False)
+ self.author = OwnerFactory()
+ self.parent_commit = CommitFactory(repository=self.repo)
+ self.commit = CommitFactory(
+ repository=self.repo,
+ totals={"c": "12", "diff": [0, 0, 0, 0, 0, "14"]},
+ parent_commit_id=self.parent_commit.commitid,
+ )
+ self.pull = PullFactory(
+ pullid=44,
+ repository=self.commit.repository,
+ head=self.commit.commitid,
+ base=self.parent_commit.commitid,
+ compared_to=self.parent_commit.commitid,
+ )
+ self.comparison = CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ report_storage_path="v4/test.json",
+ )
+ self.comparison_report = ComparisonReport(self.comparison)
+
+ # mock reports for all tests in this class
+ self.head_report_patcher = patch(
+ "services.comparison.Comparison.head_report", new_callable=PropertyMock
+ )
+ self.head_report = self.head_report_patcher.start()
+ self.head_report.return_value = None
+ self.addCleanup(self.head_report_patcher.stop)
+ self.base_report_patcher = patch(
+ "services.comparison.Comparison.base_report", new_callable=PropertyMock
+ )
+ self.base_report = self.base_report_patcher.start()
+ self.base_report.return_value = None
+ self.addCleanup(self.base_report_patcher.stop)
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_files(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(query_impacted_files, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "compareWithParent": {
+ "impactedFilesCount": 2,
+ "indirectChangedFilesCount": 1,
+ "impactedFiles": {
+ "results": [
+ {
+ "fileName": "fileA",
+ "headName": "fileA",
+ "baseName": "fileA",
+ "isNewFile": False,
+ "isRenamedFile": False,
+ "isDeletedFile": False,
+ "baseCoverage": {
+ "percentCovered": 41.666666666666664
+ },
+ "headCoverage": {
+ "percentCovered": 85.71428571428571
+ },
+ "patchCoverage": {"percentCovered": 50.0},
+ "changeCoverage": 44.047619047619044,
+ "missesCount": 1,
+ },
+ {
+ "fileName": "fileB",
+ "headName": "fileB",
+ "baseName": "fileB",
+ "isNewFile": False,
+ "isRenamedFile": False,
+ "isDeletedFile": False,
+ "baseCoverage": {
+ "percentCovered": 41.666666666666664
+ },
+ "headCoverage": {
+ "percentCovered": 85.71428571428571
+ },
+ "patchCoverage": {
+ "percentCovered": 85.71428571428571
+ },
+ "changeCoverage": 44.047619047619044,
+ "missesCount": 2,
+ },
+ ]
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.task.TaskService.compute_comparisons")
+ @patch("services.comparison.ComparisonReport.impacted_file")
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_segments_without_comparison_in_context(
+ self,
+ read_file,
+ mock_get_file_comparison,
+ mock_compare_validate,
+ mock_impacted_file,
+ _,
+ ):
+ read_file.return_value = mock_data_from_archive
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ mock_impacted_file.return_value = ImpactedFile(
+ **{
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5,
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4,
+ },
+ "added_diff_coverage": [
+ [9, "h"],
+ [10, "m"],
+ [13, "p"],
+ [14, "h"],
+ [15, "h"],
+ [16, "h"],
+ [17, "h"],
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]],
+ }
+ )
+ self.comparison.delete()
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileB",
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "pending",
+ "impactedFile": {
+ "headName": "fileB",
+ "baseName": "fileB",
+ "hashedPath": "eea3f37743bfd3409bec556ab26d4698",
+ "baseCoverage": {"percentCovered": None},
+ "headCoverage": {"percentCovered": None},
+ "patchCoverage": {"percentCovered": 71.42857142857143},
+ "segments": {"results": []},
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_with_segments(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileB",
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileB",
+ "baseName": "fileB",
+ "hashedPath": hashlib.md5("fileB".encode()).hexdigest(),
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 85.71428571428571},
+ "segments": {
+ "results": [
+ {"hasUnintendedChanges": True},
+ {"hasUnintendedChanges": False},
+ {"hasUnintendedChanges": True},
+ ],
+ },
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_segments_with_indirect_and_direct_changes(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ "filters": {"hasUnintendedChanges": True},
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": "5e9f0c9689fb7ec181ea0fb09ad3f74e",
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {
+ "results": [
+ {"hasUnintendedChanges": True},
+ {"hasUnintendedChanges": True},
+ ]
+ },
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_with_segments_unknown_path(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+ mock_get_file_comparison.side_effect = TorngitObjectNotFoundError(None, None)
+ mock_compare_validate.return_value = True
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": hashlib.md5("fileA".encode()).hexdigest(),
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {"message": "path does not exist: fileA"},
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_with_segments_provider_error(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+ mock_get_file_comparison.side_effect = TorngitClientGeneralError(
+ 500, None, None
+ )
+ mock_compare_validate.return_value = True
+
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": hashlib.md5("fileA".encode()).hexdigest(),
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {
+ "message": "Error fetching data from the provider"
+ },
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_with_invalid_comparison(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.side_effect = MissingComparisonReport()
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ "filters": {"hasUnintendedChanges": False},
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": "5e9f0c9689fb7ec181ea0fb09ad3f74e",
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {"results": []},
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_segments_with_direct_and_indirect_changes(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ "filters": {"hasUnintendedChanges": False},
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": "5e9f0c9689fb7ec181ea0fb09ad3f74e",
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {
+ "results": [
+ {"hasUnintendedChanges": False},
+ {"hasUnintendedChanges": True},
+ ]
+ },
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("services.comparison.Comparison.validate")
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_impacted_file_without_segments_filter(
+ self, read_file, mock_get_file_comparison, mock_compare_validate
+ ):
+ read_file.return_value = mock_data_from_archive
+
+ mock_get_file_comparison.return_value = MockFileComparison()
+ mock_compare_validate.return_value = True
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "pull": self.pull.pullid,
+ "path": "fileA",
+ }
+ data = self.gql_request(query_impacted_file_through_pull, variables=variables)
+ assert data == {
+ "owner": {
+ "repository": {
+ "pull": {
+ "compareWithBase": {
+ "state": "processed",
+ "impactedFile": {
+ "headName": "fileA",
+ "baseName": "fileA",
+ "hashedPath": "5e9f0c9689fb7ec181ea0fb09ad3f74e",
+ "baseCoverage": {"percentCovered": 41.666666666666664},
+ "headCoverage": {"percentCovered": 85.71428571428571},
+ "patchCoverage": {"percentCovered": 50.0},
+ "segments": {
+ "results": [
+ {"hasUnintendedChanges": True},
+ {"hasUnintendedChanges": False},
+ {"hasUnintendedChanges": True},
+ ]
+ },
+ },
+ }
+ }
+ }
+ }
+ }
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_fetch_direct_changed_files_count(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ variables = {
+ "org": self.org.username,
+ "repo": self.repo.name,
+ "commit": self.commit.commitid,
+ }
+ data = self.gql_request(
+ query_direct_changed_files_count,
+ variables=variables,
+ )
+ assert data == {
+ "owner": {
+ "repository": {
+ "commit": {
+ "compareWithParent": {
+ "directChangedFilesCount": 2,
+ }
+ }
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_invoice.py b/apps/codecov-api/graphql_api/tests/test_invoice.py
new file mode 100644
index 0000000000..f738c9f2c1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_invoice.py
@@ -0,0 +1,262 @@
+import json
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from utils.test_utils import Client
+
+from .helper import GraphQLTestHelper
+
+
+class TestInvoiceType(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.service = "gitlab"
+ self.current_owner = OwnerFactory(stripe_customer_id="1000")
+ self.client = Client()
+ self.client.force_login_owner(self.current_owner)
+
+ @patch("services.billing.stripe.Invoice.list")
+ def test_invoices_returns_100_recent_invoices(self, mock_list_filtered_invoices):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ # make it so there's 100 invoices, which is the max stripe returns
+ stripe_invoice_response["data"] = stripe_invoice_response["data"] * 100
+ mock_list_filtered_invoices.return_value = stripe_invoice_response
+
+ query = """{
+ owner(username: "%s") {
+ invoices {
+ amountDue
+ amountPaid
+ created
+ currency
+ customerAddress
+ customerEmail
+ customerName
+ dueDate
+ footer
+ id
+ lineItems {
+ amount
+ currency
+ description
+ }
+ number
+ periodEnd
+ periodStart
+ status
+ subtotal
+ total
+ defaultPaymentMethod {
+ card {
+ brand
+ expMonth
+ expYear
+ last4
+ }
+ billingDetails {
+ address {
+ city
+ country
+ line1
+ line2
+ postalCode
+ state
+ }
+ email
+ name
+ phone
+ }
+ }
+ taxIds {
+ type
+ value
+ }
+ }
+ }
+ }
+ """ % (self.current_owner.username)
+
+ data = self.gql_request(query, owner=self.current_owner)
+ assert len(data["owner"]["invoices"]) == 100
+ assert data["owner"]["invoices"][0] == {
+ "amountDue": 999,
+ "amountPaid": 999,
+ "created": 1489789429,
+ "currency": "usd",
+ "customerAddress": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "customerEmail": "olivia.williams.03@example.com",
+ "customerName": "Peer Company",
+ "dueDate": None,
+ "footer": None,
+ "id": "in_19yTU92eZvKYlo2C7uDjvu6v",
+ "lineItems": [
+ {
+ "description": "(10) users-pr-inappm",
+ "amount": 120.0,
+ "currency": "usd",
+ }
+ ],
+ "number": "EF0A41E-0001",
+ "periodEnd": 1489789420,
+ "periodStart": 1487370220,
+ "status": "paid",
+ "subtotal": 999,
+ "total": 999,
+ "defaultPaymentMethod": None,
+ "taxIds": [],
+ }
+
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_invoice_returns_invoice_by_id(self, mock_retrieve_invoice):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ invoice = stripe_invoice_response["data"][0]
+ invoice["customer"] = self.current_owner.stripe_customer_id
+ mock_retrieve_invoice.return_value = invoice
+
+ query = """{
+ owner(username: "%s") {
+ invoice(invoiceId: "in_19yTU92eZvKYlo2C7uDjvu6v") {
+ amountDue
+ amountPaid
+ created
+ currency
+ customerAddress
+ customerEmail
+ customerName
+ dueDate
+ footer
+ id
+ lineItems {
+ amount
+ currency
+ description
+ }
+ number
+ periodEnd
+ periodStart
+ status
+ subtotal
+ total
+ defaultPaymentMethod {
+ card {
+ brand
+ expMonth
+ expYear
+ last4
+ }
+ billingDetails {
+ address {
+ city
+ country
+ line1
+ line2
+ postalCode
+ state
+ }
+ email
+ name
+ phone
+ }
+ }
+ taxIds {
+ type
+ value
+ }
+ }
+ }
+ }
+ """ % (self.current_owner.username)
+
+ data = self.gql_request(query, owner=self.current_owner)
+ assert data["owner"]["invoice"] is not None
+ assert data["owner"]["invoice"] == {
+ "amountDue": 999,
+ "amountPaid": 999,
+ "created": 1489789429,
+ "currency": "usd",
+ "customerAddress": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "customerEmail": "olivia.williams.03@example.com",
+ "customerName": "Peer Company",
+ "dueDate": None,
+ "footer": None,
+ "id": "in_19yTU92eZvKYlo2C7uDjvu6v",
+ "lineItems": [
+ {
+ "description": "(10) users-pr-inappm",
+ "amount": 120.0,
+ "currency": "usd",
+ }
+ ],
+ "number": "EF0A41E-0001",
+ "periodEnd": 1489789420,
+ "periodStart": 1487370220,
+ "status": "paid",
+ "subtotal": 999,
+ "total": 999,
+ "defaultPaymentMethod": None,
+ "taxIds": [],
+ }
+
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_invoice_returns_none_if_no_invoices(self, mock_retrieve_invoice):
+ mock_retrieve_invoice.return_value = None
+
+ query = """{
+ owner(username: "%s") {
+ invoice(invoiceId: "in_19yTU92eZvKYlo2C7uDjvu6v") {
+ amountDue
+ amountPaid
+ created
+ currency
+ customerAddress
+ customerEmail
+ customerName
+ dueDate
+ footer
+ id
+ lineItems {
+ amount
+ currency
+ description
+ }
+ number
+ periodEnd
+ periodStart
+ status
+ subtotal
+ total
+ defaultPaymentMethod {
+ card {
+ brand
+ expMonth
+ expYear
+ last4
+ }
+ billingDetails {
+ address {
+ city
+ country
+ line1
+ line2
+ postalCode
+ state
+ }
+ email
+ name
+ phone
+ }
+ }
+ taxIds {
+ type
+ value
+ }
+ }
+ }
+ }
+ """ % (self.current_owner.username)
+
+ data = self.gql_request(query, owner=self.current_owner)
+ assert data["owner"]["invoice"] is None
diff --git a/apps/codecov-api/graphql_api/tests/test_okta_config.py b/apps/codecov-api/graphql_api/tests/test_okta_config.py
new file mode 100644
index 0000000000..e8a6f59899
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_okta_config.py
@@ -0,0 +1,181 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ OktaSettingsFactory,
+ OwnerFactory,
+)
+
+from .helper import GraphQLTestHelper
+
+
+class OktaConfigTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.account = AccountFactory(name="Test Account")
+ self.owner = OwnerFactory(
+ username="randomOwner", service="github", account=self.account
+ )
+ self.okta_settings = OktaSettingsFactory(
+ account=self.account,
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ enabled=True,
+ enforced=False,
+ )
+
+ def test_fetch_enabled_okta_config(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ oktaConfig {
+ enabled
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"]["enabled"] == True
+
+ def test_fetch_disabled_okta_config(self) -> None:
+ self.okta_settings.enabled = False
+ self.okta_settings.save()
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ oktaConfig {
+ enabled
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"]["enabled"] == False
+
+ def test_fetch_enforced_okta_config(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ oktaConfig {
+ enforced
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"]["enforced"] == False
+
+ def test_fetch_enforced_okta_config_true(self) -> None:
+ self.okta_settings.enforced = True
+ self.okta_settings.save()
+ query = """
+ query {
+ owner(username: "%s"){
+ account {
+ oktaConfig {
+ enforced
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"]["enforced"] == True
+
+ def test_fetch_url_okta_config(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account{
+ oktaConfig {
+ url
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"]["url"] == self.okta_settings.url
+
+ def test_fetch_okta_config_client_id(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account{
+ oktaConfig {
+ clientId
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert (
+ result["owner"]["account"]["oktaConfig"]["clientId"]
+ == self.okta_settings.client_id
+ )
+
+ def test_fetch_okta_config_client_secret(self) -> None:
+ query = """
+ query {
+ owner(username: "%s"){
+ account{
+ oktaConfig {
+ clientSecret
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert (
+ result["owner"]["account"]["oktaConfig"]["clientSecret"]
+ == self.okta_settings.client_secret
+ )
+
+ def test_fetch_non_existent_okta_config(self) -> None:
+ self.okta_settings.delete()
+
+ query = """
+ query {
+ owner(username: "%s"){
+ account{
+ oktaConfig {
+ clientId
+ clientSecret
+ url
+ }
+ }
+ }
+ }
+ """ % (self.owner.username)
+
+ result = self.gql_request(query, owner=self.owner)
+
+ assert "errors" not in result
+ assert result["owner"]["account"]["oktaConfig"] is None
diff --git a/apps/codecov-api/graphql_api/tests/test_onboarding.py b/apps/codecov-api/graphql_api/tests/test_onboarding.py
new file mode 100644
index 0000000000..c9f6c85360
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_onboarding.py
@@ -0,0 +1,54 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.tests.helper import GraphQLTestHelper
+
+
+class OnboardingTest(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.params = {
+ "typeProjects": ["PERSONAL"],
+ "goals": ["STARTING_WITH_TESTS", "OTHER"],
+ "otherGoal": "feel confident in my code",
+ }
+
+ def test_when_not_onboarded(self):
+ query = "{ me { onboardingCompleted } }"
+ data = self.gql_request(query, owner=self.owner)
+ assert data == {"me": {"onboardingCompleted": False}}
+
+ def test_onboarding_mutation(self):
+ query = """
+ mutation onboarding($input: OnboardUserInput!) {
+ onboardUser(input: $input) {
+ me {
+ onboardingCompleted
+ trackingMetadata {
+ profile {
+ otherGoal
+ goals
+ typeProjects
+ }
+ }
+ }
+ }
+ }
+ """
+ data = self.gql_request(
+ query, owner=self.owner, variables={"input": self.params}
+ )
+ assert data == {
+ "onboardUser": {
+ "me": {
+ "onboardingCompleted": True,
+ "trackingMetadata": {
+ "profile": {
+ "otherGoal": self.params["otherGoal"],
+ "goals": self.params["goals"],
+ "typeProjects": self.params["typeProjects"],
+ }
+ },
+ }
+ }
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_owner.py b/apps/codecov-api/graphql_api/tests/test_owner.py
new file mode 100644
index 0000000000..f153dd0e09
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_owner.py
@@ -0,0 +1,1268 @@
+import asyncio
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from freezegun import freeze_time
+from graphql import GraphQLError
+from prometheus_client import REGISTRY
+from shared.django_apps.codecov_auth.tests.factories import (
+ AccountFactory,
+ AccountsUsersFactory,
+ GetAdminProviderAdapter,
+ OktaSettingsFactory,
+ UserFactory,
+)
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.django_apps.reports.models import ReportType
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, TrialStatus
+from shared.upload.utils import UploaderType, insert_coverage_measurement
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov.commands.exceptions import (
+ UnauthorizedGuestAccess,
+)
+from codecov_auth.models import GithubAppInstallation, OwnerProfile
+from graphql_api.types.repository.repository import TOKEN_UNAVAILABLE
+from reports.tests.factories import CommitReportFactory, UploadFactory
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+query_repositories = """{
+ owner(username: "%s") {
+ delinquent
+ orgUploadToken
+ ownerid
+ isCurrentUserPartOfOrg
+ yaml
+ repositories%s {
+ totalCount
+ edges {
+ node {
+ name
+ }
+ }
+ pageInfo {
+ hasNextPage
+ %s
+ }
+ }
+ }
+}
+"""
+
+
+class TestOwnerType(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ mock_all_plans_and_tiers()
+ self.account = AccountFactory()
+ self.owner = OwnerFactory(
+ username="codecov-user", service="github", account=self.account
+ )
+ self.okta_settings = OktaSettingsFactory(account=self.account, enforced=True)
+ random_user = OwnerFactory(username="random-user", service="github")
+ RepositoryFactory(
+ author=self.owner,
+ active=True,
+ activated=True,
+ private=True,
+ name="a",
+ service_id="repo-1",
+ )
+ RepositoryFactory(
+ author=self.owner,
+ active=False,
+ activated=False,
+ private=False,
+ name="b",
+ service_id="repo-2",
+ )
+ RepositoryFactory(
+ author=random_user,
+ active=True,
+ activated=False,
+ private=True,
+ name="not",
+ service_id="repo-3",
+ )
+
+ def test_fetching_repositories(self):
+ before = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ errors_before = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ timer_before = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ assert data == {
+ "owner": {
+ "delinquent": None,
+ "orgUploadToken": None,
+ "ownerid": self.owner.ownerid,
+ "isCurrentUserPartOfOrg": True,
+ "yaml": None,
+ "repositories": {
+ "totalCount": 2,
+ "edges": [{"node": {"name": "a"}}, {"node": {"name": "b"}}],
+ "pageInfo": {"hasNextPage": False},
+ },
+ }
+ }
+ after = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ errors_after = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ timer_after = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "owner"},
+ )
+ assert after - before == 1
+ assert errors_after - errors_before == 0
+ assert timer_after - timer_before == 1
+
+ def test_fetching_repositories_with_pagination(self):
+ query = query_repositories % (self.owner.username, "(first: 1)", "endCursor")
+ # Check on the first page if we have the repository b
+ data_page_one = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ connection = data_page_one["owner"]["repositories"]
+ assert connection["edges"][0]["node"] == {"name": "a"}
+ pageInfo = connection["pageInfo"]
+ assert pageInfo["hasNextPage"] == True
+ next_cursor = pageInfo["endCursor"]
+ # Check on the second page if we have the other repository, by using the cursor
+ query = query_repositories % (
+ self.owner.username,
+ f'(first: 1, after: "{next_cursor}")',
+ "endCursor",
+ )
+ data_page_two = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ connection = data_page_two["owner"]["repositories"]
+ assert connection["edges"][0]["node"] == {"name": "b"}
+ pageInfo = connection["pageInfo"]
+ assert pageInfo["hasNextPage"] == False
+
+ def test_fetching_active_repositories(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { active: true })",
+ "",
+ )
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}]
+
+ def test_fetching_repositories_by_name(self):
+ query = query_repositories % (
+ self.owner.username,
+ '(filters: { term: "a" })',
+ "",
+ )
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}]
+
+ def test_fetching_public_repository_when_unauthenticated(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "b"}]
+
+ def test_fetching_repositories_with_ordering(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(ordering: NAME, orderingDirection: DESC)",
+ "",
+ )
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "b"}, {"name": "a"}]
+
+ def test_fetching_repositories_inactive_repositories(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { active: false })",
+ "",
+ )
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "b"}]
+
+ def test_fetch_account(self) -> None:
+ query = """{
+ owner(username: "%s") {
+ account {
+ name
+ }
+ }
+ }
+ """ % (self.owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["account"]["name"] == self.account.name
+
+ def test_fetching_repositories_active_repositories(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { active: true })",
+ "",
+ )
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}]
+
+ def test_fetching_repositories_activated_repositories(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { activated: true })",
+ "",
+ )
+ data = self.gql_request(
+ query, owner=self.owner, okta_signed_in_accounts=[self.account.id]
+ )
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}]
+
+ def test_fetching_repositories_deactivated_repositories(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { activated: false })",
+ "",
+ )
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "b"}]
+
+ def test_fetching_repositories_filter_out_okta_enforced(self):
+ query = query_repositories % (
+ self.owner.username,
+ '(filters: { term: "a" })',
+ "",
+ )
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == []
+
+ def test_fetching_repositories_impersonation_show_okta_enforced(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner, impersonate_owner=True)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}, {"name": "b"}]
+
+ def test_is_part_of_org_when_unauthenticated(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query)
+ assert data["owner"]["isCurrentUserPartOfOrg"] is False
+
+ def test_is_part_of_org_when_authenticated_but_not_part(self):
+ org = OwnerFactory(username="random_org_test", service="github")
+ user = OwnerFactory(username="random_org_user", service="github")
+ query = query_repositories % (org.username, "", "")
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["isCurrentUserPartOfOrg"] is False
+
+ def test_is_part_of_org_when_user_asking_for_themself(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["isCurrentUserPartOfOrg"] is True
+
+ def test_is_part_of_org_when_user_path_of_it(self):
+ org = OwnerFactory(username="random_org_test", service="github")
+ user = OwnerFactory(
+ username="random_org_user", service="github", organizations=[org.ownerid]
+ )
+ query = query_repositories % (org.username, "", "")
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["isCurrentUserPartOfOrg"] is True
+
+ def test_yaml_when_owner_not_have_yaml(self):
+ org = OwnerFactory(username="no_yaml", yaml=None, service="github")
+ self.owner.organizations = [org.ownerid]
+ self.owner.save()
+ query = query_repositories % (org.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["yaml"] is None
+
+ def test_yaml_when_current_user_not_part_of_org(self):
+ yaml = {"test": "test"}
+ org = OwnerFactory(username="no_yaml", yaml=yaml, service="github")
+ self.owner.organizations = []
+ self.owner.save()
+ query = query_repositories % (org.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["yaml"] is None
+
+ def test_yaml_return_data(self):
+ yaml = {"test": "test"}
+ org = OwnerFactory(username="no_yaml", yaml=yaml, service="github")
+ self.owner.organizations = [org.ownerid]
+ self.owner.save()
+ query = query_repositories % (org.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["yaml"] == "test: test\n"
+
+ @patch("codecov_auth.commands.owner.owner.OwnerCommands.set_yaml_on_owner")
+ def test_repository_dispatch_to_command(self, command_mock):
+ asyncio.set_event_loop(asyncio.new_event_loop())
+ repo = RepositoryFactory(author=self.owner, private=False)
+ query_repositories = """{
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ name
+ }
+ }
+ }
+ }
+ """
+ command_mock.return_value = repo
+ query = query_repositories % (repo.author.username, repo.name)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["repository"]["name"] == repo.name
+
+ def test_resolve_number_of_uploads_per_user(self):
+ query_uploads_number = """{
+ owner(username: "%s") {
+ numberOfUploads
+ }
+ }
+ """
+ repository = RepositoryFactory.create(
+ author__plan=DEFAULT_FREE_PLAN, author=self.owner
+ )
+ first_commit = CommitFactory.create(repository=repository)
+ first_report = CommitReportFactory.create(
+ commit=first_commit, report_type=ReportType.COVERAGE.value
+ )
+ for i in range(150):
+ upload = UploadFactory.create(report=first_report)
+ insert_coverage_measurement(
+ owner_id=self.owner.ownerid,
+ repo_id=repository.repoid,
+ commit_id=first_commit.id,
+ upload_id=upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=first_report.report_type,
+ )
+ query = query_uploads_number % (repository.author.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["numberOfUploads"] == 150
+
+ def test_is_current_user_not_an_admin(self):
+ query_current_user_is_admin = """{
+ owner(username: "%s") {
+ isAdmin
+ }
+ }
+ """
+ user = OwnerFactory(username="random_org_user", service="github")
+ owner = OwnerFactory(username="random_org_test", service="github")
+ query = query_current_user_is_admin % (owner.username)
+ data = self.gql_request(query, owner=user, with_errors=True)
+ assert data["data"]["owner"]["isAdmin"] is None
+
+ @patch(
+ "codecov_auth.commands.owner.interactors.get_is_current_user_an_admin.get_provider"
+ )
+ def test_is_current_user_an_admin(self, mocked_get_adapter):
+ query_current_user_is_admin = """{
+ owner(username: "%s") {
+ isAdmin
+ }
+ }
+ """
+ user = OwnerFactory(username="random_org_admin", service="github")
+ owner = OwnerFactory(
+ username="random_org_test", service="github", admins=[user.ownerid]
+ )
+ user.organizations = [owner.ownerid]
+ user.save()
+ mocked_get_adapter.return_value = GetAdminProviderAdapter()
+ query = query_current_user_is_admin % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["isAdmin"] is True
+
+ def test_ownerid(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["ownerid"] == self.owner.ownerid
+
+ def test_delinquent(self):
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["delinquent"] == self.owner.delinquent
+
+ @patch("codecov_auth.commands.owner.owner.OwnerCommands.get_org_upload_token")
+ def test_get_org_upload_token(self, mocker):
+ mocker.return_value = "upload_token"
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["orgUploadToken"] == "upload_token"
+
+ @override_settings(HIDE_ALL_CODECOV_TOKENS=True)
+ def test_get_org_upload_token_hide_tokens_setting_owner_not_admin(self):
+ random_owner = OwnerFactory()
+ query = """{
+ owner(username: "%s") {
+ orgUploadToken
+ }
+ }
+ """ % (self.owner.username)
+ random_owner.organizations = [self.owner.ownerid]
+ random_owner.save()
+ data = self.gql_request(query, owner=random_owner)
+ assert data["owner"]["orgUploadToken"] == TOKEN_UNAVAILABLE
+
+ @patch("codecov_auth.commands.owner.owner.OwnerCommands.get_org_upload_token")
+ @override_settings(HIDE_ALL_CODECOV_TOKENS=True)
+ def test_get_org_upload_token_hide_tokens_setting_owner_is_admin(self, mocker):
+ mocker.return_value = "upload_token"
+ query = query_repositories % (self.owner.username, "", "")
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["orgUploadToken"] == "upload_token"
+
+ # Applies for old users that didn't get their owner profiles created w/ their owner
+ def test_when_owner_profile_doesnt_exist(self):
+ owner = OwnerFactory(username="no-profile-user")
+ owner.profile.delete()
+ query = """{
+ owner(username: "%s") {
+ defaultOrgUsername
+ username
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["defaultOrgUsername"] is None
+
+ def test_get_default_org_username_for_owner(self):
+ organization = OwnerFactory(username="sample-org", service="github")
+ owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ organizations=[organization.ownerid],
+ )
+ OwnerProfile.objects.filter(owner_id=owner.ownerid).update(
+ default_org=organization
+ )
+ query = """{
+ owner(username: "%s") {
+ defaultOrgUsername
+ username
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["defaultOrgUsername"] == organization.username
+
+ def test_owner_without_default_org_returns_null(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ query = """{
+ owner(username: "%s") {
+ defaultOrgUsername
+ username
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["defaultOrgUsername"] is None
+
+ def test_owner_without_owner_profile_returns_no_default_org(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ query = """{
+ owner(username: "%s") {
+ defaultOrgUsername
+ username
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["defaultOrgUsername"] is None
+
+ def test_is_current_user_not_activated(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ self.owner.organizations = [owner.ownerid]
+ self.owner.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["isCurrentUserActivated"] == False
+
+ def test_is_current_user_not_activated_no_current_owner(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ self.client.force_login(user=UserFactory())
+ data = self.gql_request(query, owner=None)
+ assert data["owner"]["isCurrentUserActivated"] == False
+
+ def test_is_current_user_activated(self):
+ user = OwnerFactory(username="sample-user")
+ owner = OwnerFactory(
+ username="sample-owner", plan_activated_users=[user.ownerid]
+ )
+ user.organizations = [owner.ownerid]
+ user.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["isCurrentUserActivated"] == True
+
+ def test_is_current_user_activated_when_plan_activated_users_is_none(self):
+ user = OwnerFactory(username="sample-user")
+ owner = OwnerFactory(username="sample-owner", plan_activated_users=None)
+ user.organizations = [owner.ownerid]
+ user.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["isCurrentUserActivated"] == False
+
+ def test_is_current_user_activated_anonymous(self):
+ owner = OwnerFactory(username="sample-owner")
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query)
+ assert data["owner"]["isCurrentUserActivated"] == False
+
+ def test_is_current_user_activated_admin_activated(self):
+ owner = OwnerFactory(
+ username="sample-owner-authorized",
+ admins=[self.owner.ownerid],
+ plan_activated_users=[self.owner.ownerid],
+ )
+ self.owner.organizations = [owner.ownerid]
+ self.owner.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["isCurrentUserActivated"] == True
+
+ def test_is_current_user_activated_admin_not_activated(self):
+ owner = OwnerFactory(
+ username="sample-owner-authorized",
+ admins=[self.owner.ownerid],
+ plan_activated_users=None,
+ )
+ self.owner.organizations = [owner.ownerid]
+ self.owner.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["isCurrentUserActivated"] == False
+
+ def test_owner_is_current_user_activated(self):
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (self.owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["isCurrentUserActivated"] == True
+
+ @freeze_time("2023-06-19")
+ def test_owner_plan_status(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ trial_start_date=timezone.now(),
+ trial_end_date=timezone.now() + timedelta(days=14),
+ trial_status=TrialStatus.ONGOING.value,
+ )
+ query = """{
+ owner(username: "%s") {
+ plan {
+ trialStatus
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["plan"] == {
+ "trialStatus": "ONGOING",
+ }
+
+ @freeze_time("2023-06-19")
+ def test_owner_pretrial_plan_benefits(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ trial_start_date=timezone.now(),
+ trial_end_date=timezone.now() + timedelta(days=14),
+ trial_status=TrialStatus.ONGOING.value,
+ plan=PlanName.TRIAL_PLAN_NAME.value,
+ pretrial_users_count=123,
+ )
+ query = """{
+ owner(username: "%s") {
+ pretrialPlan {
+ benefits
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["pretrialPlan"] == {
+ "benefits": [
+ "Up to 123 users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ }
+
+ @freeze_time("2023-06-19")
+ def test_owner_available_plans(self):
+ current_org = OwnerFactory(
+ username="random-plan-user-123",
+ service="github",
+ plan=PlanName.CODECOV_PRO_MONTHLY.value,
+ pretrial_users_count=123,
+ )
+ query = """{
+ owner(username: "%s") {
+ availablePlans {
+ value
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ expected_plans = [
+ {"value": "users-pr-inappm"},
+ {"value": "users-pr-inappy"},
+ {"value": "users-teamm"},
+ {"value": "users-teamy"},
+ {"value": DEFAULT_FREE_PLAN},
+ ]
+ for plan in expected_plans:
+ self.assertIn(plan, data["owner"]["availablePlans"])
+
+ def test_owner_query_with_no_service(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+ query = """{
+ owner(username: "%s") {
+ username
+ }
+ }
+ """ % (current_org.username)
+
+ res = self.gql_request(query, provider="", with_errors=True)
+
+ assert res["data"]["owner"] is None
+
+ def test_owner_query_with_private_repos(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+ RepositoryFactory(author=current_org, active=True, activated=True, private=True)
+ query = """{
+ owner(username: "%s") {
+ hasPrivateRepos
+ hasPublicRepos
+ hasActiveRepos
+ }
+ }
+ """ % (current_org.username)
+
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["hasPrivateRepos"] == True
+ assert data["owner"]["hasPublicRepos"] == False
+ assert data["owner"]["hasActiveRepos"] == True
+
+ def test_owner_query_with_no_active_repos(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+ RepositoryFactory(
+ author=current_org, active=False, activated=False, private=True
+ )
+ query = """{
+ owner(username: "%s") {
+ hasPrivateRepos
+ hasPublicRepos
+ hasActiveRepos
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["hasPrivateRepos"] == True
+ assert data["owner"]["hasPublicRepos"] == False
+ assert data["owner"]["hasActiveRepos"] == False
+
+ def test_owner_query_with_public_repos(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+ RepositoryFactory(
+ author=current_org,
+ active=True,
+ activated=True,
+ private=False,
+ name="test-one",
+ )
+ RepositoryFactory(
+ author=current_org,
+ active=True,
+ activated=True,
+ private=False,
+ name="test-two",
+ )
+ query = """{
+ owner(username: "%s") {
+ hasPrivateRepos
+ hasPublicRepos
+ hasActiveRepos
+ }
+ }
+ """ % (current_org.username)
+
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["hasPrivateRepos"] == False
+ assert data["owner"]["hasPublicRepos"] == True
+ assert data["owner"]["hasActiveRepos"] == True
+
+ def test_owner_hash_owner_id(self):
+ user = OwnerFactory(username="sample-user")
+ owner = OwnerFactory(username="sample-owner", plan_activated_users=None)
+ user.organizations = [owner.ownerid]
+ user.save()
+ query = """{
+ owner(username: "%s") {
+ hashOwnerid
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["hashOwnerid"] is not None
+
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=False)
+ def test_fetch_owner_on_unauthenticated_enteprise_guest_access(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ query = """{
+ owner(username: "%s") {
+ username
+ }
+ }
+ """ % (owner.username)
+
+ try:
+ self.gql_request(query)
+
+ except GraphQLError as e:
+ assert e.message == UnauthorizedGuestAccess.message
+ assert e.extensions["code"] == UnauthorizedGuestAccess.code
+
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=False)
+ def test_fetch_owner_on_unauthenticated_enteprise_guest_access_not_activated(self):
+ user = OwnerFactory(username="sample-user")
+ owner = OwnerFactory(username="sample-owner", plan_activated_users=[123, 456])
+ user.organizations = [owner.ownerid]
+ user.save()
+ owner.save()
+ query = """{
+ owner(username: "%s") {
+ isCurrentUserActivated
+ }
+ }
+ """ % (owner.username)
+
+ try:
+ self.gql_request(query, owner=user)
+
+ except GraphQLError as e:
+ assert e.message == UnauthorizedGuestAccess.message
+ assert e.extensions["code"] == UnauthorizedGuestAccess.code
+
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=False)
+ def test_fetch_owner_plan_activated_users_is_none(self):
+ """
+ This test is when Enterprise guest access is disabled, and you are
+ trying to view an org that does not track plan activated users (e.g., historic data)
+ """
+ user = OwnerFactory(username="sample-user")
+ owner = OwnerFactory(username="sample-owner", plan_activated_users=None)
+ user.save()
+ owner.save()
+ query = """{
+ owner(username: "%s") {
+ username
+ }
+ }
+ """ % (owner.username)
+
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["username"] == "sample-owner"
+
+ def test_fetch_current_user_is_okta_authenticated(self):
+ account = AccountFactory()
+ owner = OwnerFactory(username="sample-owner", service="github", account=account)
+ owner.save()
+
+ user = OwnerFactory(username="sample-user")
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ query = """{
+ owner(username: "%s") {
+ isUserOktaAuthenticated
+ }
+ }
+ """ % (owner.username)
+
+ data = self.gql_request(query, owner=user, okta_signed_in_accounts=[account.pk])
+ assert data["owner"]["isUserOktaAuthenticated"] == True
+
+ def test_fetch_current_user_is_not_okta_authenticated(self):
+ account = AccountFactory()
+ owner = OwnerFactory(username="sample-owner", service="github", account=account)
+ owner.save()
+
+ user = OwnerFactory(username="sample-user")
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ query = """{
+ owner(username: "%s") {
+ isUserOktaAuthenticated
+ }
+ }
+ """ % (owner.username)
+
+ data = self.gql_request(query, owner=user, okta_signed_in_accounts=[])
+ assert data["owner"]["isUserOktaAuthenticated"] == False
+
+ def test_fetch_current_user_is_not_okta_authenticated_no_account(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ owner.save()
+
+ user = OwnerFactory(username="sample-user")
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ query = """{
+ owner(username: "%s") {
+ isUserOktaAuthenticated
+ }
+ }
+ """ % (owner.username)
+
+ data = self.gql_request(query, owner=user, okta_signed_in_accounts=[])
+ assert data["owner"]["isUserOktaAuthenticated"] == False
+
+ @patch("shared.rate_limits.determine_entity_redis_key")
+ @patch("shared.rate_limits.determine_if_entity_is_rate_limited")
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=True)
+ def test_fetch_is_github_rate_limited(
+ self, mock_determine_rate_limit, mock_determine_redis_key
+ ):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+ query = """{
+ owner(username: "%s") {
+ isGithubRateLimited
+ }
+ }
+
+ """ % (current_org.username)
+ mock_determine_redis_key.return_value = "test"
+ mock_determine_rate_limit.return_value = True
+
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["isGithubRateLimited"] == True
+
+ def test_fetch_is_github_rate_limited_not_on_gh_service(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="bitbucket",
+ )
+ query = """{
+ owner(username: "%s") {
+ isGithubRateLimited
+ }
+ }
+
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org, provider="bb")
+ assert data["owner"]["isGithubRateLimited"] == False
+
+ @patch("services.self_hosted.get_config")
+ def test_ai_features_enabled(self, get_config_mock):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+
+ get_config_mock.return_value = [
+ {"service": "github", "ai_features_app_id": 12345},
+ ]
+
+ ai_app_installation = GithubAppInstallation(
+ name="ai-features",
+ owner=current_org,
+ repository_service_ids=None,
+ installation_id=12345,
+ )
+
+ ai_app_installation.save()
+
+ query = """{
+ owner(username: "%s") {
+ aiFeaturesEnabled
+ }
+ }
+
+ """ % (current_org.username)
+
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["aiFeaturesEnabled"] == True
+
+ @patch("services.self_hosted.get_config")
+ def test_fetch_repos_ai_features_enabled(self, get_config_mock):
+ get_config_mock.return_value = [
+ {"service": "github", "ai_features_app_id": 12345},
+ ]
+
+ ai_app_installation = GithubAppInstallation(
+ name="ai-features",
+ owner=self.owner,
+ repository_service_ids=["repo-1"],
+ installation_id=12345,
+ )
+
+ ai_app_installation.save()
+
+ query = """{
+ owner(username: "%s") {
+ aiEnabledRepos
+ }
+ }
+
+ """ % (self.owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["aiEnabledRepos"] == ["a"]
+
+ @patch("services.self_hosted.get_config")
+ def test_fetch_repos_ai_features_enabled_app_not_configured(self, get_config_mock):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ )
+
+ get_config_mock.return_value = [
+ {"service": "github", "ai_features_app_id": 12345},
+ ]
+
+ query = """{
+ owner(username: "%s") {
+ aiEnabledRepos
+ }
+ }
+
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["aiEnabledRepos"] is None
+
+ @patch("services.self_hosted.get_config")
+ def test_fetch_repos_ai_features_enabled_all_repos(self, get_config_mock):
+ get_config_mock.return_value = [
+ {"service": "github", "ai_features_app_id": 12345},
+ ]
+
+ ai_app_installation = GithubAppInstallation(
+ name="ai-features",
+ owner=self.owner,
+ repository_service_ids=None,
+ installation_id=12345,
+ )
+
+ ai_app_installation.save()
+
+ query = """{
+ owner(username: "%s") {
+ aiEnabledRepos
+ }
+ }
+
+ """ % (self.owner.username)
+ data = self.gql_request(query, owner=self.owner)
+ assert data["owner"]["aiEnabledRepos"] == ["b", "a"]
+
+ def test_fetch_upload_token_required(self):
+ owner = OwnerFactory(
+ username="sample-owner",
+ service="github",
+ upload_token_required_for_public_repos=True,
+ )
+ query = """{
+ owner(username: "%s") {
+ uploadTokenRequired
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["uploadTokenRequired"] == True
+
+ def test_fetch_upload_token_not_required(self):
+ owner = OwnerFactory(username="sample-owner", service="github")
+ owner.upload_token_required_for_public_repos = False
+ owner.save()
+ query = """{
+ owner(username: "%s") {
+ uploadTokenRequired
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["uploadTokenRequired"] == False
+
+ def test_fetch_upload_token_user_not_part_of_org(self):
+ owner = OwnerFactory(username="sample", service="github")
+ user = OwnerFactory(username="sample-user", service="github")
+ query = """{
+ owner(username: "%s") {
+ uploadTokenRequired
+ }
+ }
+ """ % (owner.username)
+
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["uploadTokenRequired"] is None
+
+ def test_fetch_activated_user_count(self):
+ user = OwnerFactory(username="sample-user")
+ user2 = OwnerFactory(username="sample-user-2")
+ user3 = OwnerFactory(username="sample-user-3")
+ owner = OwnerFactory(
+ username="sample-org",
+ plan_activated_users=[user.ownerid, user2.ownerid, user3.ownerid],
+ )
+ user.organizations = [owner.ownerid]
+ user.save()
+
+ query = """{
+ owner(username: "%s") {
+ activatedUserCount
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["activatedUserCount"] == 3
+
+ def test_fetch_activated_user_count_returns_null_if_not_in_org(self):
+ user = OwnerFactory(username="sample-user")
+ user2 = OwnerFactory(username="sample-user-2")
+ user3 = OwnerFactory(username="sample-user-3")
+ owner = OwnerFactory(
+ username="sample-org", plan_activated_users=[user2.ownerid, user3.ownerid]
+ )
+
+ query = """{
+ owner(username: "%s") {
+ activatedUserCount
+ }
+ }
+ """ % (owner.username)
+ data = self.gql_request(query, owner=user)
+ assert data["owner"]["activatedUserCount"] is None
+
+ def test_fetch_activated_user_count_when_not_in_org_but_has_shared_account(self):
+ owner = OwnerFactory(username="sample-user")
+ AccountsUsersFactory(user=owner.user, account=self.account)
+ user2 = OwnerFactory(username="sample-user-2")
+ user3 = OwnerFactory(username="sample-user-3")
+ other_owner = OwnerFactory(
+ username="sample-org",
+ plan_activated_users=[user2.ownerid, user3.ownerid],
+ account=self.account,
+ )
+
+ query = """{
+ owner(username: "%s") {
+ activatedUserCount
+ }
+ }
+ """ % (other_owner.username)
+ data = self.gql_request(query, owner=owner)
+ assert data["owner"]["activatedUserCount"] == 2
+
+ def test_fetch_available_plans_is_enterprise_plan(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=DEFAULT_FREE_PLAN,
+ )
+ query = """{
+ owner(username: "%s") {
+ availablePlans {
+ value
+ isEnterprisePlan
+ isProPlan
+ isTeamPlan
+ isSentryPlan
+ isFreePlan
+ isTrialPlan
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ expected_plans = [
+ {
+ "value": "users-pr-inappm",
+ "isEnterprisePlan": False,
+ "isProPlan": True,
+ "isTeamPlan": False,
+ "isSentryPlan": False,
+ "isFreePlan": False,
+ "isTrialPlan": False,
+ },
+ {
+ "value": "users-pr-inappy",
+ "isEnterprisePlan": False,
+ "isProPlan": True,
+ "isTeamPlan": False,
+ "isSentryPlan": False,
+ "isFreePlan": False,
+ "isTrialPlan": False,
+ },
+ {
+ "value": "users-teamm",
+ "isEnterprisePlan": False,
+ "isProPlan": False,
+ "isTeamPlan": True,
+ "isSentryPlan": False,
+ "isFreePlan": False,
+ "isTrialPlan": False,
+ },
+ {
+ "value": "users-teamy",
+ "isEnterprisePlan": False,
+ "isProPlan": False,
+ "isTeamPlan": True,
+ "isSentryPlan": False,
+ "isFreePlan": False,
+ "isTrialPlan": False,
+ },
+ {
+ "value": DEFAULT_FREE_PLAN,
+ "isEnterprisePlan": False,
+ "isProPlan": False,
+ "isTeamPlan": True,
+ "isSentryPlan": False,
+ "isFreePlan": True,
+ "isTrialPlan": False,
+ },
+ ]
+ for plan in expected_plans:
+ self.assertIn(plan, data["owner"]["availablePlans"])
+
+ def test_fetch_owner_with_no_service(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=DEFAULT_FREE_PLAN,
+ )
+
+ query = """{
+ owner(username: "%s") {
+ username
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org, provider="", with_errors=True)
+ assert data == {"data": {"owner": None}}
+
+ def test_fetch_repositories_ai_features_enabled(self):
+ ai_app_installation = GithubAppInstallation(
+ name="ai-features",
+ owner=self.owner,
+ repository_service_ids=[],
+ installation_id=12345,
+ )
+
+ ai_app_installation.save()
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { aiEnabled: true })",
+ "",
+ )
+
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == [{"name": "a"}, {"name": "b"}]
+
+ def test_fetch_repositories_ai_features_enabled_no_app_install(self):
+ query = query_repositories % (
+ self.owner.username,
+ "(filters: { aiEnabled: true })",
+ "",
+ )
+ data = self.gql_request(query, owner=self.owner)
+ repos = paginate_connection(data["owner"]["repositories"])
+ assert repos == []
diff --git a/apps/codecov-api/graphql_api/tests/test_owner_measurements.py b/apps/codecov-api/graphql_api/tests/test_owner_measurements.py
new file mode 100644
index 0000000000..c5a67a094e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_owner_measurements.py
@@ -0,0 +1,186 @@
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from timeseries.models import Interval
+
+from .helper import GraphQLTestHelper
+
+
+@patch("timeseries.helpers.owner_coverage_measurements_with_fallback")
+class TestOwnerMeasurements(TestCase, GraphQLTestHelper):
+ def _request(self, variables=None):
+ query = f"""
+ query Measurements($repos: [String!]) {{
+ owner(username: "{self.org.username}") {{
+ measurements(
+ interval: INTERVAL_1_DAY
+ after: "2022-01-01"
+ before: "2022-01-03"
+ repos: $repos
+ ) {{
+ timestamp
+ avg
+ min
+ max
+ }}
+ }}
+ }}
+ """
+ data = self.gql_request(query, owner=self.owner, variables=variables)
+ return data["owner"]["measurements"]
+
+ def setUp(self):
+ self.org = OwnerFactory(username="test-org")
+ self.repo1 = RepositoryFactory(
+ name="test-repo1",
+ author=self.org,
+ private=True,
+ )
+ self.repo2 = RepositoryFactory(
+ name="test-repo2",
+ author=self.org,
+ private=False,
+ )
+ self.owner = OwnerFactory(permission=[self.repo1.pk, self.repo2.pk])
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_measurements_timeseries_enabled(
+ self, owner_coverage_measurements_with_fallback
+ ):
+ owner_coverage_measurements_with_fallback.return_value = [
+ {"timestamp_bin": datetime(2022, 1, 1), "min": 1, "max": 2, "avg": 1.5},
+ {"timestamp_bin": datetime(2022, 1, 2), "min": 3, "max": 4, "avg": 3.5},
+ ]
+
+ assert self._request() == [
+ {"timestamp": "2022-01-01T00:00:00", "min": 1.0, "max": 2.0, "avg": 1.5},
+ {"timestamp": "2022-01-02T00:00:00", "min": 3.0, "max": 4.0, "avg": 3.5},
+ {
+ "timestamp": "2022-01-03T00:00:00+00:00",
+ "min": None,
+ "max": None,
+ "avg": None,
+ },
+ ]
+
+ owner_coverage_measurements_with_fallback.assert_called_once_with(
+ self.org,
+ [self.repo2.pk, self.repo1.pk],
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_measurements_timeseries_enabled_repoids(
+ self, owner_coverage_measurements_with_fallback
+ ):
+ owner_coverage_measurements_with_fallback.return_value = [
+ {"timestamp_bin": datetime(2022, 1, 1), "min": 1, "max": 2, "avg": 1.5},
+ {"timestamp_bin": datetime(2022, 1, 2), "min": 3, "max": 4, "avg": 3.5},
+ ]
+
+ assert self._request(variables={"repos": ["test-repo1"]}) == [
+ {"timestamp": "2022-01-01T00:00:00", "min": 1.0, "max": 2.0, "avg": 1.5},
+ {"timestamp": "2022-01-02T00:00:00", "min": 3.0, "max": 4.0, "avg": 3.5},
+ {
+ "timestamp": "2022-01-03T00:00:00+00:00",
+ "min": None,
+ "max": None,
+ "avg": None,
+ },
+ ]
+
+ owner_coverage_measurements_with_fallback.assert_called_once_with(
+ self.org,
+ [self.repo1.pk],
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_measurements_timeseries_not_enabled(
+ self, owner_coverage_measurements_with_fallback
+ ):
+ owner_coverage_measurements_with_fallback.return_value = [
+ {"timestamp_bin": datetime(2022, 1, 1), "min": 1, "max": 2, "avg": 1.5},
+ {"timestamp_bin": datetime(2022, 1, 2), "min": 3, "max": 4, "avg": 3.5},
+ ]
+
+ assert self._request() == [
+ {"timestamp": "2022-01-01T00:00:00", "min": 1.0, "max": 2.0, "avg": 1.5},
+ {"timestamp": "2022-01-02T00:00:00", "min": 3.0, "max": 4.0, "avg": 3.5},
+ {
+ "timestamp": "2022-01-03T00:00:00+00:00",
+ "min": None,
+ "max": None,
+ "avg": None,
+ },
+ ]
+
+ owner_coverage_measurements_with_fallback.assert_called_once_with(
+ self.org,
+ [self.repo2.pk, self.repo1.pk],
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+
+ @override_settings(TIMESERIES_ENABLED=True)
+ def test_repository_filtering_by_public_private(
+ self, owner_coverage_measurements_with_fallback
+ ):
+ owner_coverage_measurements_with_fallback.return_value = []
+ query = f"""
+ query Measurements($isPublic: Boolean) {{
+ owner(username: "{self.org.username}") {{
+ measurements(
+ interval: INTERVAL_1_DAY
+ isPublic: $isPublic
+ ) {{
+ timestamp
+ }}
+ }}
+ }}
+ """
+
+ self.gql_request(query, owner=self.owner, variables={"isPublic": False})[
+ "owner"
+ ]["measurements"]
+ params = owner_coverage_measurements_with_fallback.call_args.args
+ # Check that the call is using only repo_ids of the private repo
+ assert params[1] == [self.repo1.pk]
+
+ self.gql_request(query, owner=self.owner, variables={"isPublic": True})[
+ "owner"
+ ]["measurements"]
+ params = owner_coverage_measurements_with_fallback.call_args.args
+ # Check that the call is using only repo_ids of the public repo
+ assert params[1] == [self.repo2.pk]
+
+ self.gql_request(query, owner=self.owner, variables={"isPublic": None})[
+ "owner"
+ ]["measurements"]
+ params = owner_coverage_measurements_with_fallback.call_args.args
+ # Check that the call is using both private and public repos
+ assert set(params[1]) == {self.repo1.pk, self.repo2.pk}
+
+ query = f"""
+ query Measurements {{
+ owner(username: "{self.org.username}") {{
+ measurements(
+ interval: INTERVAL_1_DAY
+ ) {{
+ timestamp
+ }}
+ }}
+ }}
+ """
+ self.gql_request(query, owner=self.owner)["owner"]["measurements"]
+ params = owner_coverage_measurements_with_fallback.call_args.args
+ # Check that the call is using both private and public repos
+ assert set(params[1]) == {self.repo1.pk, self.repo2.pk}
diff --git a/apps/codecov-api/graphql_api/tests/test_path_content.py b/apps/codecov-api/graphql_api/tests/test_path_content.py
new file mode 100644
index 0000000000..71499934bf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_path_content.py
@@ -0,0 +1,87 @@
+from unittest.mock import Mock, PropertyMock, patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine, ReportTotals
+from shared.utils.sessions import Session
+
+from services.path import Dir, File
+
+from ..types.commit.commit import resolve_path_contents
+from ..types.errors.errors import MissingCoverage, UnknownPath
+from ..types.path_contents.path_content import (
+ resolve_path_content_type,
+)
+
+
+def sample_report() -> Report:
+ report = Report()
+ first_file = ReportFile("foo/file1.py")
+ first_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file = ReportFile("bar/file2.py")
+ second_file.append(1, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session())
+ return report
+
+
+class MockContext(object):
+ def __init__(self, context):
+ self.context = context
+
+
+class MockProfilingSummary:
+ def __init__(self, critical_filenames):
+ self.critical_filenames = critical_filenames
+
+
+class TestResolvePathContent:
+ def test_returns_path_content_file(self):
+ file = File(full_path="file.py", totals=ReportTotals.default_totals())
+
+ type = resolve_path_content_type(file)
+ assert type == "PathContentFile"
+
+ def test_returns_path_content_dir(self):
+ dir = Dir(full_path="foo/bar", children=[])
+
+ type = resolve_path_content_type(dir)
+ assert type == "PathContentDir"
+
+ def test_returns_none(self):
+ type = resolve_path_content_type("string")
+ assert type is None
+
+
+class TestPathContents(TestCase):
+ def setUp(self):
+ request = Mock()
+ request.user = Mock()
+ self.info = MockContext({"request": request})
+ self.commit = CommitFactory()
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ async def test_missing_coverage(
+ self, paths_mock, provider_path_exists_mock, report_mock
+ ):
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = True
+ report_mock.return_value = sample_report()
+ res = await resolve_path_contents(self.commit, self.info, "test/path")
+ assert isinstance(res, MissingCoverage)
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ @patch("services.path.provider_path_exists")
+ @patch("services.path.ReportPaths.paths", new_callable=PropertyMock)
+ async def test_unknown_path(
+ self, paths_mock, provider_path_exists_mock, report_mock
+ ):
+ paths_mock.return_value = []
+ provider_path_exists_mock.return_value = False
+ report_mock.return_value = sample_report()
+ res = await resolve_path_contents(self.commit, self.info, "test/path")
+ assert isinstance(res, UnknownPath)
diff --git a/apps/codecov-api/graphql_api/tests/test_plan.py b/apps/codecov-api/graphql_api/tests/test_plan.py
new file mode 100644
index 0000000000..2e73e5de4b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_plan.py
@@ -0,0 +1,269 @@
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from django.utils import timezone
+from freezegun import freeze_time
+from shared.django_apps.codecov_auth.tests.factories import AccountFactory
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.license import LicenseInformation
+from shared.plan.constants import PlanName, TrialStatus
+from shared.utils.test_utils import mock_config_helper
+
+from billing.helpers import mock_all_plans_and_tiers
+
+from .helper import GraphQLTestHelper
+
+
+class TestPlanType(GraphQLTestHelper, TestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(request, mocker):
+ request.mocker = mocker
+
+ def setUp(self):
+ mock_all_plans_and_tiers()
+ self.current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ trial_start_date=timezone.now(),
+ trial_end_date=timezone.now() + timedelta(days=14),
+ )
+
+ @freeze_time("2023-06-19")
+ def test_owner_plan_data_when_trialing(self):
+ now = timezone.now()
+ later = timezone.now() + timedelta(days=14)
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.TRIAL_PLAN_NAME.value,
+ trial_start_date=now,
+ trial_end_date=later,
+ trial_status=TrialStatus.ONGOING.value,
+ pretrial_users_count=234,
+ plan_user_count=123,
+ )
+ query = """{
+ owner(username: "%s") {
+ plan {
+ trialStatus
+ trialEndDate
+ trialStartDate
+ trialTotalDays
+ marketingName
+ value
+ tierName
+ billingRate
+ baseUnitPrice
+ benefits
+ monthlyUploadLimit
+ pretrialUsersCount
+ planUserCount
+ isEnterprisePlan
+ isFreePlan
+ isProPlan
+ isSentryPlan
+ isTeamPlan
+ isTrialPlan
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["plan"] == {
+ "trialStatus": "ONGOING",
+ "trialEndDate": "2023-07-03T00:00:00",
+ "trialStartDate": "2023-06-19T00:00:00",
+ "trialTotalDays": 14,
+ "marketingName": "Developer",
+ "value": "users-trial",
+ "tierName": "trial",
+ "billingRate": None,
+ "baseUnitPrice": 0,
+ "benefits": [
+ "Configurable # of users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ "Priority Support",
+ ],
+ "monthlyUploadLimit": None,
+ "pretrialUsersCount": 234,
+ "planUserCount": 123,
+ "isEnterprisePlan": False,
+ "isFreePlan": False,
+ "isProPlan": False,
+ "isSentryPlan": False,
+ "isTeamPlan": False,
+ "isTrialPlan": True,
+ }
+
+ def test_owner_plan_data_with_account(self):
+ self.current_org.account = AccountFactory(
+ plan=PlanName.CODECOV_PRO_YEARLY.value,
+ plan_seat_count=25,
+ )
+ self.current_org.save()
+ query = """{
+ owner(username: "%s") {
+ plan {
+ marketingName
+ value
+ tierName
+ billingRate
+ baseUnitPrice
+ planUserCount
+ isEnterprisePlan
+ isFreePlan
+ isProPlan
+ isSentryPlan
+ isTeamPlan
+ isTrialPlan
+ }
+ }
+ }
+ """ % (self.current_org.username)
+ data = self.gql_request(query, owner=self.current_org)
+ assert data["owner"]["plan"] == {
+ "marketingName": "Pro",
+ "value": "users-pr-inappy",
+ "tierName": "pro",
+ "billingRate": "annually",
+ "baseUnitPrice": 10,
+ "planUserCount": 25,
+ "isEnterprisePlan": False,
+ "isFreePlan": False,
+ "isProPlan": True,
+ "isSentryPlan": False,
+ "isTeamPlan": False,
+ "isTrialPlan": False,
+ }
+
+ def test_owner_plan_data_has_seats_left(self):
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.TRIAL_PLAN_NAME.value,
+ trial_status=TrialStatus.ONGOING.value,
+ plan_user_count=2,
+ plan_activated_users=[],
+ )
+ query = """{
+ owner(username: "%s") {
+ plan {
+ hasSeatsLeft
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["plan"] == {"hasSeatsLeft": True}
+
+ @patch("shared.self_hosted.service.get_current_license")
+ def test_plan_user_count_for_enterprise_org(self, mocked_license):
+ """
+ If an Org has an enterprise license, number_allowed_users from their license
+ should be used instead of plan_user_count on the Org object.
+ """
+ mock_enterprise_license = LicenseInformation(
+ is_valid=True,
+ message=None,
+ url="https://codeov.mysite.com",
+ number_allowed_users=5,
+ number_allowed_repos=10,
+ expires=datetime.strptime("2020-05-09 00:00:00", "%Y-%m-%d %H:%M:%S"),
+ is_trial=False,
+ is_pr_billing=True,
+ )
+ mocked_license.return_value = mock_enterprise_license
+ mock_config_helper(
+ self.mocker, configs={"setup.enterprise_license": mock_enterprise_license}
+ )
+
+ enterprise_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.CODECOV_PRO_YEARLY.value,
+ plan_user_count=1,
+ plan_activated_users=[],
+ )
+ for i in range(4):
+ new_owner = OwnerFactory()
+ enterprise_org.plan_activated_users.append(new_owner.ownerid)
+ enterprise_org.save()
+
+ other_org_in_enterprise = OwnerFactory(
+ service="github",
+ plan=PlanName.CODECOV_PRO_YEARLY.value,
+ plan_user_count=1,
+ plan_activated_users=[],
+ )
+ for i in range(4):
+ new_owner = OwnerFactory()
+ other_org_in_enterprise.plan_activated_users.append(new_owner.ownerid)
+ other_org_in_enterprise.save()
+
+ query = """{
+ owner(username: "%s") {
+ plan {
+ planUserCount
+ hasSeatsLeft
+ }
+ }
+ }
+ """ % (enterprise_org.username)
+ data = self.gql_request(query, owner=enterprise_org)
+ assert data["owner"]["plan"]["planUserCount"] == 5
+ assert data["owner"]["plan"]["hasSeatsLeft"] == False
+
+ @patch("shared.self_hosted.service.get_current_license")
+ def test_plan_user_count_for_enterprise_org_invaild_license(self, mocked_license):
+ mock_enterprise_license = LicenseInformation(
+ is_valid=False,
+ )
+ mocked_license.return_value = mock_enterprise_license
+ mock_config_helper(
+ self.mocker, configs={"setup.enterprise_license": mock_enterprise_license}
+ )
+
+ enterprise_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.CODECOV_PRO_YEARLY.value,
+ plan_user_count=1,
+ plan_activated_users=[],
+ )
+ query = """{
+ owner(username: "%s") {
+ plan {
+ planUserCount
+ hasSeatsLeft
+ }
+ }
+ }
+ """ % (enterprise_org.username)
+ data = self.gql_request(query, owner=enterprise_org)
+ assert data["owner"]["plan"]["planUserCount"] == 0
+ assert data["owner"]["plan"]["hasSeatsLeft"] == False
+
+ def test_owner_plan_data_when_trial_status_is_none(self):
+ now = timezone.now()
+ later = now + timedelta(days=14)
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.TRIAL_PLAN_NAME.value,
+ trial_start_date=now,
+ trial_end_date=later,
+ trial_status=None,
+ )
+ query = """{
+ owner(username: "%s") {
+ plan {
+ trialStatus
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["plan"]["trialStatus"] == "NOT_STARTED"
diff --git a/apps/codecov-api/graphql_api/tests/test_plan_representation.py b/apps/codecov-api/graphql_api/tests/test_plan_representation.py
new file mode 100644
index 0000000000..3f8b5d5958
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_plan_representation.py
@@ -0,0 +1,63 @@
+from datetime import timedelta
+
+from django.test import TestCase
+from django.utils import timezone
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName, TrialStatus
+
+from billing.helpers import mock_all_plans_and_tiers
+
+from .helper import GraphQLTestHelper
+
+
+class TestPlanRepresentationsType(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ mock_all_plans_and_tiers()
+ self.current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ trial_start_date=timezone.now(),
+ trial_end_date=timezone.now() + timedelta(days=14),
+ plan=PlanName.USERS_DEVELOPER.value,
+ )
+
+ @freeze_time("2023-06-19")
+ def test_owner_pretrial_plan_data_when_trialing(self):
+ now = timezone.now()
+ later = timezone.now() + timedelta(days=14)
+ current_org = OwnerFactory(
+ username="random-plan-user",
+ service="github",
+ plan=PlanName.TRIAL_PLAN_NAME.value,
+ trial_start_date=now,
+ trial_end_date=later,
+ trial_status=TrialStatus.ONGOING.value,
+ pretrial_users_count=234,
+ )
+ query = """{
+ owner(username: "%s") {
+ pretrialPlan {
+ marketingName
+ value
+ billingRate
+ baseUnitPrice
+ benefits
+ monthlyUploadLimit
+ }
+ }
+ }
+ """ % (current_org.username)
+ data = self.gql_request(query, owner=current_org)
+ assert data["owner"]["pretrialPlan"] == {
+ "marketingName": "Developer",
+ "value": DEFAULT_FREE_PLAN,
+ "billingRate": None,
+ "baseUnitPrice": 0,
+ "benefits": [
+ "Up to 234 users",
+ "Unlimited public repositories",
+ "Unlimited private repositories",
+ ],
+ "monthlyUploadLimit": 250,
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_pull.py b/apps/codecov-api/graphql_api/tests/test_pull.py
new file mode 100644
index 0000000000..984806ad64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_pull.py
@@ -0,0 +1,682 @@
+import os
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import StoragePaths
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.storage.memory import MemoryStorageService
+
+from compare.tests.factories import CommitComparisonFactory
+from core.models import Commit
+from reports.models import CommitReport
+from reports.tests.factories import CommitReportFactory, ReportLevelTotalsFactory
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+query_list_pull_request = """{
+ me {
+ owner {
+ repository(name: "test-repo-for-pull") {
+ ... on Repository {
+ name
+ pulls {
+ edges {
+ node {
+ title
+ pullId
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+default_pull_request_detail_query = """
+ title
+ state
+ pullId
+ updatestamp
+ author {
+ username
+ }
+ head {
+ coverageAnalytics {
+ totals {
+ coverage
+ }
+ }
+ }
+ comparedTo {
+ commitid
+ }
+ compareWithBase {
+ __typename
+ ... on Comparison {
+ patchTotals {
+ coverage
+ }
+ }
+ }
+ behindBy
+ behindByCommit
+"""
+
+pull_request_detail_query_with_bundle_analysis = """
+ title
+ state
+ pullId
+ updatestamp
+ author {
+ username
+ }
+ head {
+ coverageAnalytics {
+ totals {
+ coverage
+ }
+ }
+ }
+ comparedTo {
+ commitid
+ }
+ compareWithBase {
+ __typename
+ ... on Comparison {
+ patchTotals {
+ coverage
+ }
+ }
+ }
+ bundleAnalysisCompareWithBase {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ behindBy
+ behindByCommit
+"""
+
+pull_request_bundle_analysis_missing_reports = """
+ bundleAnalysisCompareWithBase {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+"""
+
+query_pull_request_detail = """{
+ me {
+ owner {
+ repository(name: "test-repo-for-pull") {
+ ... on Repository {
+ name
+ pull(id: %s) {
+ %s
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class TestPullRequestList(GraphQLTestHelper, TestCase):
+ def fetch_list_pull_request(self):
+ data = self.gql_request(query_list_pull_request, owner=self.owner)
+ return paginate_connection(data["me"]["owner"]["repository"]["pulls"])
+
+ def fetch_one_pull_request(self, id, query=default_pull_request_detail_query):
+ data = self.gql_request(
+ query_pull_request_detail % (id, query), owner=self.owner
+ )
+ return data["me"]["owner"]["repository"]["pull"]
+
+ def setUp(self):
+ self.owner = OwnerFactory(username="test-pull-user")
+ self.repository = RepositoryFactory(
+ author=self.owner, active=True, private=True, name="test-repo-for-pull"
+ )
+
+ def test_fetch_list_pull_request(self):
+ pull_1 = PullFactory(repository=self.repository, title="a")
+ pull_2 = PullFactory(repository=self.repository, title="b")
+ pulls = self.fetch_list_pull_request()
+ pull_titles = [pull["title"] for pull in pulls]
+ assert pull_1.title in pull_titles
+ assert pull_2.title in pull_titles
+
+ @freeze_time("2021-02-02 00:00:00")
+ @patch("core.commands.pull.interactors.fetch_pull_request.TaskService")
+ def test_when_repository_has_null_compared_to(self, mock_task_service):
+ my_pull = PullFactory(
+ repository=self.repository,
+ title="test-null-base",
+ author=self.owner,
+ head=CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="5672734ij1n234918231290j12nasdfioasud0f9",
+ ).commitid,
+ compared_to=None,
+ )
+ with freeze_time("2021-02-02 06:00:00"):
+ pull = self.fetch_one_pull_request(
+ my_pull.pullid, pull_request_detail_query_with_bundle_analysis
+ )
+ assert pull == {
+ "title": "test-null-base",
+ "state": "OPEN",
+ "pullId": my_pull.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": {"coverageAnalytics": {"totals": None}},
+ "comparedTo": None,
+ "compareWithBase": {
+ "__typename": "MissingBaseCommit",
+ },
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "MissingBaseCommit",
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+ mock_task_service.return_value.pulls_sync.assert_called_with(
+ my_pull.repository.repoid, my_pull.pullid
+ )
+
+ @freeze_time("2021-02-02 00:00:00")
+ @patch("core.commands.pull.interactors.fetch_pull_request.TaskService")
+ def test_when_repository_has_null_author(self, mock_task_service):
+ PullFactory(
+ repository=self.repository,
+ title="dummy-first-pr",
+ )
+ second_pr = PullFactory(
+ repository=self.repository,
+ title="test-null-author",
+ author=None,
+ head=None,
+ compared_to=None,
+ )
+ pull = self.fetch_one_pull_request(
+ second_pr.pullid, pull_request_detail_query_with_bundle_analysis
+ )
+ assert pull == {
+ "title": "test-null-author",
+ "state": "OPEN",
+ "pullId": second_pr.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": None,
+ "head": None,
+ "comparedTo": None,
+ "compareWithBase": {
+ "__typename": "MissingBaseCommit",
+ },
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "MissingBaseCommit",
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+ mock_task_service.return_value.pulls_sync.assert_not_called()
+
+ @freeze_time("2021-02-02")
+ @patch("core.commands.pull.interactors.fetch_pull_request.TaskService")
+ def test_when_repository_has_null_head_no_parent_report(self, mock_task_service):
+ PullFactory(
+ repository=self.repository,
+ title="dummy-first-pr",
+ author=self.owner,
+ )
+ second_pr = PullFactory(
+ repository=self.repository,
+ title="test-null-head",
+ author=self.owner,
+ head=None,
+ )
+ pull = self.fetch_one_pull_request(
+ second_pr.pullid, pull_request_detail_query_with_bundle_analysis
+ )
+ assert pull == {
+ "title": "test-null-head",
+ "state": "OPEN",
+ "pullId": second_pr.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": None,
+ "comparedTo": None,
+ "compareWithBase": {
+ "__typename": "MissingHeadCommit",
+ },
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "MissingHeadReport",
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+ mock_task_service.return_value.pulls_sync.assert_not_called()
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_when_repository_has_null_head_has_parent_report(self, get_storage_service):
+ os.system("rm -rf /tmp/bundle_analysis_*")
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ parent_commit = CommitFactory(repository=self.repository)
+
+ base_commit_report = CommitReportFactory(
+ commit=parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+
+ my_pull = PullFactory(
+ repository=self.repository,
+ title="test-pull-request",
+ author=self.owner,
+ head=None,
+ compared_to=base_commit_report.commit.commitid,
+ behind_by=23,
+ behind_by_commit="1089nf898as-jdf09hahs09fgh",
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repository),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ bundleAnalysisCompareWithBase {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ bundleChange {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ """
+
+ pull = self.fetch_one_pull_request(my_pull.pullid, query)
+
+ assert pull == {
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "BundleAnalysisComparison",
+ "bundleData": {
+ "size": {
+ "uncompress": 165165,
+ }
+ },
+ "bundleChange": {
+ "size": {
+ "uncompress": 0,
+ }
+ },
+ }
+ }
+
+ for file in os.listdir("/tmp"):
+ assert not file.startswith("bundle_analysis_")
+
+ os.system("rm -rf /tmp/bundle_analysis_*")
+
+ @freeze_time("2021-02-02")
+ def test_when_pr_is_first_pr_in_repo(self):
+ first_pr = PullFactory(
+ repository=self.repository,
+ title="dummy-first-pr",
+ author=self.owner,
+ compared_to=None,
+ )
+
+ res = self.fetch_one_pull_request(
+ first_pr.pullid, pull_request_detail_query_with_bundle_analysis
+ )
+ assert res == {
+ "title": "dummy-first-pr",
+ "state": "OPEN",
+ "pullId": first_pr.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": None,
+ "comparedTo": None,
+ "compareWithBase": {
+ "__typename": "FirstPullRequest",
+ },
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "FirstPullRequest",
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+
+ @freeze_time("2021-02-02")
+ def test_when_repository_has_missing_head_commit(self):
+ PullFactory(
+ repository=self.repository,
+ title="test-missing-head-commit",
+ author=self.owner,
+ )
+ second_pull = PullFactory(
+ repository=self.repository,
+ title="second-pr-so-it-doesn't-fetch-first-pr",
+ author=self.owner,
+ )
+ Commit.objects.filter(
+ repository_id=self.repository.pk,
+ commitid=second_pull.head,
+ ).delete()
+
+ res = self.fetch_one_pull_request(second_pull.pullid)
+ assert res == {
+ "title": "second-pr-so-it-doesn't-fetch-first-pr",
+ "state": "OPEN",
+ "pullId": second_pull.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": None,
+ "comparedTo": None,
+ "compareWithBase": {
+ "__typename": "MissingComparison",
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+
+ @freeze_time("2021-02-02")
+ def test_with_complete_pull_request(self):
+ head = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="5672734ij1n234918231290j12nasdfioasud0f9",
+ totals={"c": "78.38", "diff": [0, 0, 0, 0, 0, "14"]},
+ )
+ report = CommitReportFactory(commit=head)
+ ReportLevelTotalsFactory(report=report, coverage=78.38)
+ compared_to = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="9asd78fa7as8d8fa97s8d7fgagsd8fa9asd8f77s",
+ )
+ CommitComparisonFactory(
+ base_commit=compared_to,
+ compare_commit=head,
+ patch_totals={"coverage": 0.8739},
+ )
+ my_pull = PullFactory(
+ repository=self.repository,
+ title="test-pull-request",
+ author=self.owner,
+ head=head.commitid,
+ compared_to=compared_to.commitid,
+ behind_by=23,
+ behind_by_commit="1089nf898as-jdf09hahs09fgh",
+ )
+ pull = self.fetch_one_pull_request(my_pull.pullid)
+ assert pull == {
+ "title": "test-pull-request",
+ "state": "OPEN",
+ "pullId": my_pull.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": {"coverageAnalytics": {"totals": {"coverage": 78.38}}},
+ "comparedTo": {"commitid": "9asd78fa7as8d8fa97s8d7fgagsd8fa9asd8f77s"},
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "patchTotals": {"coverage": 87.39},
+ },
+ "behindBy": 23,
+ "behindByCommit": "1089nf898as-jdf09hahs09fgh",
+ }
+
+ def test_compare_bundle_analysis_missing_reports(self):
+ head = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="cool-commit-id",
+ totals={"c": "78.38", "diff": [0, 0, 0, 0, 0, "14"]},
+ )
+ compared_to = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="blah",
+ )
+
+ my_pull = PullFactory(
+ repository=self.repository,
+ author=self.owner,
+ head=head.commitid,
+ compared_to=compared_to.commitid,
+ )
+
+ pull = self.fetch_one_pull_request(
+ my_pull.pullid, pull_request_bundle_analysis_missing_reports
+ )
+ assert pull == {
+ "bundleAnalysisCompareWithBase": {"__typename": "MissingHeadReport"}
+ }
+
+ CommitReportFactory(
+ commit=head, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ pull = self.fetch_one_pull_request(
+ my_pull.pullid, pull_request_bundle_analysis_missing_reports
+ )
+ assert pull == {
+ "bundleAnalysisCompareWithBase": {"__typename": "MissingBaseReport"}
+ }
+
+ @patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_sqlite_file_deleted(self, get_storage_service):
+ os.system("rm -rf /tmp/bundle_analysis_*")
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ parent_commit = CommitFactory(repository=self.repository)
+ commit = CommitFactory(
+ repository=self.repository,
+ totals={"c": "12", "diff": [0, 0, 0, 0, 0, "14"]},
+ parent_commit_id=parent_commit.commitid,
+ )
+
+ base_commit_report = CommitReportFactory(
+ commit=parent_commit,
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+ head_commit_report = CommitReportFactory(
+ commit=commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ my_pull = PullFactory(
+ repository=self.repository,
+ title="test-pull-request",
+ author=self.owner,
+ head=head_commit_report.commit.commitid,
+ compared_to=base_commit_report.commit.commitid,
+ behind_by=23,
+ behind_by_commit="1089nf898as-jdf09hahs09fgh",
+ )
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repository),
+ report_key=base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repository),
+ report_key=head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ query = """
+ bundleAnalysisCompareWithBase {
+ __typename
+ ... on BundleAnalysisComparison {
+ bundleData {
+ size {
+ uncompress
+ }
+ }
+ }
+ }
+ """
+
+ pull = self.fetch_one_pull_request(my_pull.pullid, query)
+
+ assert pull == {
+ "bundleAnalysisCompareWithBase": {
+ "__typename": "BundleAnalysisComparison",
+ "bundleData": {
+ "size": {
+ "uncompress": 201720,
+ }
+ },
+ }
+ }
+
+ for file in os.listdir("/tmp"):
+ assert not file.startswith("bundle_analysis_")
+
+ os.system("rm -rf /tmp/bundle_analysis_*")
+
+ @freeze_time("2021-02-02")
+ def test_pull_no_patch_totals(self):
+ head = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="5672734ij1n234918231290j12nasdfioasud0f9",
+ totals=None,
+ )
+ report = CommitReportFactory(commit=head)
+ ReportLevelTotalsFactory(report=report, coverage=78.38)
+ compared_to = CommitFactory(
+ repository=self.repository,
+ author=self.owner,
+ commitid="9asd78fa7as8d8fa97s8d7fgagsd8fa9asd8f77s",
+ )
+ CommitComparisonFactory(
+ base_commit=compared_to, compare_commit=head, patch_totals=None
+ )
+ my_pull = PullFactory(
+ repository=self.repository,
+ title="test-pull-request",
+ author=self.owner,
+ head=head.commitid,
+ compared_to=compared_to.commitid,
+ )
+ pull = self.fetch_one_pull_request(my_pull.pullid)
+ assert pull == {
+ "title": "test-pull-request",
+ "state": "OPEN",
+ "pullId": my_pull.pullid,
+ "updatestamp": "2021-02-02T00:00:00",
+ "author": {"username": "test-pull-user"},
+ "head": {"coverageAnalytics": {"totals": {"coverage": 78.38}}},
+ "comparedTo": {"commitid": "9asd78fa7as8d8fa97s8d7fgagsd8fa9asd8f77s"},
+ "compareWithBase": {
+ "__typename": "Comparison",
+ "patchTotals": None,
+ },
+ "behindBy": None,
+ "behindByCommit": None,
+ }
+
+ @freeze_time("2021-02-02")
+ def test_fetch_commits_request(self):
+ query = """
+ commits {
+ totalCount
+ edges {
+ node {
+ commitid
+ }
+ }
+ }
+ """
+ my_pull = PullFactory(repository=self.repository)
+
+ CommitFactory(
+ repository=self.repository,
+ pullid=my_pull.pullid,
+ commitid="11111",
+ timestamp=datetime.today() - timedelta(days=1),
+ )
+ CommitFactory(
+ repository=self.repository,
+ pullid=my_pull.pullid,
+ commitid="22222",
+ timestamp=datetime.today() - timedelta(days=2),
+ )
+ CommitFactory(
+ repository=self.repository,
+ pullid=my_pull.pullid,
+ commitid="33333",
+ timestamp=datetime.today() - timedelta(days=3),
+ )
+ CommitFactory(
+ repository=self.repository,
+ pullid=my_pull.pullid,
+ commitid="44444",
+ timestamp=datetime.today() - timedelta(days=3),
+ deleted=True,
+ )
+
+ pull = self.fetch_one_pull_request(my_pull.pullid, query)
+
+ assert pull == {
+ "commits": {
+ "edges": [
+ {"node": {"commitid": "11111"}},
+ {"node": {"commitid": "22222"}},
+ {"node": {"commitid": "33333"}},
+ ],
+ "totalCount": 3,
+ }
+ }
+
+ def test_fetch_first_pull(self):
+ pull1 = PullFactory(repository=self.repository)
+ assert self.fetch_one_pull_request(pull1.pullid, "firstPull") == {
+ "firstPull": True
+ }
+ pull2 = PullFactory(repository=self.repository)
+ assert self.fetch_one_pull_request(pull1.pullid, "firstPull") == {
+ "firstPull": True
+ }
+ assert self.fetch_one_pull_request(pull2.pullid, "firstPull") == {
+ "firstPull": False
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_pull_comparison.py b/apps/codecov-api/graphql_api/tests/test_pull_comparison.py
new file mode 100644
index 0000000000..93421d7c48
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_pull_comparison.py
@@ -0,0 +1,1034 @@
+from collections import namedtuple
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitWithReportFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.types import ReportTotals
+from shared.utils.merge import LineType
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory, FlagComparisonFactory
+from reports.tests.factories import RepositoryFlagFactory
+
+from .helper import GraphQLTestHelper
+
+base_query = """{
+ me {
+ owner {
+ repository(name: "%s") {
+ ... on Repository {
+ pull(id: %s) {
+ %s
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+MockSegmentComparison = namedtuple(
+ "MockSegmentComparison", ["header", "lines", "has_unintended_changes"]
+)
+MockLineComparison = namedtuple(
+ "MockLineComparison",
+ ["number", "coverage", "value", "is_diff", "hit_session_ids"],
+)
+
+
+class TestPullComparison(TestCase, GraphQLTestHelper):
+ def _request(self, query):
+ data = self.gql_request(
+ base_query % (self.repository.name, self.pull.pullid, query),
+ owner=self.owner,
+ )
+ return data["me"]["owner"]["repository"]["pull"]
+
+ def setUp(self):
+ # mock reports for all tests in this class
+ self.head_report_patcher = patch(
+ "services.comparison.Comparison.head_report", new_callable=PropertyMock
+ )
+ self.head_report = self.head_report_patcher.start()
+ self.head_report.return_value = None
+ self.addCleanup(self.head_report_patcher.stop)
+ self.head_report_without_diff_patcher = patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ self.head_report_without_diff = self.head_report_without_diff_patcher.start()
+ self.head_report_without_diff.return_value = None
+ self.addCleanup(self.head_report_without_diff_patcher.stop)
+ self.base_report_patcher = patch(
+ "services.comparison.Comparison.base_report", new_callable=PropertyMock
+ )
+ self.base_report = self.base_report_patcher.start()
+ self.base_report.return_value = None
+ self.addCleanup(self.base_report_patcher.stop)
+
+ self.owner = OwnerFactory()
+ self.repository = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ )
+ self.base_commit = CommitWithReportFactory(
+ repository=self.repository,
+ author=self.owner,
+ )
+ self.head_commit = CommitWithReportFactory(
+ parent_commit_id=self.base_commit.commitid,
+ repository=self.repository,
+ author=self.owner,
+ )
+ self.commit_comparison = CommitComparisonFactory(
+ base_commit=self.base_commit,
+ compare_commit=self.head_commit,
+ state=CommitComparison.CommitComparisonStates.PROCESSED,
+ )
+ self.pull = PullFactory(
+ pullid=2,
+ repository=self.repository,
+ author=self.owner,
+ head=self.head_commit.commitid,
+ compared_to=self.base_commit.commitid,
+ )
+
+ def test_pull_comparison_totals(self):
+ base_totals = self.base_commit.reports.first().reportleveltotals
+ base_totals.coverage = 75.0
+ base_totals.files = 1
+ base_totals.lines = 6
+ base_totals.hits = 3
+ base_totals.misses = 2
+ base_totals.partials = 1
+ base_totals.branches = 0
+ base_totals.methods = 0
+ base_totals.save()
+
+ head_totals = self.head_commit.reports.first().reportleveltotals
+ head_totals.coverage = 75.0
+ head_totals.files = 1
+ head_totals.lines = 6
+ head_totals.hits = 3
+ head_totals.misses = 2
+ head_totals.partials = 1
+ head_totals.branches = 0
+ head_totals.methods = 0
+ head_totals.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ baseTotals {
+ percentCovered
+ fileCount
+ lineCount
+ hitsCount
+ missesCount
+ partialsCount
+ }
+ headTotals {
+ percentCovered
+ fileCount
+ lineCount
+ hitsCount
+ missesCount
+ partialsCount
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ totals = {
+ "percentCovered": 75.0,
+ "fileCount": 1,
+ "lineCount": 6,
+ "hitsCount": 3,
+ "missesCount": 2,
+ "partialsCount": 1,
+ }
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "baseTotals": totals,
+ "headTotals": totals,
+ },
+ }
+
+ def test_pull_no_flag_comparisons_for_commit_comparison(self):
+ # Just running this w/ the commit_comparison in setup will yield nothing
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ flagComparisons {
+ name
+ patchTotals {
+ percentCovered
+ }
+ headTotals {
+ percentCovered
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {"compareWithBase": {"flagComparisons": []}}
+
+ @patch("services.task.TaskService.compute_comparisons")
+ def test_pull_different_number_of_head_and_base_reports_without_context(self, _):
+ # Just running this w/ the commit_comparison in setup will yield nothing
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ hasDifferentNumberOfHeadAndBaseReports
+ }
+ }
+ """
+ self.commit_comparison.delete()
+ res = self._request(query)
+ assert res == {
+ "compareWithBase": {"hasDifferentNumberOfHeadAndBaseReports": False}
+ }
+
+ @patch("services.task.TaskService.compute_comparisons")
+ def test_pull_component_comparison_without_context(self, _):
+ # Just running this w/ the commit_comparison in setup will yield nothing
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ componentComparisons {
+ name
+ }
+ }
+ }
+ """
+ self.commit_comparison.delete()
+ res = self._request(query)
+ assert res == {"compareWithBase": {"componentComparisons": []}}
+
+ def test_pull_flag_comparisons(self):
+ FlagComparisonFactory(
+ commit_comparison=self.commit_comparison,
+ repositoryflag=RepositoryFlagFactory(
+ repository=self.repository, flag_name="flag_one"
+ ),
+ head_totals={"coverage": "85.71429"},
+ base_totals={"coverage": "92.2973"},
+ patch_totals={"coverage": "29.28364"},
+ )
+ FlagComparisonFactory(
+ commit_comparison=self.commit_comparison,
+ repositoryflag=RepositoryFlagFactory(
+ repository=self.repository, flag_name="flag_two"
+ ),
+ head_totals={"coverage": "75.273820"},
+ base_totals={"coverage": "16.293"},
+ patch_totals={"coverage": "68.283496"},
+ )
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ flagComparisonsCount
+ flagComparisons {
+ name
+ patchTotals {
+ percentCovered
+ }
+ headTotals {
+ percentCovered
+ }
+ baseTotals {
+ percentCovered
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "compareWithBase": {
+ "flagComparisonsCount": 2,
+ "flagComparisons": [
+ {
+ "name": "flag_one",
+ "patchTotals": {"percentCovered": 29.28364},
+ "headTotals": {"percentCovered": 85.71429},
+ "baseTotals": {"percentCovered": 92.2973},
+ },
+ {
+ "name": "flag_two",
+ "patchTotals": {"percentCovered": 68.283496},
+ "headTotals": {"percentCovered": 75.27382},
+ "baseTotals": {"percentCovered": 16.293},
+ },
+ ],
+ }
+ }
+
+ def test_pull_flag_comparisons_with_filter(self):
+ FlagComparisonFactory(
+ commit_comparison=self.commit_comparison,
+ repositoryflag=RepositoryFlagFactory(
+ repository=self.repository, flag_name="flag_one"
+ ),
+ head_totals={"coverage": "85.71429"},
+ base_totals={"coverage": "92.2973"},
+ patch_totals={"coverage": "29.28364"},
+ )
+ FlagComparisonFactory(
+ commit_comparison=self.commit_comparison,
+ repositoryflag=RepositoryFlagFactory(
+ repository=self.repository, flag_name="flag_two"
+ ),
+ head_totals={"coverage": "75.273820"},
+ base_totals={"coverage": "16.293"},
+ patch_totals={"coverage": "68.283496"},
+ )
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ flagComparisons(filters: { term: "flag_two"}) {
+ name
+ patchTotals {
+ percentCovered
+ }
+ headTotals {
+ percentCovered
+ }
+ baseTotals {
+ percentCovered
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "compareWithBase": {
+ "flagComparisons": [
+ {
+ "name": "flag_two",
+ "patchTotals": {"percentCovered": 68.283496},
+ "headTotals": {"percentCovered": 75.27382},
+ "baseTotals": {"percentCovered": 16.293},
+ },
+ ],
+ }
+ }
+
+ @patch(
+ "services.comparison.Comparison.has_different_number_of_head_and_base_sessions",
+ new_callable=PropertyMock,
+ )
+ def test_compare_with_base_has_different_number_of_reports_on_head_and_base(
+ self, mock_has_different_number_of_head_and_base_sessions
+ ):
+ mock_has_different_number_of_head_and_base_sessions.return_value = True
+ query = """
+ compareWithBase {
+ ... on Comparison {
+ hasDifferentNumberOfHeadAndBaseReports
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "compareWithBase": {"hasDifferentNumberOfHeadAndBaseReports": True}
+ }
+
+ @patch(
+ "services.comparison.ComparisonReport.files",
+ new_callable=PropertyMock,
+ )
+ def test_pull_comparison_impacted_files(
+ self,
+ files_mock,
+ ):
+ base_report_totals = ReportTotals(
+ coverage=75.0,
+ files=1,
+ lines=6,
+ hits=3,
+ misses=2,
+ partials=1,
+ )
+ head_report_totals = ReportTotals(
+ coverage=85.0,
+ files=1,
+ lines=6,
+ hits=3,
+ misses=2,
+ partials=1,
+ diff=None,
+ )
+ patch_totals = ReportTotals(
+ coverage=0.5,
+ files=1,
+ lines=2,
+ hits=1,
+ misses=1,
+ partials=0,
+ )
+
+ TestImpactedFile = namedtuple(
+ "TestImpactedFile",
+ [
+ "base_name",
+ "head_name",
+ "base_coverage",
+ "head_coverage",
+ "patch_coverage",
+ ],
+ )
+
+ files_mock.return_value = [
+ TestImpactedFile(
+ base_name="foo.py",
+ head_name="bar.py",
+ base_coverage=base_report_totals,
+ head_coverage=head_report_totals,
+ patch_coverage=patch_totals,
+ ),
+ TestImpactedFile(
+ base_name=None,
+ head_name="baz.py",
+ base_coverage=base_report_totals,
+ head_coverage=head_report_totals,
+ patch_coverage=patch_totals,
+ ),
+ ]
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ impactedFiles(filters:{}) {
+ ... on ImpactedFiles {
+ results {
+ baseName
+ headName
+ isNewFile
+ isRenamedFile
+ isDeletedFile
+ baseCoverage {
+ percentCovered
+ fileCount
+ lineCount
+ hitsCount
+ missesCount
+ partialsCount
+ }
+ headCoverage {
+ percentCovered
+ fileCount
+ lineCount
+ hitsCount
+ missesCount
+ partialsCount
+ }
+ patchCoverage {
+ percentCovered
+ fileCount
+ lineCount
+ hitsCount
+ missesCount
+ partialsCount
+ }
+ }
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ """
+
+ base_totals = {
+ "percentCovered": 75.0,
+ "fileCount": 1,
+ "lineCount": 6,
+ "hitsCount": 3,
+ "missesCount": 2,
+ "partialsCount": 1,
+ }
+ head_totals = {
+ "percentCovered": 85.0,
+ "fileCount": 1,
+ "lineCount": 6,
+ "hitsCount": 3,
+ "missesCount": 2,
+ "partialsCount": 1,
+ }
+ patch_totals = {
+ "percentCovered": 0.5,
+ "fileCount": 1,
+ "lineCount": 2,
+ "hitsCount": 1,
+ "missesCount": 1,
+ "partialsCount": 0,
+ }
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "impactedFiles": {
+ "results": [
+ {
+ "baseName": "foo.py",
+ "headName": "bar.py",
+ "isNewFile": False,
+ "isRenamedFile": True,
+ "isDeletedFile": False,
+ "baseCoverage": base_totals,
+ "headCoverage": head_totals,
+ "patchCoverage": patch_totals,
+ },
+ {
+ "baseName": None,
+ "headName": "baz.py",
+ "isNewFile": True,
+ "isRenamedFile": False,
+ "isDeletedFile": False,
+ "baseCoverage": base_totals,
+ "headCoverage": head_totals,
+ "patchCoverage": patch_totals,
+ },
+ ]
+ },
+ },
+ }
+
+ @patch(
+ "services.comparison.PullRequestComparison.get_file_comparison",
+ )
+ @patch(
+ "services.comparison.PullRequestComparison.files",
+ new_callable=PropertyMock,
+ )
+ @patch(
+ "services.comparison.ComparisonReport.files",
+ new_callable=PropertyMock,
+ )
+ def test_pull_comparison_line_comparisons(
+ self, comparison_files_mock, files_mock, get_file_comparison
+ ):
+ TestFileComparison = namedtuple(
+ "TestFileComparison",
+ ["name", "head_name", "base_name", "has_diff", "has_changes", "segments"],
+ )
+
+ test_files = [
+ TestFileComparison(
+ name={"head": "file1", "base": "file1"},
+ head_name="file1",
+ base_name="file1",
+ has_diff=True,
+ has_changes=False,
+ segments=[
+ MockSegmentComparison(
+ header=(1, 2, 3, 4),
+ has_unintended_changes=False,
+ lines=[
+ MockLineComparison(
+ number={
+ "head": "1",
+ "base": "1",
+ },
+ coverage={
+ "base": LineType.hit,
+ "head": LineType.hit,
+ },
+ value=" line1",
+ is_diff=False,
+ hit_session_ids=[0],
+ ),
+ MockLineComparison(
+ number={
+ "base": None,
+ "head": "2",
+ },
+ coverage={
+ "base": None,
+ "head": LineType.hit,
+ },
+ value="+ line2",
+ is_diff=True,
+ hit_session_ids=[0, 1],
+ ),
+ ],
+ ),
+ ],
+ ),
+ TestFileComparison(
+ name={"head": "file2", "base": "file2"},
+ head_name="file2",
+ base_name="file2",
+ has_diff=False,
+ has_changes=True,
+ segments=[
+ MockSegmentComparison(
+ header=(1, None, 1, None),
+ has_unintended_changes=True,
+ lines=[
+ MockLineComparison(
+ number={
+ "head": "1",
+ "base": "1",
+ },
+ coverage={
+ "base": LineType.miss,
+ "head": LineType.hit,
+ },
+ value=" line1",
+ is_diff=True,
+ hit_session_ids=[0],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ]
+
+ comparison_files_mock.return_value = test_files
+ files_mock.return_value = test_files
+ get_file_comparison.side_effect = test_files
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ impactedFiles(filters: {}){
+ ... on ImpactedFiles {
+ results {
+ segments {
+ ... on SegmentComparisons {
+ results {
+ header
+ hasUnintendedChanges
+ lines {
+ baseNumber
+ headNumber
+ baseCoverage
+ headCoverage
+ content
+ coverageInfo(ignoredUploadIds: [1]) {
+ hitCount
+ hitUploadIds
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "impactedFiles": {
+ "results": [
+ {
+ "segments": {
+ "results": [
+ {
+ "header": "-1,2 +3,4",
+ "hasUnintendedChanges": False,
+ "lines": [
+ {
+ "baseNumber": "1",
+ "headNumber": "1",
+ "baseCoverage": "H",
+ "headCoverage": "H",
+ "content": " line1",
+ "coverageInfo": {
+ "hitCount": 1,
+ "hitUploadIds": [0],
+ },
+ },
+ {
+ "baseNumber": None,
+ "headNumber": "2",
+ "baseCoverage": None,
+ "headCoverage": "H",
+ "content": "+ line2",
+ "coverageInfo": {
+ "hitCount": 1,
+ "hitUploadIds": [0],
+ },
+ },
+ ],
+ }
+ ]
+ }
+ },
+ {
+ "segments": {
+ "results": [
+ {
+ "header": "-1 +1",
+ "hasUnintendedChanges": True,
+ "lines": [
+ {
+ "baseNumber": "1",
+ "headNumber": "1",
+ "baseCoverage": "M",
+ "headCoverage": "H",
+ "content": " line1",
+ "coverageInfo": {
+ "hitCount": 1,
+ "hitUploadIds": [0],
+ },
+ }
+ ],
+ }
+ ]
+ }
+ },
+ ]
+ },
+ },
+ }
+
+ @patch("services.comparison.PullRequestComparison.get_file_comparison")
+ @patch(
+ "services.comparison.PullRequestComparison.files",
+ new_callable=PropertyMock,
+ )
+ @patch(
+ "services.comparison.ComparisonReport.files",
+ new_callable=PropertyMock,
+ )
+ def test_pull_comparison_coverage_changes(
+ self, comparison_files_mock, files_mock, get_file_comparison_mock
+ ):
+ TestFileComparison = namedtuple(
+ "TestFileComparison",
+ ["has_diff", "has_changes", "segments", "name", "head_name", "base_name"],
+ )
+
+ test_file_comparison = TestFileComparison(
+ has_diff=False,
+ has_changes=True,
+ name={"head": "test", "base": "test"},
+ head_name="test",
+ base_name="test",
+ segments=[
+ MockSegmentComparison(
+ header=(1, 1, 1, 1),
+ has_unintended_changes=True,
+ lines=[
+ MockLineComparison(
+ number={
+ "head": "1",
+ "base": "1",
+ },
+ coverage={
+ "base": LineType.miss,
+ "head": LineType.hit,
+ },
+ value=" line1",
+ is_diff=True,
+ hit_session_ids=[0, 1],
+ ),
+ ],
+ ),
+ ],
+ )
+
+ get_file_comparison_mock.return_value = test_file_comparison
+
+ comparison_files_mock.return_value = [test_file_comparison]
+ files_mock.return_value = [test_file_comparison]
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ impactedFiles(filters: {}){
+ ... on ImpactedFiles {
+ results {
+ segments {
+ ... on SegmentComparisons {
+ results {
+ header
+ hasUnintendedChanges
+ lines {
+ baseNumber
+ headNumber
+ baseCoverage
+ headCoverage
+ content
+ }
+ }
+ }
+ }
+ }
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "impactedFiles": {
+ "results": [
+ {
+ "segments": {
+ "results": [
+ {
+ "header": "-1,1 +1,1",
+ "hasUnintendedChanges": True,
+ "lines": [
+ {
+ "baseNumber": "1",
+ "headNumber": "1",
+ "baseCoverage": "M",
+ "headCoverage": "H",
+ "content": " line1",
+ }
+ ],
+ }
+ ]
+ }
+ }
+ ]
+ },
+ },
+ }
+
+ def test_pull_comparison_pending(self):
+ self.commit_comparison.state = CommitComparison.CommitComparisonStates.PENDING
+ self.commit_comparison.save()
+
+ # these are created by default in the factory
+ base_report = self.base_commit.reports.first()
+ base_report.reportleveltotals.delete()
+ head_report = self.head_commit.reports.first()
+ head_report.reportleveltotals.delete()
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ state
+ baseTotals {
+ percentCovered
+ }
+ headTotals {
+ percentCovered
+ }
+ impactedFiles(filters: {}) {
+ ... on ImpactedFiles {
+ results {
+ baseName
+ headName
+ }
+ }
+ ... on UnknownFlags {
+ message
+ }
+ }
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "state": "pending",
+ "baseTotals": None,
+ "headTotals": None,
+ "impactedFiles": {"results": []},
+ },
+ }
+
+ @patch("services.task.TaskService.compute_comparisons")
+ def test_pull_comparison_no_comparison(self, compute_comparisons_mock):
+ self.commit_comparison.delete()
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ state
+ }
+ }
+ """
+
+ res = self._request(query)
+ # it regenerates the comparison as needed
+ assert res["compareWithBase"] is not None
+
+ compute_comparisons_mock.assert_called_once
+
+ def test_pull_comparison_missing_when_commit_comparison_state_is_errored(self):
+ self.commit_comparison.state = CommitComparison.CommitComparisonStates.ERROR
+ self.commit_comparison.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {"__typename": "MissingComparison"},
+ }
+
+ def test_pull_comparison_missing_comparison(self):
+ self.head_commit.delete()
+ self.commit_comparison.delete()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {"__typename": "MissingComparison"},
+ }
+
+ def test_pull_comparison_missing_base_sha(self):
+ self.pull.compared_to = None
+ self.pull.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "__typename": "MissingBaseCommit",
+ "message": "Invalid base commit",
+ },
+ }
+
+ def test_pull_comparison_missing_head_sha(self):
+ self.pull.head = None
+ self.pull.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ ... on ResolverError {
+ message
+ }
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "__typename": "MissingHeadCommit",
+ "message": "Invalid head commit",
+ },
+ }
+
+ def test_pull_comparison_missing_base_report(self):
+ self.commit_comparison.error = "missing_base_report"
+ self.commit_comparison.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "__typename": "MissingBaseReport",
+ },
+ }
+
+ def test_pull_comparison_missing_head_report(self):
+ self.commit_comparison.error = "missing_head_report"
+ self.commit_comparison.save()
+
+ query = """
+ pullId
+ compareWithBase {
+ __typename
+ }
+ """
+
+ res = self._request(query)
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {
+ "__typename": "MissingHeadReport",
+ },
+ }
+
+ @patch("services.task.TaskService.compute_comparisons")
+ @patch("services.comparison.CommitComparisonService.needs_recompute")
+ def test_pull_comparison_needs_recalculation(
+ self, needs_recompute_mock, compute_comparisons_mock
+ ):
+ needs_recompute_mock.return_value = True
+
+ query = """
+ pullId
+ compareWithBase {
+ ... on Comparison {
+ state
+ }
+ }
+ """
+
+ res = self._request(query)
+ # recalculates comparison
+ assert res == {
+ "pullId": self.pull.pullid,
+ "compareWithBase": {"state": "pending"},
+ }
+ compute_comparisons_mock.assert_called_once
diff --git a/apps/codecov-api/graphql_api/tests/test_repository.py b/apps/codecov-api/graphql_api/tests/test_repository.py
new file mode 100644
index 0000000000..5eb3d17afb
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_repository.py
@@ -0,0 +1,741 @@
+import datetime
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from graphql_api.types.repository.repository import TOKEN_UNAVAILABLE
+
+from .helper import GraphQLTestHelper
+
+query_repository = """
+query Repository($name: String!){
+ me {
+ owner {
+ repository(name: $name) {
+ __typename
+ ... on Repository {
+ %s
+ }
+ ... on ResolverError {
+ message
+ }
+ }
+ }
+ }
+}
+"""
+
+query_repositories = """
+query Repositories($repoNames: [String!]!) {
+ me {
+ owner {
+ repositories(filters: { repoNames: $repoNames }) {
+ edges {
+ node {
+ %s
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+default_fields = """
+ repoid
+ name
+ active
+ private
+ updatedAt
+ latestCommitAt
+ oldestCommitAt
+ uploadToken
+ defaultBranch
+ author { username }
+ graphToken
+ yaml
+ isATSConfigured
+ primaryLanguage
+ languages
+ bundleAnalysisEnabled
+ coverageEnabled
+ bot { username }
+ testAnalyticsEnabled
+"""
+
+
+def mock_get_config_global_upload_tokens(*args):
+ if args == ("setup", "hide_all_codecov_tokens"):
+ return True
+
+
+class TestFetchRepository(GraphQLTestHelper, TestCase):
+ def fetch_repository(self, name, fields=None):
+ data = self.gql_request(
+ query_repository % (fields or default_fields),
+ owner=self.owner,
+ variables={"name": name},
+ )
+ return data["me"]["owner"]["repository"]
+
+ def fetch_repositories(self, repo_names, fields=None):
+ data = self.gql_request(
+ query_repositories % (fields or default_fields),
+ owner=self.owner,
+ variables={"repoNames": repo_names},
+ )
+ return [edge["node"] for edge in data["me"]["owner"]["repositories"]["edges"]]
+
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.yaml = {"test": "test"}
+
+ @freeze_time("2021-01-01")
+ def test_when_repository_has_no_coverage(self):
+ repo = RepositoryFactory(
+ repoid=1,
+ author=self.owner,
+ active=True,
+ private=True,
+ name="a",
+ yaml=self.yaml,
+ language="rust",
+ languages=["python", "rust"],
+ test_analytics_enabled=True,
+ )
+ graphToken = repo.image_token
+ assert self.fetch_repository(
+ repo.name,
+ default_fields
+ + "coverageAnalytics { percentCovered commitSha hits misses lines },",
+ ) == {
+ "__typename": "Repository",
+ "repoid": 1,
+ "name": "a",
+ "active": True,
+ "private": True,
+ "coverageAnalytics": {
+ "percentCovered": None,
+ "commitSha": None,
+ "hits": None,
+ "misses": None,
+ "lines": None,
+ },
+ "latestCommitAt": None,
+ "oldestCommitAt": None,
+ "updatedAt": "2021-01-01T00:00:00+00:00",
+ "uploadToken": repo.upload_token,
+ "defaultBranch": "main",
+ "author": {"username": "codecov-user"},
+ "graphToken": graphToken,
+ "yaml": "test: test\n",
+ "isATSConfigured": False,
+ "primaryLanguage": "rust",
+ "languages": ["python", "rust"],
+ "bundleAnalysisEnabled": False,
+ "coverageEnabled": False,
+ "bot": None,
+ "testAnalyticsEnabled": True,
+ }
+
+ @freeze_time("2021-01-01")
+ def test_when_repository_has_coverage(self):
+ repo = RepositoryFactory(
+ repoid=1,
+ author=self.owner,
+ active=True,
+ private=True,
+ name="b",
+ yaml=self.yaml,
+ language="erlang",
+ languages=[],
+ )
+
+ hour_ago = datetime.datetime.now() - datetime.timedelta(hours=1)
+ coverage_commit = CommitFactory(
+ repository=repo,
+ totals={"c": 75, "h": 30, "m": 10, "n": 40},
+ timestamp=hour_ago,
+ )
+ CommitFactory(repository=repo, totals={"c": 85})
+
+ # trigger in the database is updating `updatestamp` after creating
+ # associated commits
+ repo.updatestamp = datetime.datetime.now()
+ repo.save()
+
+ graphToken = repo.image_token
+ assert self.fetch_repository(
+ repo.name,
+ default_fields
+ + "coverageAnalytics { percentCovered commitSha hits misses lines },",
+ ) == {
+ "__typename": "Repository",
+ "repoid": 1,
+ "name": "b",
+ "active": True,
+ "latestCommitAt": None,
+ "oldestCommitAt": "2020-12-31T23:00:00", # hour ago
+ "private": True,
+ "coverageAnalytics": {
+ "percentCovered": 75,
+ "commitSha": coverage_commit.commitid,
+ "hits": 30,
+ "misses": 10,
+ "lines": 40,
+ },
+ "updatedAt": "2021-01-01T00:00:00+00:00",
+ "uploadToken": repo.upload_token,
+ "defaultBranch": "main",
+ "author": {"username": "codecov-user"},
+ "graphToken": graphToken,
+ "yaml": "test: test\n",
+ "isATSConfigured": False,
+ "primaryLanguage": "erlang",
+ "languages": [],
+ "bundleAnalysisEnabled": False,
+ "coverageEnabled": False,
+ "bot": None,
+ "testAnalyticsEnabled": False,
+ }
+
+ @freeze_time("2021-01-01")
+ def test_repositories_oldest_commit_at(self):
+ repo = RepositoryFactory(author=self.owner)
+
+ CommitFactory(repository=repo, totals={"c": 75})
+ CommitFactory(repository=repo, totals={"c": 85})
+
+ # oldestCommitAt not loaded for multiple repos
+ assert self.fetch_repositories([repo.name], fields="oldestCommitAt") == [
+ {
+ "oldestCommitAt": None,
+ }
+ ]
+
+ def test_repository_pulls(self):
+ repo = RepositoryFactory(author=self.owner, active=True, private=True, name="a")
+ PullFactory(repository=repo, pullid=2)
+ PullFactory(repository=repo, pullid=3)
+
+ res = self.fetch_repository(repo.name, "pulls { edges { node { pullId } } }")
+ assert res["pulls"]["edges"][0]["node"]["pullId"] == 3
+ assert res["pulls"]["edges"][1]["node"]["pullId"] == 2
+
+ def test_repository_get_static_analysis_token(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user, name="gazebo", active=True)
+ RepositoryTokenFactory(
+ repository=repo, key="random", token_type="static_analysis"
+ )
+
+ data = self.gql_request(
+ query_repository % "staticAnalysisToken",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["staticAnalysisToken"] == "random"
+
+ def test_repository_get_graph_token(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user)
+
+ data = self.gql_request(
+ query_repository % "graphToken",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["graphToken"] == repo.image_token
+
+ def test_repository_resolve_yaml(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user, name="has_yaml", yaml=self.yaml)
+ data = self.gql_request(
+ query_repository % "yaml",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["yaml"] == "test: test\n"
+
+ def test_repository_resolve_yaml_no_yaml(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user, name="no_yaml")
+ data = self.gql_request(
+ query_repository % "yaml",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["yaml"] is None
+
+ def test_repository_resolve_bot(self):
+ user = OwnerFactory()
+ bot = OwnerFactory(username="random_bot")
+ repo = RepositoryFactory(author=user, bot=bot)
+ data = self.gql_request(
+ query_repository % "bot {username}",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["bot"]["username"] == "random_bot"
+
+ def test_repository_resolve_activated_true(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user, activated=True)
+ data = self.gql_request(
+ query_repository % "activated",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["activated"] == True
+
+ @override_settings(TIMESERIES_ENABLED=False)
+ def test_repository_resolve_activated_false(self):
+ user = OwnerFactory()
+ repo = RepositoryFactory(author=user, activated=False)
+ data = self.gql_request(
+ query_repository % "activated",
+ owner=user,
+ variables={"name": repo.name},
+ )
+ assert data["me"]["owner"]["repository"]["activated"] == False
+
+ @patch("shared.yaml.user_yaml.UserYaml.get_final_yaml")
+ def test_repository_repository_config_indication_range(self, mocked_useryaml):
+ mocked_useryaml.return_value = {"coverage": {"range": [60, 80]}}
+
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ )
+
+ data = self.gql_request(
+ query_repository
+ % "repositoryConfig { indicationRange { upperRange lowerRange } }",
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert (
+ data["me"]["owner"]["repository"]["repositoryConfig"]["indicationRange"][
+ "lowerRange"
+ ]
+ == 60
+ )
+ assert (
+ data["me"]["owner"]["repository"]["repositoryConfig"]["indicationRange"][
+ "upperRange"
+ ]
+ == 80
+ )
+
+ @patch("shared.yaml.user_yaml.UserYaml.get_final_yaml")
+ def test_repository_repository_config_indication_range_float(self, mocked_useryaml):
+ mocked_useryaml.return_value = {"coverage": {"range": [61.1, 82.2]}}
+
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ )
+
+ data = self.gql_request(
+ query_repository
+ % "repositoryConfig { indicationRange { upperRange lowerRange } }",
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert (
+ data["me"]["owner"]["repository"]["repositoryConfig"]["indicationRange"][
+ "lowerRange"
+ ]
+ == 61.1
+ )
+ assert (
+ data["me"]["owner"]["repository"]["repositoryConfig"]["indicationRange"][
+ "upperRange"
+ ]
+ == 82.2
+ )
+
+ @patch("services.activation.try_auto_activate")
+ def test_repository_auto_activate(self, try_auto_activate):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ coverage_enabled=True,
+ bundle_analysis_enabled=True,
+ )
+
+ self.gql_request(
+ query_repository % "name",
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ try_auto_activate.assert_called_once_with(
+ self.owner,
+ self.owner,
+ )
+
+ @patch("services.activation.is_activated")
+ @patch("services.activation.try_auto_activate")
+ def test_resolve_inactive_user_on_unconfigured_repo(
+ self, try_auto_activate, is_activated
+ ):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=False,
+ activated=False,
+ private=True,
+ name="test-one",
+ coverage_enabled=True,
+ bundle_analysis_enabled=False,
+ )
+
+ is_activated.return_value = False
+
+ data = self.gql_request(
+ query_repository % "name",
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["name"] == "test-one"
+
+ def test_repository_not_found(self):
+ data = self.gql_request(
+ query_repository % "name",
+ owner=self.owner,
+ variables={"name": "nonexistent-repo-name"},
+ )
+ assert data["me"]["owner"]["repository"] == {
+ "__typename": "NotFoundError",
+ "message": "Not found",
+ }
+
+ def test_repository_has_ats_configured(self):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={
+ "flag_management": {"individual_flags": {"carryforward_mode": "labels"}}
+ },
+ )
+
+ res = self.fetch_repository(repo.name)
+ assert res["isATSConfigured"] == True
+
+ def test_repository_get_language(self):
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, language="python"
+ )
+
+ res = self.fetch_repository(repo.name)
+ assert res["primaryLanguage"] == "python"
+
+ def test_repository_get_bundle_analysis_enabled(self):
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, bundle_analysis_enabled=True
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["bundleAnalysisEnabled"] == True
+
+ def test_repository_get_coverage_enabled(self):
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, coverage_enabled=True
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["coverageEnabled"] == True
+
+ def test_repository_get_test_analytics_enabled(self) -> None:
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, test_analytics_enabled=True
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["testAnalyticsEnabled"] == True
+
+ def test_repository_get_test_analytics_disabled(self) -> None:
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, test_analytics_enabled=False
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["testAnalyticsEnabled"] == False
+
+ def test_repository_get_languages_null(self):
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, languages=None
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["languages"] is None
+
+ def test_repository_get_languages_empty(self):
+ repo = RepositoryFactory(author=self.owner, active=True, private=True)
+ res = self.fetch_repository(repo.name)
+ assert res["languages"] == []
+
+ def test_repository_get_languages_with_values(self):
+ repo = RepositoryFactory(
+ author=self.owner, active=True, private=True, languages=["C", "C++"]
+ )
+ res = self.fetch_repository(repo.name)
+ assert res["languages"] == ["C", "C++"]
+
+ def test_repository_is_first_pull_request(self) -> None:
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ PullFactory(repository=repo, pullid=1, compared_to=None)
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isFirstPullRequest
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["isFirstPullRequest"] == True
+
+ def test_repository_is_first_pull_request_compared_to_not_none(self) -> None:
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ PullFactory(repository=repo, pullid=1, compared_to=1)
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isFirstPullRequest
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["isFirstPullRequest"] == False
+
+ def test_repository_when_is_first_pull_request_false(self) -> None:
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ PullFactory(repository=repo, pullid=1)
+ PullFactory(repository=repo, pullid=2)
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isFirstPullRequest
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["isFirstPullRequest"] == False
+
+ @patch("shared.rate_limits.determine_entity_redis_key")
+ @patch("shared.rate_limits.determine_if_entity_is_rate_limited")
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=False)
+ def test_fetch_is_github_rate_limited(
+ self, mock_determine_rate_limit, mock_determine_redis_key
+ ):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ mock_determine_redis_key.return_value = "test"
+ mock_determine_rate_limit.return_value = True
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isGithubRateLimited
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["isGithubRateLimited"] == True
+
+ def test_fetch_is_github_rate_limited_not_on_gh_service(self):
+ owner = OwnerFactory(service="gitlab")
+ repo = RepositoryFactory(
+ author=owner,
+ author__service="gitlab",
+ service_id=12345,
+ active=True,
+ )
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isGithubRateLimited
+ """,
+ owner=owner,
+ variables={"name": repo.name},
+ provider="gitlab",
+ )
+
+ assert data["me"]["owner"]["repository"]["isGithubRateLimited"] == False
+
+ @override_settings(HIDE_ALL_CODECOV_TOKENS=True)
+ def test_repo_upload_token_not_available_config_setting_owner_not_admin(self):
+ owner = OwnerFactory(service="gitlab")
+
+ repo = RepositoryFactory(
+ author=owner,
+ author__service="gitlab",
+ service_id=12345,
+ active=True,
+ )
+ new_owner = OwnerFactory(service="gitlab", organizations=[owner.ownerid])
+ new_owner.permission = [repo.repoid]
+ new_owner.save()
+ owner.admins = []
+
+ query = """
+ query {
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ uploadToken
+ }
+ }
+ }
+ }
+ """ % (
+ owner.username,
+ repo.name,
+ )
+
+ data = self.gql_request(
+ query,
+ owner=new_owner,
+ variables={"name": repo.name},
+ provider="gitlab",
+ )
+
+ assert data["owner"]["repository"]["uploadToken"] == TOKEN_UNAVAILABLE
+
+ @override_settings(HIDE_ALL_CODECOV_TOKENS=True)
+ def test_repo_upload_token_not_available_config_setting_owner_is_anonymous(self):
+ owner = OwnerFactory(service="gitlab")
+
+ repo = RepositoryFactory(
+ author=owner,
+ author__service="gitlab",
+ service_id=12345,
+ active=True,
+ private=False,
+ )
+
+ query = """
+ query {
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ uploadToken
+ }
+ }
+ }
+ }
+ """ % (
+ owner.username,
+ repo.name,
+ )
+
+ data = self.gql_request(
+ query,
+ variables={"name": repo.name},
+ provider="gitlab",
+ )
+
+ assert data["owner"]["repository"]["uploadToken"] == TOKEN_UNAVAILABLE
+
+ @override_settings(HIDE_ALL_CODECOV_TOKENS=True)
+ def test_repo_upload_token_not_available_config_setting_owner_is_admin(self):
+ owner = OwnerFactory(service="gitlab")
+ repo = RepositoryFactory(
+ author=owner,
+ author__service="gitlab",
+ service_id=12345,
+ active=True,
+ )
+ owner.admins = [owner.ownerid]
+
+ data = self.gql_request(
+ query_repository
+ % """
+ uploadToken
+ """,
+ owner=owner,
+ variables={"name": repo.name},
+ provider="gitlab",
+ )
+
+ assert data["me"]["owner"]["repository"]["uploadToken"] != TOKEN_UNAVAILABLE
+
+ @patch("shared.rate_limits.determine_entity_redis_key")
+ @patch("shared.rate_limits.determine_if_entity_is_rate_limited")
+ @patch("logging.Logger.warning")
+ @override_settings(IS_ENTERPRISE=True, GUEST_ACCESS=False)
+ def test_fetch_is_github_rate_limited_but_errors(
+ self,
+ mock_log_warning,
+ mock_determine_rate_limit,
+ mock_determine_redis_key,
+ ):
+ repo = RepositoryFactory(
+ author=self.owner,
+ active=True,
+ private=True,
+ yaml={"component_management": {}},
+ )
+
+ mock_determine_redis_key.side_effect = Exception("some random error lol")
+ mock_determine_rate_limit.return_value = True
+
+ data = self.gql_request(
+ query_repository
+ % """
+ isGithubRateLimited
+ """,
+ owner=self.owner,
+ variables={"name": repo.name},
+ )
+
+ assert data["me"]["owner"]["repository"]["isGithubRateLimited"] is None
+
+ mock_log_warning.assert_any_call(
+ "Error when checking rate limit",
+ extra={
+ "repo_id": repo.repoid,
+ "has_owner": True,
+ },
+ )
diff --git a/apps/codecov-api/graphql_api/tests/test_requested_fields.py b/apps/codecov-api/graphql_api/tests/test_requested_fields.py
new file mode 100644
index 0000000000..c7ab1b1a54
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_requested_fields.py
@@ -0,0 +1,208 @@
+from graphql import GraphQLResolveInfo
+from graphql.language import (
+ FragmentDefinitionNode,
+ OperationDefinitionNode,
+ parse,
+)
+
+from graphql_api.helpers.requested_fields import selected_fields
+
+
+def parse_into_resolveinfo(source: str) -> GraphQLResolveInfo:
+ document = parse(source)
+
+ operation: OperationDefinitionNode | None = None
+ fragments: dict[str, FragmentDefinitionNode] = {}
+
+ for definition in document.definitions:
+ if isinstance(definition, OperationDefinitionNode):
+ operation = definition
+ elif isinstance(definition, FragmentDefinitionNode):
+ fragments[definition.name.value] = definition
+
+ assert operation
+ root_fields = [operation]
+
+ return GraphQLResolveInfo(
+ "__root__",
+ root_fields, # list[FieldNode]
+ None,
+ None,
+ None,
+ None,
+ fragments, # dict[str, FragmentDefinitionNode]
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+
+
+QUERY_CoverageForFile = """
+query CoverageForFile(
+ $owner: String!
+ $repo: String!
+ $ref: String!
+ $path: String!
+ $flags: [String]
+ $components: [String]
+) {
+ owner(username: $owner) {
+ repository(name: $repo) {
+ __typename
+ ... on Repository {
+ commit(id: $ref) {
+ ...CoverageForFile
+ }
+ branch(name: $ref) {
+ name
+ head {
+ ...CoverageForFile
+ }
+ }
+ }
+ ... on NotFoundError {
+ message
+ }
+ ... on OwnerNotActivatedError {
+ message
+ }
+ }
+ }
+}
+
+fragment CoverageForFile on Commit {
+ commitid
+ coverageAnalytics {
+ flagNames
+ components {
+ id
+ name
+ }
+ coverageFile(path: $path, flags: $flags, components: $components) {
+ hashedPath
+ content
+ coverage {
+ line
+ coverage
+ }
+ totals {
+ percentCovered # Absolute coverage of the commit
+ }
+ }
+ }
+}
+"""
+
+QUERY_GetRepoConfigurationStatus = """
+query GetRepoConfigurationStatus($owner: String!, $repo: String!) {
+ owner(username: $owner) {
+ plan {
+ isTeamPlan
+ }
+ repository(name:$repo) {
+ __typename
+ ... on Repository {
+ coverageEnabled
+ bundleAnalysisEnabled
+ testAnalyticsEnabled
+ yaml
+ languages
+ coverageAnalytics {
+ flagsCount
+ componentsCount
+ }
+ }
+ ... on NotFoundError {
+ message
+ }
+ ... on OwnerNotActivatedError {
+ message
+ }
+ }
+ }
+}
+"""
+
+QUERY_ReposForOwner = """
+query ReposForOwner(
+ $filters: RepositorySetFilters!
+ $owner: String!
+ $ordering: RepositoryOrdering!
+ $direction: OrderingDirection!
+ $after: String
+ $first: Int
+) {
+ owner(username: $owner) {
+ repositories(
+ filters: $filters
+ ordering: $ordering
+ orderingDirection: $direction
+ first: $first
+ after: $after
+ ) {
+ edges {
+ node {
+ name
+ active
+ activated
+ private
+ coverageAnalytics {
+ percentCovered
+ lines
+ }
+ updatedAt
+ latestCommitAt
+ author {
+ username
+ }
+ coverageEnabled
+ bundleAnalysisEnabled
+ repositoryConfig {
+ indicationRange {
+ upperRange
+ lowerRange
+ }
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+}
+"""
+
+
+def test_requested_fields():
+ info = parse_into_resolveinfo(QUERY_CoverageForFile)
+ fields = selected_fields(info)
+
+ assert "owner.repository.branch.name" in fields
+ assert "owner.repository.branch.head.commitid" in fields
+ assert (
+ "owner.repository.branch.head.coverageAnalytics.coverageFile.totals.percentCovered"
+ in fields
+ )
+
+ assert "owner.repository.oldestCommitAt" not in fields
+ assert "owner.repository.coverageAnalytics.percentCovered" not in fields
+ assert "owner.repository.coverageAnalytics.commitSha" not in fields
+
+ info = parse_into_resolveinfo(QUERY_GetRepoConfigurationStatus)
+ fields = selected_fields(info)
+
+ assert "owner.repository.coverageAnalytics" in fields
+ assert "owner.repository.oldestCommitAt" not in fields
+ assert "owner.repository.coverageAnalytics.percentCovered" not in fields
+
+ info = parse_into_resolveinfo(QUERY_ReposForOwner)
+ fields = selected_fields(info)
+
+ assert "owner.repositories.edges.node.latestCommitAt" in fields
+ assert "owner.repositories.edges.node.oldestCommitAt" not in fields
+ assert "owner.repositories.edges.node.coverageAnalytics" in fields
+ assert "owner.repositories.edges.node.coverageAnalytics.percentCovered" in fields
diff --git a/apps/codecov-api/graphql_api/tests/test_session.py b/apps/codecov-api/graphql_api/tests/test_session.py
new file mode 100644
index 0000000000..c0d3fc15f4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_session.py
@@ -0,0 +1,59 @@
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, SessionFactory
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+query = """
+query MySession {
+ me {
+ sessions {
+ edges {
+ node {
+ name
+ ip
+ lastseen
+ useragent
+ type
+ lastFour
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class SessionTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+ self.session = SessionFactory(
+ owner=self.owner,
+ type="login",
+ name="test-123",
+ lastseen="2021-01-01T00:00:00+00:00",
+ )
+
+ @freeze_time("2021-01-01")
+ def test_fetching_session(self):
+ data = self.gql_request(query, owner=self.owner)
+ sessions = paginate_connection(data["me"]["sessions"])
+ current_session = self.owner.session_set.filter(type="login").first()
+ assert sessions == [
+ {
+ "name": current_session.name,
+ "ip": current_session.ip,
+ "lastseen": "2021-01-01T00:00:00+00:00",
+ "useragent": current_session.useragent,
+ "type": current_session.type,
+ "lastFour": str(current_session.token)[-4:],
+ }
+ ]
+
+ def test_fetching_session_doesnt_include_other_people_session(self):
+ random_user = OwnerFactory()
+ for _ in range(5):
+ SessionFactory(owner=random_user)
+ data = self.gql_request(query, owner=self.owner)
+ sessions = paginate_connection(data["me"]["sessions"])
+ assert len(sessions) == 1
diff --git a/apps/codecov-api/graphql_api/tests/test_test_analytics.py b/apps/codecov-api/graphql_api/tests/test_test_analytics.py
new file mode 100644
index 0000000000..c9b835e483
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_test_analytics.py
@@ -0,0 +1,723 @@
+import datetime
+from base64 import b64encode
+from typing import Any
+
+import polars as pl
+import pytest
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from shared.django_apps.core.tests.factories import RepositoryFactory
+from shared.helpers.redis import get_redis_connection
+from shared.storage.exceptions import BucketAlreadyExistsError
+from shared.storage.memory import MemoryStorageService
+
+from graphql_api.types.enums import (
+ OrderingDirection,
+ TestResultsOrderingParameter,
+)
+from graphql_api.types.enums.enum_types import MeasurementInterval
+from graphql_api.types.test_analytics.test_analytics import (
+ TestResultsRow,
+ encode_cursor,
+ generate_test_results,
+ get_results,
+)
+from utils.test_results import dedup_table
+
+from .helper import GraphQLTestHelper
+
+
+class RowFactory:
+ idx = 0
+
+ def __call__(self, updated_at: datetime.datetime) -> dict[str, Any]:
+ RowFactory.idx += 1
+ return {
+ "name": f"test{RowFactory.idx}",
+ "testsuite": f"testsuite{RowFactory.idx}",
+ "flags": [f"flag{RowFactory.idx}"],
+ "failure_rate": 0.1,
+ "flake_rate": 0.0,
+ "updated_at": updated_at,
+ "avg_duration": 100.0,
+ "total_fail_count": 1,
+ "total_flaky_fail_count": 1 if RowFactory.idx == 1 else 0,
+ "total_pass_count": 1,
+ "total_skip_count": 1,
+ "commits_where_fail": 1,
+ "last_duration": 100.0,
+ }
+
+
+@pytest.fixture
+def mock_storage(mocker):
+ m = mocker.patch("utils.test_results.get_appropriate_storage_service")
+ storage_server = MemoryStorageService({})
+ m.return_value = storage_server
+ yield storage_server
+
+
+base_gql_query = """
+ query {
+ owner(username: "%s") {
+ repository(name: "%s") {
+ ... on Repository {
+ testAnalytics {
+ %s
+ }
+ }
+ }
+ }
+ }
+"""
+
+
+rows = [RowFactory()(datetime.datetime(2024, 1, 1 + i)) for i in range(5)]
+
+
+rows_with_duplicate_names = [
+ RowFactory()(datetime.datetime(2024, 1, 1 + i)) for i in range(5)
+]
+for i in range(0, len(rows_with_duplicate_names) - 1, 2):
+ rows_with_duplicate_names[i]["name"] = rows_with_duplicate_names[i + 1]["name"]
+
+
+def dedup(rows: list[dict]) -> list[dict]:
+ by_name = {}
+ for row in rows:
+ if row["name"] not in by_name:
+ by_name[row["name"]] = []
+ by_name[row["name"]].append(row)
+
+ result = []
+ for name, group in by_name.items():
+ if len(group) == 1:
+ result.append(group[0])
+ continue
+
+ weights = [r["total_pass_count"] + r["total_fail_count"] for r in group]
+ total_weight = sum(weights)
+
+ merged = {
+ "name": name,
+ "testsuite": sorted({r["testsuite"] for r in group}),
+ "flags": sorted({flag for r in group for flag in r["flags"]}),
+ "failure_rate": sum(r["failure_rate"] * w for r, w in zip(group, weights))
+ / total_weight,
+ "flake_rate": sum(r["flake_rate"] * w for r, w in zip(group, weights))
+ / total_weight,
+ "updated_at": max(r["updated_at"] for r in group),
+ "avg_duration": sum(r["avg_duration"] * w for r, w in zip(group, weights))
+ / total_weight,
+ "total_fail_count": sum(r["total_fail_count"] for r in group),
+ "total_flaky_fail_count": sum(r["total_flaky_fail_count"] for r in group),
+ "total_pass_count": sum(r["total_pass_count"] for r in group),
+ "total_skip_count": sum(r["total_skip_count"] for r in group),
+ "commits_where_fail": sum(r["commits_where_fail"] for r in group),
+ "last_duration": max(r["last_duration"] for r in group),
+ }
+ result.append(merged)
+
+ return sorted(result, key=lambda x: x["updated_at"], reverse=True)
+
+
+def row_to_camel_case(row: dict) -> dict:
+ return {
+ "commitsFailed"
+ if key == "commits_where_fail"
+ else "".join(
+ part.capitalize() if i > 0 else part.lower()
+ for i, part in enumerate(key.split("_"))
+ ): value.isoformat() if key == "updated_at" else value
+ for key, value in row.items()
+ if key not in ("testsuite", "flags")
+ }
+
+
+test_results_table = pl.DataFrame(rows)
+test_results_table_with_duplicate_names = pl.DataFrame(rows_with_duplicate_names)
+
+
+def base64_encode_string(x: str) -> str:
+ return b64encode(x.encode()).decode("utf-8")
+
+
+def cursor(row: dict) -> str:
+ return encode_cursor(TestResultsRow(**row), TestResultsOrderingParameter.UPDATED_AT)
+
+
+@pytest.fixture(autouse=True)
+def repository(mocker, transactional_db):
+ owner = OwnerFactory(username="codecov-user")
+ repo = RepositoryFactory(author=owner, name="testRepoName", active=True)
+
+ return repo
+
+
+@pytest.fixture
+def store_in_redis(repository):
+ redis = get_redis_connection()
+ redis.set(
+ f"test_results:{repository.repoid}:{repository.branch}:30",
+ test_results_table.write_ipc(None).getvalue(),
+ )
+
+ yield
+
+ redis.delete(
+ f"test_results:{repository.repoid}:{repository.branch}:30",
+ )
+
+
+@pytest.fixture
+def store_in_storage(repository, mock_storage):
+ from django.conf import settings
+
+ try:
+ mock_storage.create_root_storage(settings.GCS_BUCKET_NAME)
+ except BucketAlreadyExistsError:
+ pass
+
+ mock_storage.write_file(
+ settings.GCS_BUCKET_NAME,
+ f"test_results/rollups/{repository.repoid}/{repository.branch}/30",
+ test_results_table.write_ipc(None).getvalue(),
+ )
+
+ yield
+
+ mock_storage.delete_file(
+ settings.GCS_BUCKET_NAME,
+ f"test_results/rollups/{repository.repoid}/{repository.branch}/30",
+ )
+
+
+@pytest.fixture
+def store_in_redis_with_duplicate_names(repository):
+ redis = get_redis_connection()
+ redis.set(
+ f"test_results:{repository.repoid}:{repository.branch}:30",
+ test_results_table_with_duplicate_names.write_ipc(None).getvalue(),
+ )
+
+ yield
+
+ redis.delete(
+ f"test_results:{repository.repoid}:{repository.branch}:30",
+ )
+
+
+class TestAnalyticsTestCase(
+ GraphQLTestHelper,
+):
+ def test_get_test_results(
+ self,
+ transactional_db,
+ repository,
+ store_in_redis,
+ store_in_storage,
+ mock_storage,
+ ):
+ results = get_results(repository.repoid, repository.branch, 30)
+ assert results is not None
+
+ assert results.equals(dedup_table(test_results_table))
+
+ def test_get_test_results_no_storage(
+ self, transactional_db, repository, mock_storage
+ ):
+ assert get_results(repository.repoid, repository.branch, 30) is None
+
+ def test_get_test_results_no_redis(
+ self, mocker, transactional_db, repository, store_in_storage, mock_storage
+ ):
+ m = mocker.patch("services.task.TaskService.cache_test_results_redis")
+ results = get_results(repository.repoid, repository.branch, 30)
+ assert results is not None
+ assert results.equals(dedup_table(test_results_table))
+
+ m.assert_called_once_with(repository.repoid, repository.branch)
+
+ def test_test_results(
+ self, transactional_db, repository, store_in_redis, mock_storage, snapshot
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.DESC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ )
+ assert test_results is not None
+ assert test_results.total_count == 5
+ assert test_results.page_info == {
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": cursor(rows[4]),
+ "end_cursor": cursor(rows[0]),
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ def test_test_results_asc(
+ self, transactional_db, repository, store_in_redis, mock_storage, snapshot
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.ASC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ )
+ assert test_results is not None
+ assert test_results.total_count == 5
+ assert test_results.page_info == {
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": cursor(rows[0]),
+ "end_cursor": cursor(rows[4]),
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ @pytest.mark.parametrize(
+ "first, after, last, before, has_next_page, has_previous_page, start_cursor, end_cursor, expected_rows",
+ [
+ pytest.param(
+ 1,
+ None,
+ None,
+ None,
+ True,
+ False,
+ cursor(rows[4]),
+ cursor(rows[4]),
+ [rows[4]],
+ id="first_1",
+ ),
+ pytest.param(
+ 1,
+ cursor(rows[4]),
+ None,
+ None,
+ True,
+ False,
+ cursor(rows[3]),
+ cursor(rows[3]),
+ [rows[3]],
+ id="first_1_after",
+ ),
+ pytest.param(
+ 1,
+ cursor(rows[1]),
+ None,
+ None,
+ False,
+ False,
+ cursor(rows[0]),
+ cursor(rows[0]),
+ [rows[0]],
+ id="first_1_after_no_next",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ None,
+ False,
+ True,
+ cursor(rows[0]),
+ cursor(rows[0]),
+ [rows[0]],
+ id="last_1",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ cursor(rows[0]),
+ False,
+ True,
+ cursor(rows[1]),
+ cursor(rows[1]),
+ [rows[1]],
+ id="last_1_before",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ cursor(rows[3]),
+ False,
+ False,
+ cursor(rows[4]),
+ cursor(rows[4]),
+ [rows[4]],
+ id="last_1_before_no_previous",
+ ),
+ ],
+ )
+ def test_test_results_pagination(
+ self,
+ first,
+ after,
+ before,
+ last,
+ has_next_page,
+ has_previous_page,
+ expected_rows,
+ start_cursor,
+ end_cursor,
+ repository,
+ store_in_redis,
+ mock_storage,
+ snapshot,
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.DESC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ first=first,
+ after=after,
+ before=before,
+ last=last,
+ )
+ assert test_results.total_count == 5
+ assert test_results.page_info == {
+ "has_next_page": has_next_page,
+ "has_previous_page": has_previous_page,
+ "start_cursor": start_cursor,
+ "end_cursor": end_cursor,
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ @pytest.mark.parametrize(
+ "first, after, last, before, has_next_page, has_previous_page, start_cursor, end_cursor, expected_rows",
+ [
+ pytest.param(
+ 1,
+ None,
+ None,
+ None,
+ True,
+ False,
+ cursor(rows[0]),
+ cursor(rows[0]),
+ [rows[0]],
+ id="first_1",
+ ),
+ pytest.param(
+ 1,
+ cursor(rows[0]),
+ None,
+ None,
+ True,
+ False,
+ cursor(rows[1]),
+ cursor(rows[1]),
+ [rows[1]],
+ id="first_1_after",
+ ),
+ pytest.param(
+ 1,
+ cursor(rows[3]),
+ None,
+ None,
+ False,
+ False,
+ cursor(rows[4]),
+ cursor(rows[4]),
+ [rows[4]],
+ id="first_1_after_no_next",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ None,
+ False,
+ True,
+ cursor(rows[4]),
+ cursor(rows[4]),
+ [rows[4]],
+ id="last_1",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ cursor(rows[4]),
+ False,
+ True,
+ cursor(rows[3]),
+ cursor(rows[3]),
+ [rows[3]],
+ id="last_1_before",
+ ),
+ pytest.param(
+ None,
+ None,
+ 1,
+ cursor(rows[1]),
+ False,
+ False,
+ cursor(rows[0]),
+ cursor(rows[0]),
+ [rows[0]],
+ id="last_1_before_no_previous",
+ ),
+ ],
+ )
+ def test_test_results_pagination_asc(
+ self,
+ first,
+ after,
+ before,
+ last,
+ has_next_page,
+ has_previous_page,
+ expected_rows,
+ start_cursor,
+ end_cursor,
+ repository,
+ store_in_redis,
+ mock_storage,
+ snapshot,
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.ASC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ first=first,
+ after=after,
+ before=before,
+ last=last,
+ )
+ assert test_results.total_count == 5
+ assert test_results.page_info == {
+ "has_next_page": has_next_page,
+ "has_previous_page": has_previous_page,
+ "start_cursor": start_cursor,
+ "end_cursor": end_cursor,
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ def test_test_analytics_term_filter(
+ self, repository, store_in_redis, mock_storage, snapshot
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ term=rows[0]["name"][2:],
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.DESC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ )
+ assert test_results is not None
+ assert test_results.total_count == 1
+ assert test_results.page_info == {
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": cursor(rows[0]),
+ "end_cursor": cursor(rows[0]),
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ def test_test_analytics_testsuite_filter(
+ self, repository, store_in_redis, snapshot
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ testsuites=[rows[0]["testsuite"]],
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.DESC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ )
+ assert test_results is not None
+ assert test_results.total_count == 1
+ assert test_results.page_info == {
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": cursor(rows[0]),
+ "end_cursor": cursor(rows[0]),
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ def test_test_analytics_flag_filter(
+ self, repository, store_in_redis, mock_storage, snapshot
+ ):
+ test_results = generate_test_results(
+ repoid=repository.repoid,
+ flags=[rows[0]["flags"][0]],
+ ordering=TestResultsOrderingParameter.UPDATED_AT,
+ ordering_direction=OrderingDirection.DESC,
+ measurement_interval=MeasurementInterval.INTERVAL_30_DAY,
+ )
+ assert test_results is not None
+ # rows = dedup(rows)
+ assert test_results.total_count == 1
+ assert test_results.page_info == {
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": cursor(rows[0]),
+ "end_cursor": cursor(rows[0]),
+ }
+ assert snapshot("json") == [
+ row["node"].to_dict()
+ for row in test_results.edges
+ if isinstance(row["node"], TestResultsRow)
+ ]
+
+ def test_gql_query(self, repository, store_in_redis, mock_storage):
+ query = base_gql_query % (
+ repository.author.username,
+ repository.name,
+ """
+ testResults(ordering: { parameter: UPDATED_AT, direction: DESC } ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ name
+ failureRate
+ flakeRate
+ updatedAt
+ avgDuration
+ totalFailCount
+ totalFlakyFailCount
+ totalPassCount
+ totalSkipCount
+ commitsFailed
+ lastDuration
+ }
+ }
+ }
+ """,
+ )
+
+ result = self.gql_request(query, owner=repository.author)
+
+ assert (
+ result["owner"]["repository"]["testAnalytics"]["testResults"]["totalCount"]
+ == 5
+ )
+ assert result["owner"]["repository"]["testAnalytics"]["testResults"][
+ "edges"
+ ] == [
+ {
+ "cursor": cursor(row),
+ "node": row_to_camel_case(row),
+ }
+ for row in dedup(rows)
+ ]
+
+ def test_gql_query_with_duplicate_names(
+ self, repository, store_in_redis_with_duplicate_names, mock_storage
+ ):
+ query = base_gql_query % (
+ repository.author.username,
+ repository.name,
+ """
+ testResults(ordering: { parameter: UPDATED_AT, direction: DESC } ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ name
+ failureRate
+ flakeRate
+ updatedAt
+ avgDuration
+ totalFailCount
+ totalFlakyFailCount
+ totalPassCount
+ totalSkipCount
+ commitsFailed
+ lastDuration
+ }
+ }
+ }
+ """,
+ )
+
+ result = self.gql_request(query, owner=repository.author)
+
+ assert (
+ result["owner"]["repository"]["testAnalytics"]["testResults"]["totalCount"]
+ == 3
+ )
+ assert result["owner"]["repository"]["testAnalytics"]["testResults"][
+ "edges"
+ ] == [
+ {
+ "cursor": cursor(row),
+ "node": row_to_camel_case(row),
+ }
+ for row in dedup(rows_with_duplicate_names)
+ ]
+
+ def test_gql_query_aggregates(self, repository, store_in_redis, mock_storage):
+ query = base_gql_query % (
+ repository.author.username,
+ repository.name,
+ """
+ testResultsAggregates {
+ totalDuration
+ slowestTestsDuration
+ totalFails
+ totalSkips
+ totalSlowTests
+ }
+ """,
+ )
+
+ result = self.gql_request(query, owner=repository.author)
+
+ assert result["owner"]["repository"]["testAnalytics"][
+ "testResultsAggregates"
+ ] == {
+ "totalDuration": 1000.0,
+ "slowestTestsDuration": 200.0,
+ "totalFails": 5,
+ "totalSkips": 5,
+ "totalSlowTests": 1,
+ }
+
+ def test_gql_query_flake_aggregates(self, repository, store_in_redis, mock_storage):
+ query = base_gql_query % (
+ repository.author.username,
+ repository.name,
+ """
+ flakeAggregates {
+ flakeRate
+ flakeCount
+ }
+ """,
+ )
+
+ result = self.gql_request(query, owner=repository.author)
+
+ assert result["owner"]["repository"]["testAnalytics"]["flakeAggregates"] == {
+ "flakeRate": 0.1,
+ "flakeCount": 1,
+ }
diff --git a/apps/codecov-api/graphql_api/tests/test_user.py b/apps/codecov-api/graphql_api/tests/test_user.py
new file mode 100644
index 0000000000..aad72fc92a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_user.py
@@ -0,0 +1,88 @@
+from datetime import timedelta
+
+from django.test import TestCase
+from django.utils import timezone
+from freezegun import freeze_time
+from prometheus_client import REGISTRY
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from graphql_api.types.user.user import resolve_customer_intent
+
+from ..views import GQL_ERROR_COUNTER, GQL_HIT_COUNTER, GQL_REQUEST_LATENCIES
+from .helper import GraphQLTestHelper
+
+
+@freeze_time("2023-06-19")
+class UserTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.service_id = 1
+ self.user = OwnerFactory(
+ username="codecov-user",
+ name="codecov-name",
+ service="github",
+ service_id=self.service_id,
+ student=True,
+ student_created_at=timezone.now(),
+ student_updated_at=timezone.now() + timedelta(days=1),
+ )
+
+ def test_query_user_resolver(self):
+ GQL_HIT_COUNTER.labels(operation_type="unknown_type", operation_name="me")
+ GQL_ERROR_COUNTER.labels(operation_type="unknown_type", operation_name="me")
+ GQL_REQUEST_LATENCIES.labels(operation_type="unknown_type", operation_name="me")
+ before = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ errors_before = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ timer_before = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ query = """{
+ me {
+ user {
+ username
+ name
+ avatarUrl
+ student
+ studentCreatedAt
+ studentUpdatedAt
+ customerIntent
+ }
+ }
+ }
+ """
+ data = self.gql_request(query, owner=self.user)
+ assert data["me"]["user"] == {
+ "username": "codecov-user",
+ "name": "codecov-name",
+ "avatarUrl": f"https://avatars0.githubusercontent.com/u/{self.service_id}?v=3&s=55",
+ "student": True,
+ "studentCreatedAt": "2023-06-19T00:00:00",
+ "studentUpdatedAt": "2023-06-20T00:00:00",
+ "customerIntent": "Business",
+ }
+ after = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ errors_after = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ timer_after = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "me"},
+ )
+ assert after - before == 1
+ assert errors_after - errors_before == 0
+ assert timer_after - timer_before == 1
+
+ def test_query_null_user_customer_intent_resolver(self):
+ null_user = OwnerFactory(user=None, service_id=4)
+ data = resolve_customer_intent(null_user, None)
+ assert data is None
diff --git a/apps/codecov-api/graphql_api/tests/test_user_tokens.py b/apps/codecov-api/graphql_api/tests/test_user_tokens.py
new file mode 100644
index 0000000000..ee475d1ef7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_user_tokens.py
@@ -0,0 +1,54 @@
+from django.test import TestCase
+from shared.django_apps.codecov_auth.tests.factories import (
+ OwnerFactory,
+ UserTokenFactory,
+)
+
+from .helper import GraphQLTestHelper, paginate_connection
+
+query = """
+query {
+ me {
+ tokens {
+ edges {
+ node {
+ id
+ name
+ type
+ lastFour
+ expiration
+ }
+ }
+ }
+ }
+}
+"""
+
+
+class UserTokensTestCase(GraphQLTestHelper, TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory(username="codecov-user")
+
+ self.token1 = UserTokenFactory(owner=self.owner, name="token1")
+ self.token2 = UserTokenFactory(owner=self.owner, name="token2")
+ self.token3 = UserTokenFactory(name="token3")
+
+ def test_user_tokens(self):
+ data = self.gql_request(query, owner=self.owner)
+ tokens = paginate_connection(data["me"]["tokens"])
+ assert tokens == [
+ {
+ "id": str(self.token2.external_id),
+ "name": "token2",
+ "type": "api",
+ "lastFour": str(self.token2.token)[-4:],
+ "expiration": None,
+ },
+ {
+ "id": str(self.token1.external_id),
+ "name": "token1",
+ "type": "api",
+ "lastFour": str(self.token1.token)[-4:],
+ "expiration": None,
+ },
+ ]
diff --git a/apps/codecov-api/graphql_api/tests/test_validation.py b/apps/codecov-api/graphql_api/tests/test_validation.py
new file mode 100644
index 0000000000..6c7f7ca118
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_validation.py
@@ -0,0 +1,100 @@
+from graphql import (
+ GraphQLField,
+ GraphQLObjectType,
+ GraphQLSchema,
+ GraphQLString,
+ parse,
+ validate,
+)
+
+from ..validation import (
+ create_max_aliases_rule,
+ create_max_depth_rule,
+)
+
+
+def resolve_field(*args):
+ return "test"
+
+
+QueryType = GraphQLObjectType(
+ "Query", {"field": GraphQLField(GraphQLString, resolve=resolve_field)}
+)
+schema = GraphQLSchema(query=QueryType)
+
+
+def validate_query(query, *rules):
+ ast = parse(query)
+ return validate(schema, ast, rules=rules)
+
+
+def test_max_depth_rule_allows_within_depth():
+ query = """
+ query {
+ field
+ }
+ """
+ errors = validate_query(query, create_max_depth_rule(2))
+ assert not errors, "Expected no errors for depth within the limit"
+
+
+def test_max_depth_rule_rejects_exceeding_depth():
+ query = """
+ query {
+ field {
+ field {
+ field
+ }
+ }
+ }
+ """
+ errors = validate_query(query, create_max_depth_rule(2))
+ assert errors, "Expected errors for exceeding depth limit"
+ assert any(
+ "Query depth exceeds the maximum allowed depth" in str(e) for e in errors
+ )
+
+
+def test_max_depth_rule_exact_depth():
+ query = """
+ query {
+ field
+ }
+ """
+ errors = validate_query(query, create_max_depth_rule(2))
+ assert not errors, "Expected no errors when query depth matches the limit"
+
+
+def test_max_aliases_rule_allows_within_alias_limit():
+ query = """
+ query {
+ alias1: field
+ alias2: field
+ }
+ """
+ errors = validate_query(query, create_max_aliases_rule(2))
+ assert not errors, "Expected no errors for alias count within the limit"
+
+
+def test_max_aliases_rule_rejects_exceeding_alias_limit():
+ query = """
+ query {
+ alias1: field
+ alias2: field
+ alias3: field
+ }
+ """
+ errors = validate_query(query, create_max_aliases_rule(2))
+ assert errors, "Expected errors for exceeding alias limit"
+ assert any("Query uses too many aliases" in str(e) for e in errors)
+
+
+def test_max_aliases_rule_exact_alias_limit():
+ query = """
+ query {
+ alias1: field
+ alias2: field
+ }
+ """
+ errors = validate_query(query, create_max_aliases_rule(2))
+ assert not errors, "Expected no errors when alias count matches the limit"
diff --git a/apps/codecov-api/graphql_api/tests/test_views.py b/apps/codecov-api/graphql_api/tests/test_views.py
new file mode 100644
index 0000000000..134f9ab86b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/tests/test_views.py
@@ -0,0 +1,331 @@
+import json
+from unittest.mock import Mock, patch
+
+from ariadne import ObjectType, gql, make_executable_schema
+from ariadne.validation import cost_directive
+from django.test import RequestFactory, TestCase, override_settings
+from django.urls import ResolverMatch
+from prometheus_client import REGISTRY
+
+from codecov.commands.exceptions import Unauthorized
+
+from ..views import AsyncGraphqlView, QueryMetricsExtension
+from .helper import GraphQLTestHelper
+
+
+def generate_schema_that_raise_with(exception):
+ types = """
+ type Query {
+ failing: Boolean
+ }
+ """
+ query_bindable = ObjectType("Query")
+
+ @query_bindable.field("failing")
+ def failing_bindable(*_):
+ raise exception
+
+ return make_executable_schema(types, query_bindable)
+
+
+def generate_cost_test_schema():
+ types = """
+ type Query {
+ stuff: String @cost(complexity: 2000)
+ }
+ """
+ query_bindable = ObjectType("Query")
+
+ return make_executable_schema([types, cost_directive], query_bindable)
+
+
+def generate_schema_with_required_variables():
+ types = gql(
+ """
+ type Query {
+ person_exists(name: String!): Boolean
+ stuff: String
+ }
+ """
+ )
+ query_bindable = ObjectType("Query")
+
+ # Add a resolver for the `person_exists` field
+ @query_bindable.field("person_exists")
+ def resolve_person_exists(_, info, name):
+ return name is not None # Example resolver logic
+
+ return make_executable_schema(types, query_bindable)
+
+
+class AriadneViewTestCase(GraphQLTestHelper, TestCase):
+ async def do_query(self, schema, query="{ failing }", variables=None):
+ view = AsyncGraphqlView.as_view(schema=schema)
+ data = {"query": query}
+ if variables is not None:
+ data["variables"] = variables
+
+ request = RequestFactory().post(
+ "/graphql/gh", data, content_type="application/json"
+ )
+ match = ResolverMatch(func=lambda: None, args=(), kwargs={"service": "github"})
+
+ request.resolver_match = match
+ request.user = None
+ request.current_owner = None
+ res = await view(request, service="gh")
+ return json.loads(res.content)
+
+ @override_settings(DEBUG=True)
+ @patch("logging.Logger.info")
+ async def test_when_debug_is_true(self, patched_log):
+ before = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ errors_before = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ timer_before = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ schema = generate_schema_that_raise_with(Exception("hello"))
+ data = await self.do_query(schema)
+ assert data["errors"] is not None
+ assert data["errors"][0]["message"] == "hello"
+ assert data["errors"][0]["extensions"] is not None
+ after = REGISTRY.get_sample_value(
+ "api_gql_counts_hits_total",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ errors_after = REGISTRY.get_sample_value(
+ "api_gql_counts_errors_total",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ timer_after = REGISTRY.get_sample_value(
+ "api_gql_timers_full_runtime_seconds_count",
+ labels={"operation_type": "unknown_type", "operation_name": "unknown_name"},
+ )
+ assert after - before == 1
+ assert errors_after - errors_before == 1
+ assert timer_after - timer_before == 1
+ patched_log.assert_called_with(
+ "Could not match gql query format for logging",
+ extra=dict(
+ query_slice="{ failing }",
+ ),
+ )
+
+ @override_settings(DEBUG=False)
+ async def test_when_debug_is_false_and_random_exception(self):
+ schema = generate_schema_that_raise_with(Exception("hello"))
+ data = await self.do_query(schema)
+ assert data["errors"] is not None
+ assert data["errors"][0]["message"] == "INTERNAL SERVER ERROR"
+ assert data["errors"][0]["type"] == "ServerError"
+ assert data["errors"][0].get("extensions") is None
+
+ @override_settings(DEBUG=False)
+ async def test_when_debug_is_false_and_exception_we_know(self):
+ schema = generate_schema_that_raise_with(Unauthorized())
+ data = await self.do_query(schema)
+ assert data["errors"] is not None
+ assert data["errors"][0]["message"] == "You are not authorized"
+ assert data["errors"][0]["type"] == "Unauthorized"
+ assert data["errors"][0].get("extensions") is None
+
+ @override_settings(DEBUG=True)
+ async def test_when_bad_query(self):
+ schema = generate_schema_that_raise_with(Unauthorized())
+ data = await self.do_query(schema, " { fieldThatDoesntExist }")
+ assert data["errors"] is not None
+ assert (
+ data["errors"][0]["message"]
+ == "Cannot query field 'fieldThatDoesntExist' on type 'Query'."
+ )
+
+ @override_settings(DEBUG=False)
+ async def test_when_bad_query_and_anonymous(self):
+ schema = generate_schema_that_raise_with(Unauthorized())
+ data = await self.do_query(schema, " { fieldThatDoesntExist }")
+ assert data["errors"] is not None
+ assert data["errors"][0]["message"] == "INTERNAL SERVER ERROR"
+
+ @override_settings(DEBUG=False, GRAPHQL_QUERY_COST_THRESHOLD=1000)
+ @patch("logging.Logger.error")
+ async def test_when_costly_query(self, mock_error_logger):
+ schema = generate_cost_test_schema()
+ data = await self.do_query(schema, " { stuff }")
+
+ assert data["errors"] is not None
+ assert data["errors"][0]["extensions"]["cost"]["requestedQueryCost"] == 2000
+ assert data["errors"][0]["extensions"]["cost"]["maximumAvailable"] == 1000
+ mock_error_logger.assert_called_with(
+ "Query Cost Exceeded",
+ extra=dict(
+ requested_cost=2000,
+ maximum_cost=1000,
+ request_body=dict(query="{ stuff }"),
+ ),
+ )
+
+ @patch("logging.Logger.info")
+ async def test_query_metrics_extension_set_type_and_name(self, patched_log):
+ extension = QueryMetricsExtension()
+ sample_named_query = "query MySession { operation body }"
+ sample_named_mutation = "mutation($input: CancelTrialInput!) { operation body }"
+ sample_unnamed_query = "{ owner(username: me) { continued operation body } }"
+ sample_wildcard = "{ failing }"
+
+ assert extension.operation_type is None
+ assert extension.operation_name is None
+
+ extension.set_type_and_name(query=sample_named_query)
+ assert extension.operation_type == "query"
+ assert extension.operation_name == "MySession"
+
+ extension.set_type_and_name(query=sample_named_mutation)
+ assert extension.operation_type == "mutation"
+ assert extension.operation_name == "CancelTrialInput"
+
+ extension.set_type_and_name(query=sample_unnamed_query)
+ assert extension.operation_type == "unknown_type"
+ assert extension.operation_name == "owner"
+
+ extension.set_type_and_name(query=sample_wildcard)
+ assert extension.operation_type == "unknown_type"
+ assert extension.operation_name == "unknown_name"
+ patched_log.assert_called_with(
+ "Could not match gql query format for logging",
+ extra=dict(
+ query_slice="{ failing }",
+ ),
+ )
+
+ @patch("regex.match")
+ @patch("logging.Logger.error")
+ @patch("logging.Logger.info")
+ async def test_query_metrics_extension_set_type_and_name_timeout(
+ self, patched_info_log, patched_error_log, patched_regex
+ ):
+ patched_regex.side_effect = TimeoutError
+ extension = QueryMetricsExtension()
+ sample_named_query = "query MySession { operation body }"
+
+ extension.set_type_and_name(query=sample_named_query)
+
+ patched_info_log.assert_called_with(
+ "Could not match gql query format for logging",
+ extra=dict(
+ query_slice=sample_named_query[:30],
+ ),
+ )
+ patched_error_log.assert_called_with(
+ "Regex Timeout Error",
+ extra=dict(
+ query_slice=sample_named_query[:30],
+ ),
+ )
+ assert extension.operation_type == "unknown_type"
+ assert extension.operation_name == "unknown_name"
+
+ @patch("graphql_api.views.GQL_REQUEST_MADE_COUNTER.labels")
+ @patch("graphql_api.views.GQL_ERROR_TYPE_COUNTER.labels")
+ @patch("graphql_api.views.AsyncGraphqlView._check_ratelimit")
+ @override_settings(DEBUG=False, GRAPHQL_RATE_LIMIT_RPM=1000)
+ async def test_when_rate_limit_reached(
+ self, mocked_check_ratelimit, mocked_error_counter, mocked_request_counter
+ ):
+ schema = generate_cost_test_schema()
+ mocked_check_ratelimit.return_value = True
+ response = await self.do_query(schema, " { stuff }")
+
+ assert response["status"] == 429
+ assert (
+ response["detail"]
+ == "It looks like you've hit the rate limit of 1000 req/min. Try again later."
+ )
+
+ mocked_error_counter.assert_called_with(
+ error_type="rate_limit", path="/graphql/gh"
+ )
+ mocked_request_counter.assert_called_with(path="/graphql/gh")
+
+ @override_settings(
+ DEBUG=False, GRAPHQL_RATE_LIMIT_RPM=0, GRAPHQL_RATE_LIMIT_ENABLED=False
+ )
+ def test_rate_limit_disabled(self):
+ # rate limit is 0, so any request would cause a rate limit if the GQL rate limit was enabled
+ view = AsyncGraphqlView()
+ request = Mock()
+
+ result = view._check_ratelimit(request)
+ assert result == False
+
+ def test_client_ip_from_x_forwarded_for(self):
+ view = AsyncGraphqlView()
+ request = Mock()
+ request.META = {"HTTP_X_FORWARDED_FOR": "127.0.0.1,blah", "REMOTE_ADDR": "lol"}
+
+ result = view.get_client_ip(request)
+ assert result == "127.0.0.1"
+
+ def test_client_ip_from_remote_addr(self):
+ view = AsyncGraphqlView()
+ request = Mock()
+ request.META = {"HTTP_X_FORWARDED_FOR": None, "REMOTE_ADDR": "lol"}
+
+ result = view.get_client_ip(request)
+ assert result == "lol"
+
+ async def test_required_variable_present(self):
+ schema = generate_schema_with_required_variables()
+
+ query = """
+ query ($name: String!) {
+ person_exists(name: $name)
+ }
+ """
+
+ # Provide the variable
+ data = await self.do_query(schema, query=query, variables={"name": "Bob"})
+
+ assert data is not None
+ assert "data" in data
+ assert data["data"]["person_exists"] is True
+
+ async def test_required_variable_missing(self):
+ schema = generate_schema_with_required_variables()
+
+ query = """
+ query ($name: String!) {
+ person_exists(name: $name)
+ }
+ """
+
+ # Don't provide the variable
+ data = await self.do_query(schema, query=query, variables={})
+
+ assert data == {"detail": "Missing required variables: name", "status": 400}
+
+ async def test_empty_request_body(self):
+ schema = generate_schema_with_required_variables()
+
+ request = RequestFactory().post(
+ "/graphql/gh", "", content_type="application/json"
+ )
+ match = ResolverMatch(func=lambda: None, args=(), kwargs={"service": "github"})
+ request.resolver_match = match
+ request.user = None
+ request.current_owner = None
+
+ view = AsyncGraphqlView.as_view(schema=schema)
+ response = await view(request, service="gh")
+
+ assert response.status_code == 400
+ assert json.loads(response.content) == {
+ "status": 400,
+ "detail": "Invalid JSON response received.",
+ }
diff --git a/apps/codecov-api/graphql_api/types/__init__.py b/apps/codecov-api/graphql_api/types/__init__.py
new file mode 100644
index 0000000000..41794229d7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/__init__.py
@@ -0,0 +1,203 @@
+from ariadne.validation import cost_directive
+from ariadne_django.scalars import datetime_scalar
+
+from ..helpers.ariadne import ariadne_load_local_graphql
+from .account import account, account_bindable
+from .billing import billing, billing_bindable
+from .branch import branch, branch_bindable
+from .bundle_analysis import (
+ bundle_analysis,
+ bundle_analysis_comparison,
+ bundle_analysis_comparison_bindable,
+ bundle_analysis_comparison_result_bindable,
+ bundle_analysis_report,
+ bundle_analysis_report_bindable,
+ bundle_analysis_report_result_bindable,
+ bundle_asset_bindable,
+ bundle_comparison_bindable,
+ bundle_data_bindable,
+ bundle_module_bindable,
+ bundle_report_bindable,
+ bundle_report_info_bindable,
+)
+from .commit import (
+ commit,
+ commit_bindable,
+ commit_bundle_analysis_bindable,
+ commit_coverage_analytics_bindable,
+)
+from .comparison import comparison, comparison_bindable, comparison_result_bindable
+from .component import component, component_bindable
+from .component_comparison import component_comparison, component_comparison_bindable
+from .config import config, config_bindable
+from .coverage_analytics import coverage_analytics, coverage_analytics_bindable
+from .coverage_totals import coverage_totals, coverage_totals_bindable
+from .enums import enum_types
+from .file import commit_file, file_bindable
+from .flag import flag, flag_bindable
+from .flag_comparison import flag_comparison, flag_comparison_bindable
+from .flake_aggregates import flake_aggregates, flake_aggregates_bindable
+from .impacted_file import (
+ impacted_file,
+ impacted_file_bindable,
+ impacted_files_result_bindable,
+)
+from .invoice import invoice, invoice_bindable
+from .line_comparison import line_comparison, line_comparison_bindable
+from .me import me, me_bindable, tracking_metadata_bindable
+from .measurement import measurement, measurement_bindable
+from .mutation import mutation, mutation_resolvers
+from .okta_config import okta_config, okta_config_bindable
+from .owner import owner, owner_bindable
+from .path_contents import (
+ deprecated_path_contents_result_bindable,
+ path_content,
+ path_content_bindable,
+ path_content_file_bindable,
+ path_contents_result_bindable,
+)
+from .plan import plan, plan_bindable
+from .plan_representation import plan_representation, plan_representation_bindable
+from .profile import profile, profile_bindable
+from .pull import pull, pull_bindable
+from .query import query, query_bindable
+from .repository import repository, repository_bindable, repository_result_bindable
+from .repository_config import (
+ indication_range_bindable,
+ repository_config,
+ repository_config_bindable,
+)
+from .segment_comparison import (
+ segment_comparison,
+ segment_comparison_bindable,
+ segments_result_bindable,
+)
+from .self_hosted_license import self_hosted_license, self_hosted_license_bindable
+from .session import session, session_bindable
+from .test_analytics import test_analytics, test_analytics_bindable
+from .test_results import test_result_bindable, test_results
+from .test_results_aggregates import (
+ test_results_aggregates,
+ test_results_aggregates_bindable,
+)
+from .upload import upload, upload_bindable, upload_error_bindable
+from .user import user, user_bindable
+from .user_token import user_token, user_token_bindable
+
+inputs = ariadne_load_local_graphql(__file__, "./inputs")
+enums = ariadne_load_local_graphql(__file__, "./enums")
+errors = ariadne_load_local_graphql(__file__, "./errors")
+types = [
+ billing,
+ branch,
+ bundle_analysis_comparison,
+ bundle_analysis_report,
+ bundle_analysis,
+ commit_file,
+ commit,
+ comparison,
+ component_comparison,
+ component,
+ config,
+ cost_directive,
+ coverage_analytics,
+ coverage_totals,
+ enums,
+ errors,
+ flag_comparison,
+ flag,
+ impacted_file,
+ inputs,
+ invoice,
+ line_comparison,
+ me,
+ measurement,
+ mutation,
+ owner,
+ path_content,
+ plan_representation,
+ plan,
+ profile,
+ pull,
+ query,
+ repository_config,
+ repository,
+ segment_comparison,
+ self_hosted_license,
+ session,
+ test_analytics,
+ upload,
+ user_token,
+ user,
+ account,
+ okta_config,
+ test_results,
+ flake_aggregates,
+ test_results_aggregates,
+]
+
+bindables = [
+ *enum_types.enum_types,
+ *mutation_resolvers,
+ billing_bindable,
+ branch_bindable,
+ bundle_analysis_comparison_bindable,
+ bundle_analysis_comparison_result_bindable,
+ bundle_analysis_report_bindable,
+ bundle_analysis_report_result_bindable,
+ bundle_asset_bindable,
+ bundle_comparison_bindable,
+ bundle_data_bindable,
+ bundle_module_bindable,
+ bundle_report_bindable,
+ bundle_report_info_bindable,
+ commit_bindable,
+ commit_bundle_analysis_bindable,
+ commit_coverage_analytics_bindable,
+ comparison_bindable,
+ comparison_result_bindable,
+ component_bindable,
+ component_comparison_bindable,
+ config_bindable,
+ coverage_analytics_bindable,
+ coverage_totals_bindable,
+ datetime_scalar,
+ file_bindable,
+ flag_bindable,
+ flag_comparison_bindable,
+ impacted_file_bindable,
+ impacted_files_result_bindable,
+ indication_range_bindable,
+ invoice_bindable,
+ line_comparison_bindable,
+ me_bindable,
+ measurement_bindable,
+ owner_bindable,
+ path_content_bindable,
+ path_content_file_bindable,
+ path_contents_result_bindable,
+ deprecated_path_contents_result_bindable,
+ plan_bindable,
+ plan_representation_bindable,
+ profile_bindable,
+ pull_bindable,
+ query_bindable,
+ repository_bindable,
+ repository_config_bindable,
+ repository_result_bindable,
+ segment_comparison_bindable,
+ segments_result_bindable,
+ self_hosted_license_bindable,
+ session_bindable,
+ test_analytics_bindable,
+ tracking_metadata_bindable,
+ upload_bindable,
+ upload_error_bindable,
+ user_bindable,
+ user_token_bindable,
+ account_bindable,
+ okta_config_bindable,
+ test_result_bindable,
+ test_results_aggregates_bindable,
+ flake_aggregates_bindable,
+]
diff --git a/apps/codecov-api/graphql_api/types/account/__init__.py b/apps/codecov-api/graphql_api/types/account/__init__.py
new file mode 100644
index 0000000000..fd7541cd3c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/account/__init__.py
@@ -0,0 +1,3 @@
+from .account import account, account_bindable
+
+__all__ = ["account_bindable", "account"]
diff --git a/apps/codecov-api/graphql_api/types/account/account.graphql b/apps/codecov-api/graphql_api/types/account/account.graphql
new file mode 100644
index 0000000000..3af381ea42
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/account/account.graphql
@@ -0,0 +1,13 @@
+type Account {
+ name: String!
+ oktaConfig: OktaConfig
+ totalSeatCount: Int!
+ activatedUserCount: Int!
+ organizations(
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): AccountOrganizationConnection! @cost(complexity: 25, multipliers: ["first", "last"])
+}
diff --git a/apps/codecov-api/graphql_api/types/account/account.py b/apps/codecov-api/graphql_api/types/account/account.py
new file mode 100644
index 0000000000..8bcfc17225
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/account/account.py
@@ -0,0 +1,56 @@
+from typing import Any, Coroutine, Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+
+from codecov_auth.models import Account, OktaSettings, Owner
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+from graphql_api.helpers.connection import (
+ build_connection_graphql,
+ queryset_to_connection,
+)
+from graphql_api.types.enums.enums import OrderingDirection
+
+account = ariadne_load_local_graphql(__file__, "account.graphql")
+account = account + build_connection_graphql("AccountOrganizationConnection", "Owner")
+account_bindable = ObjectType("Account")
+
+
+@account_bindable.field("name")
+def resolve_name(account: Account, info: GraphQLResolveInfo) -> str:
+ return account.name
+
+
+@account_bindable.field("oktaConfig")
+@sync_to_async
+def resolve_okta_config(
+ account: Account, info: GraphQLResolveInfo
+) -> OktaSettings | None:
+ return OktaSettings.objects.filter(account_id=account.pk).first()
+
+
+@account_bindable.field("totalSeatCount")
+def resolve_total_seat_count(account: Account, info: GraphQLResolveInfo) -> int:
+ return account.total_seat_count
+
+
+@account_bindable.field("activatedUserCount")
+@sync_to_async
+def resolve_activated_user_count(account: Account, info: GraphQLResolveInfo) -> int:
+ return account.activated_user_count
+
+
+@account_bindable.field("organizations")
+def resolve_organizations(
+ account: Account,
+ info: GraphQLResolveInfo,
+ ordering_direction: Optional[OrderingDirection] = OrderingDirection.ASC,
+ **kwargs: Any,
+) -> Coroutine[Any, Any, Owner]:
+ return queryset_to_connection(
+ account.organizations,
+ ordering=("username",),
+ ordering_direction=ordering_direction,
+ **kwargs,
+ )
diff --git a/apps/codecov-api/graphql_api/types/billing/__init__.py b/apps/codecov-api/graphql_api/types/billing/__init__.py
new file mode 100644
index 0000000000..5b0c130147
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/billing/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .billing import billing_bindable
+
+billing = ariadne_load_local_graphql(__file__, "billing.graphql")
+
+
+__all__ = ["billing_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/billing/billing.graphql b/apps/codecov-api/graphql_api/types/billing/billing.graphql
new file mode 100644
index 0000000000..972421c7a0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/billing/billing.graphql
@@ -0,0 +1,8 @@
+type Billing {
+ unverifiedPaymentMethods: [UnverifiedPaymentMethod]
+}
+
+type UnverifiedPaymentMethod {
+ paymentMethodId: String!
+ hostedVerificationUrl: String
+}
diff --git a/apps/codecov-api/graphql_api/types/billing/billing.py b/apps/codecov-api/graphql_api/types/billing/billing.py
new file mode 100644
index 0000000000..bffb6d6895
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/billing/billing.py
@@ -0,0 +1,14 @@
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+
+from codecov_auth.models import Owner
+from services.billing import BillingService
+
+billing_bindable = ObjectType("Billing")
+
+
+@billing_bindable.field("unverifiedPaymentMethods")
+def resolve_unverified_payment_methods(
+ owner: Owner, info: GraphQLResolveInfo
+) -> list[dict]:
+ return BillingService(requesting_user=owner).get_unverified_payment_methods(owner)
diff --git a/apps/codecov-api/graphql_api/types/branch/__init__.py b/apps/codecov-api/graphql_api/types/branch/__init__.py
new file mode 100644
index 0000000000..27a3954e89
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/branch/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .branch import branch_bindable
+
+branch = ariadne_load_local_graphql(__file__, "branch.graphql")
+
+
+__all__ = ["branch_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/branch/branch.graphql b/apps/codecov-api/graphql_api/types/branch/branch.graphql
new file mode 100644
index 0000000000..162ebe9b9b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/branch/branch.graphql
@@ -0,0 +1,5 @@
+type Branch {
+ name: String!
+ headSha: String!
+ head: Commit
+}
diff --git a/apps/codecov-api/graphql_api/types/branch/branch.py b/apps/codecov-api/graphql_api/types/branch/branch.py
new file mode 100644
index 0000000000..c396cdab65
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/branch/branch.py
@@ -0,0 +1,25 @@
+from typing import Optional
+
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+
+from core.models import Branch, Commit
+from graphql_api.dataloader.commit import CommitLoader
+
+branch_bindable = ObjectType("Branch")
+
+
+@branch_bindable.field("headSha")
+def resolve_head_sha(branch: Branch, info: GraphQLResolveInfo) -> str:
+ head = branch.head
+ return head
+
+
+@branch_bindable.field("head")
+async def resolve_head_commit(
+ branch: Branch, info: GraphQLResolveInfo
+) -> Optional[Commit]:
+ head = branch.head
+ if head:
+ loader = CommitLoader.loader(info, branch.repository_id)
+ return await loader.load(head)
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/__init__.py b/apps/codecov-api/graphql_api/types/bundle_analysis/__init__.py
new file mode 100644
index 0000000000..40cf8873ad
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/__init__.py
@@ -0,0 +1,36 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .base import (
+ bundle_asset_bindable,
+ bundle_data_bindable,
+ bundle_module_bindable,
+ bundle_report_bindable,
+ bundle_report_info_bindable,
+)
+from .comparison import (
+ bundle_analysis_comparison_bindable,
+ bundle_analysis_comparison_result_bindable,
+ bundle_comparison_bindable,
+)
+from .report import (
+ bundle_analysis_report_bindable,
+ bundle_analysis_report_result_bindable,
+)
+
+bundle_analysis = ariadne_load_local_graphql(__file__, "base.graphql")
+bundle_analysis_comparison = ariadne_load_local_graphql(__file__, "comparison.graphql")
+bundle_analysis_report = ariadne_load_local_graphql(__file__, "report.graphql")
+
+
+__all__ = [
+ "bundle_asset_bindable",
+ "bundle_data_bindable",
+ "bundle_module_bindable",
+ "bundle_report_bindable",
+ "bundle_report_info_bindable",
+ "bundle_analysis_comparison_bindable",
+ "bundle_analysis_comparison_result_bindable",
+ "bundle_comparison_bindable",
+ "bundle_analysis_report_bindable",
+ "bundle_analysis_report_result_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/base.graphql b/apps/codecov-api/graphql_api/types/bundle_analysis/base.graphql
new file mode 100644
index 0000000000..d6102ca91b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/base.graphql
@@ -0,0 +1,116 @@
+enum BundleAnalysisMeasurementsAssetType {
+ REPORT_SIZE
+ JAVASCRIPT_SIZE
+ STYLESHEET_SIZE
+ FONT_SIZE
+ IMAGE_SIZE
+ ASSET_SIZE
+ UNKNOWN_SIZE
+}
+
+enum BundleReportGroups {
+ JAVASCRIPT
+ STYLESHEET
+ FONT
+ IMAGE
+ UNKNOWN
+}
+
+enum BundleLoadTypes {
+ ENTRY
+ INITIAL
+ LAZY
+}
+
+type BundleSize {
+ gzip: Int!
+ uncompress: Int!
+}
+
+type BundleLoadTime {
+ threeG: Int!
+ highSpeed: Int!
+}
+
+type BundleData {
+ loadTime: BundleLoadTime!
+ size: BundleSize!
+}
+
+type BundleModule {
+ name: String!
+ extension: String!
+ bundleData: BundleData!
+}
+
+type BundleAsset {
+ name: String!
+ extension: String!
+ normalizedName: String!
+ modules: [BundleModule]!
+ bundleData: BundleData!
+ measurements(
+ interval: MeasurementInterval!
+ before: DateTime!
+ after: DateTime
+ branch: String
+ ): BundleAnalysisMeasurements
+ routes: [String!]
+}
+
+type BundleReportInfo {
+ version: String!
+ pluginName: String!
+ pluginVersion: String!
+ builtAt: String!
+ duration: Int!
+ bundlerName: String!
+ bundlerVersion: String!
+}
+
+type BundleReport {
+ name: String!
+ moduleCount: Int!
+ assets: [BundleAsset]!
+ asset(name: String!): BundleAsset
+ bundleData: BundleData!
+ bundleDataFiltered(filters: BundleReportFilters): BundleData!
+ measurements(
+ interval: MeasurementInterval!
+ before: DateTime!
+ after: DateTime
+ branch: String
+ orderingDirection: OrderingDirection
+ filters: BundleAnalysisMeasurementsSetFilters
+ ): [BundleAnalysisMeasurements!]
+ isCached: Boolean!
+ assetsPaginated(
+ ordering: AssetOrdering
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): AssetConnection
+ info: BundleReportInfo!
+ cacheConfig: Boolean!
+}
+
+type BundleAnalysisMeasurements{
+ assetType: BundleAnalysisMeasurementsAssetType!
+ name: String
+ size: BundleData
+ change: BundleData
+ measurements: [Measurement!]
+}
+
+type AssetConnection {
+ edges: [AssetEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type AssetEdge {
+ cursor: String!
+ node: BundleAsset!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/base.py b/apps/codecov-api/graphql_api/types/bundle_analysis/base.py
new file mode 100644
index 0000000000..f086848f4f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/base.py
@@ -0,0 +1,435 @@
+from copy import deepcopy
+from datetime import datetime
+from typing import Dict, List, Mapping, Optional, Union
+
+import sentry_sdk
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+
+from codecov.commands.exceptions import ValidationError
+from graphql_api.types.enums import AssetOrdering, OrderingDirection
+from services.bundle_analysis import (
+ AssetReport,
+ BundleAnalysisMeasurementData,
+ BundleAnalysisMeasurementsAssetType,
+ BundleAnalysisMeasurementsService,
+ BundleData,
+ BundleLoadTime,
+ BundleReport,
+ BundleReportInfo,
+ BundleSize,
+ ModuleReport,
+)
+from timeseries.models import Interval
+
+ASSET_TYPE_UNKNOWN = "UNKNOWN_SIZE"
+
+bundle_data_bindable = ObjectType("BundleData")
+bundle_module_bindable = ObjectType("BundleModule")
+bundle_asset_bindable = ObjectType("BundleAsset")
+bundle_report_bindable = ObjectType("BundleReport")
+bundle_report_info_bindable = ObjectType("BundleReportInfo")
+
+
+def _find_index_by_cursor(assets: List, cursor: str) -> int:
+ try:
+ for i, asset in enumerate(assets):
+ if asset.id == int(cursor):
+ return i
+ except ValueError:
+ pass
+ return -1
+
+
+def _compute_unknown_asset_size_raw_measurements(fetched_data: dict) -> List[dict]:
+ """
+ Computes measurements for the unknown asset types, some asset types are not in
+ the predetermined list of types so we must compute those measurements manually.
+ The heuristic will be to get the measurements of the bundle type (ie total bundle size)
+ then substract it from all the known asset type measurements, leaving with only the unknown.
+ """
+ unknown_raw_measurements = deepcopy(
+ fetched_data[BundleAnalysisMeasurementsAssetType.REPORT_SIZE][
+ 0
+ ].raw_measurements
+ )
+
+ for name, measurements in fetched_data.items():
+ if name not in (
+ BundleAnalysisMeasurementsAssetType.REPORT_SIZE,
+ BundleAnalysisMeasurementsAssetType.ASSET_SIZE,
+ ):
+ raw_measurements = measurements[0].raw_measurements
+ for i in range(len(raw_measurements)):
+ if len(unknown_raw_measurements) != len(raw_measurements):
+ return []
+ unknown_raw_measurements[i]["min"] -= raw_measurements[i]["min"]
+ unknown_raw_measurements[i]["max"] -= raw_measurements[i]["max"]
+ unknown_raw_measurements[i]["avg"] -= raw_measurements[i]["avg"]
+
+ return unknown_raw_measurements
+
+
+# ============= Bundle Data Bindable =============
+
+
+@bundle_data_bindable.field("size")
+def resolve_bundle_size(
+ bundle_data: BundleData, info: GraphQLResolveInfo
+) -> BundleSize:
+ return bundle_data.size
+
+
+@bundle_data_bindable.field("loadTime")
+def resolve_bundle_load_time(
+ bundle_data: BundleData, info: GraphQLResolveInfo
+) -> BundleLoadTime:
+ return bundle_data.load_time
+
+
+# ============= Bundle Module Bindable =============
+
+
+@bundle_module_bindable.field("name")
+def resolve_bundle_module_name(
+ bundle_module: ModuleReport, info: GraphQLResolveInfo
+) -> str:
+ return bundle_module.name
+
+
+@bundle_module_bindable.field("bundleData")
+def resolve_bundle_module_bundle_data(
+ bundle_module: ModuleReport, info: GraphQLResolveInfo
+) -> BundleData:
+ return BundleData(bundle_module.size_total)
+
+
+# ============= Bundle Asset Bindable =============
+
+
+@bundle_asset_bindable.field("name")
+def resolve_bundle_asset_name(
+ bundle_asset: AssetReport, info: GraphQLResolveInfo
+) -> str:
+ return bundle_asset.name
+
+
+@bundle_asset_bindable.field("normalizedName")
+def resolve_normalized_name(bundle_asset: AssetReport, info: GraphQLResolveInfo) -> str:
+ return bundle_asset.normalized_name
+
+
+@bundle_asset_bindable.field("extension")
+def resolve_extension(bundle_asset: AssetReport, info: GraphQLResolveInfo) -> str:
+ return bundle_asset.extension
+
+
+@bundle_asset_bindable.field("bundleData")
+def resolve_bundle_asset_bundle_data(
+ bundle_asset: AssetReport, info: GraphQLResolveInfo
+) -> BundleData:
+ return BundleData(bundle_asset.size_total, bundle_asset.gzip_size_total)
+
+
+@bundle_asset_bindable.field("modules")
+def resolve_modules(
+ bundle_asset: AssetReport, info: GraphQLResolveInfo
+) -> List[ModuleReport]:
+ return bundle_asset.modules
+
+
+@bundle_asset_bindable.field("measurements")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_asset_report_measurements(
+ bundle_asset: AssetReport,
+ info: GraphQLResolveInfo,
+ interval: Interval,
+ before: datetime,
+ after: Optional[datetime] = None,
+ branch: Optional[str] = None,
+) -> Optional[BundleAnalysisMeasurementData]:
+ bundle_analysis_measurements = BundleAnalysisMeasurementsService(
+ repository=info.context["commit"].repository,
+ interval=interval,
+ before=before,
+ after=after,
+ branch=branch,
+ )
+ return bundle_analysis_measurements.compute_asset(bundle_asset)
+
+
+@bundle_asset_bindable.field("routes")
+def resolve_routes(
+ bundle_asset: AssetReport, info: GraphQLResolveInfo
+) -> Optional[List[str]]:
+ return bundle_asset.routes
+
+
+# ============= Bundle Report Bindable =============
+
+
+@bundle_report_bindable.field("name")
+def resolve_name(bundle_report: BundleReport, info: GraphQLResolveInfo) -> str:
+ return bundle_report.name
+
+
+@bundle_report_bindable.field("moduleCount")
+def resolve_module_count(bundle_report: BundleReport, info: GraphQLResolveInfo) -> int:
+ return bundle_report.module_count
+
+
+@bundle_report_bindable.field("assets")
+def resolve_assets(
+ bundle_report: BundleReport,
+ info: GraphQLResolveInfo,
+) -> List[AssetReport]:
+ return list(bundle_report.assets())
+
+
+@bundle_report_bindable.field("assetsPaginated")
+@sentry_sdk.trace
+def resolve_assets_paginated(
+ bundle_report: BundleReport,
+ info: GraphQLResolveInfo,
+ ordering: AssetOrdering = AssetOrdering.SIZE,
+ ordering_direction: OrderingDirection = OrderingDirection.DESC,
+ first: Optional[int] = None,
+ after: Optional[str] = None,
+ last: Optional[int] = None,
+ before: Optional[str] = None,
+) -> Union[Dict[str, object], ValidationError]:
+ if first is not None and last is not None:
+ return ValidationError("First and last can not be used at the same time")
+ if after is not None and before is not None:
+ return ValidationError("After and before can not be used at the same time")
+
+ # All filtered assets before pagination
+ assets = list(
+ bundle_report.assets(
+ ordering=ordering.value,
+ ordering_desc=ordering_direction.value == OrderingDirection.DESC.value,
+ )
+ )
+
+ total_count, has_next_page, has_previous_page = len(assets), False, False
+ start_cursor, end_cursor = None, None
+
+ # Apply cursors to edges
+ if after is not None:
+ after_edge = _find_index_by_cursor(assets, after)
+ if after_edge > -1:
+ assets = assets[after_edge + 1 :]
+
+ if before is not None:
+ before_edge = _find_index_by_cursor(assets, before)
+ if before_edge > -1:
+ assets = assets[:before_edge]
+
+ # Slice edges by return size
+ if first is not None and first >= 0:
+ if len(assets) > first:
+ assets = assets[:first]
+ has_next_page = True
+
+ if last is not None and last >= 0:
+ if len(assets) > last:
+ assets = assets[len(assets) - last :]
+ has_previous_page = True
+
+ if assets:
+ start_cursor, end_cursor = assets[0].id, assets[-1].id
+
+ return {
+ "edges": [{"cursor": asset.id, "node": asset} for asset in assets],
+ "total_count": total_count,
+ "page_info": {
+ "has_next_page": has_next_page,
+ "has_previous_page": has_previous_page,
+ "start_cursor": start_cursor,
+ "end_cursor": end_cursor,
+ },
+ }
+
+
+@bundle_report_bindable.field("asset")
+def resolve_asset(
+ bundle_report: BundleReport, info: GraphQLResolveInfo, name: str
+) -> Optional[AssetReport]:
+ return bundle_report.asset(name)
+
+
+@bundle_report_bindable.field("bundleData")
+def resolve_bundle_data(
+ bundle_report: BundleReport, info: GraphQLResolveInfo
+) -> BundleData:
+ return BundleData(
+ bundle_report.size_total,
+ bundle_report.gzip_size_total,
+ )
+
+
+@bundle_report_bindable.field("bundleDataFiltered")
+def resolve_bundle_report_filtered(
+ bundle_report: BundleReport,
+ info: GraphQLResolveInfo,
+ filters: dict[str, list[str]] = {},
+) -> BundleData:
+ group = filters.get("report_group")
+ return BundleData(
+ bundle_report.report.total_size(asset_types=[group] if group else None),
+ bundle_report.report.total_gzip_size(asset_types=[group] if group else None),
+ )
+
+
+@bundle_report_bindable.field("measurements")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_bundle_report_measurements(
+ bundle_report: BundleReport,
+ info: GraphQLResolveInfo,
+ interval: Interval,
+ before: datetime,
+ after: Optional[datetime] = None,
+ branch: Optional[str] = None,
+ filters: Mapping = {},
+ ordering_direction: Optional[OrderingDirection] = OrderingDirection.ASC,
+) -> List[BundleAnalysisMeasurementData]:
+ asset_types = list(filters.get("asset_types", []))
+ bundle_analysis_measurements = BundleAnalysisMeasurementsService(
+ repository=info.context["commit"].repository,
+ interval=interval,
+ before=before,
+ after=after,
+ branch=branch,
+ )
+
+ # All measureable names we need to fetch to compute the requested asset types
+ if not asset_types:
+ measurables_to_fetch = list(BundleAnalysisMeasurementsAssetType)
+ elif ASSET_TYPE_UNKNOWN in asset_types:
+ measurables_to_fetch = [
+ BundleAnalysisMeasurementsAssetType.REPORT_SIZE,
+ BundleAnalysisMeasurementsAssetType.JAVASCRIPT_SIZE,
+ BundleAnalysisMeasurementsAssetType.STYLESHEET_SIZE,
+ BundleAnalysisMeasurementsAssetType.FONT_SIZE,
+ BundleAnalysisMeasurementsAssetType.IMAGE_SIZE,
+ ]
+ else:
+ measurables_to_fetch = [
+ BundleAnalysisMeasurementsAssetType[item] for item in asset_types
+ ]
+
+ # Retrieve all the measurements necessary to compute the requested asset types
+ fetched_data = {}
+ for name in measurables_to_fetch:
+ fetched_data[name] = bundle_analysis_measurements.compute_report(
+ bundle_report, asset_type=name
+ )
+
+ # All measureable name we need to return
+ if not asset_types:
+ measurables_to_display = list(BundleAnalysisMeasurementsAssetType)
+ else:
+ measurables_to_display = [
+ BundleAnalysisMeasurementsAssetType[item]
+ for item in asset_types
+ if item != ASSET_TYPE_UNKNOWN
+ ]
+
+ measurements = []
+ for measurable in measurables_to_display:
+ measurements.extend(fetched_data[measurable])
+
+ # Compute for unknown asset type size if necessary
+ if not asset_types or ASSET_TYPE_UNKNOWN in asset_types:
+ unknown_size_raw_measurements = _compute_unknown_asset_size_raw_measurements(
+ fetched_data
+ )
+ measurements.append(
+ BundleAnalysisMeasurementData(
+ raw_measurements=unknown_size_raw_measurements,
+ asset_type=ASSET_TYPE_UNKNOWN,
+ asset_name=None,
+ interval=interval,
+ after=after,
+ before=before,
+ )
+ )
+
+ return sorted(
+ measurements,
+ key=lambda c: c.asset_type,
+ reverse=ordering_direction == OrderingDirection.DESC,
+ )
+
+
+@bundle_report_bindable.field("isCached")
+def resolve_bundle_report_is_cached(
+ bundle_report: BundleReport, info: GraphQLResolveInfo
+) -> bool:
+ return bundle_report.is_cached
+
+
+@bundle_report_bindable.field("cacheConfig")
+def resolve_bundle_report_cache_config(
+ bundle_report: BundleReport, info: GraphQLResolveInfo
+) -> bool:
+ return bundle_report.cache_config(info.context["commit"].repository.pk)
+
+
+@bundle_report_bindable.field("info")
+def resolve_bundle_report_info(
+ bundle_report: BundleReport, info: GraphQLResolveInfo
+) -> BundleReportInfo:
+ return BundleReportInfo(bundle_report.info)
+
+
+@bundle_report_info_bindable.field("version")
+def resolve_bundle_report_info_version(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.version
+
+
+@bundle_report_info_bindable.field("pluginName")
+def resolve_bundle_report_info_plugin_name(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.plugin_name
+
+
+@bundle_report_info_bindable.field("pluginVersion")
+def resolve_bundle_report_info_plugin_version(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.plugin_version
+
+
+@bundle_report_info_bindable.field("builtAt")
+def resolve_bundle_report_info_built_at(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.built_at
+
+
+@bundle_report_info_bindable.field("duration")
+def resolve_bundle_report_info_duration(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> int:
+ return bundle_report_info.duration
+
+
+@bundle_report_info_bindable.field("bundlerName")
+def resolve_bundle_report_info_bundler_name(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.bundler_name
+
+
+@bundle_report_info_bindable.field("bundlerVersion")
+def resolve_bundle_report_info_bundler_version(
+ bundle_report_info: BundleReportInfo, info: GraphQLResolveInfo
+) -> str:
+ return bundle_report_info.bundler_version
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.graphql b/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.graphql
new file mode 100644
index 0000000000..c35f5f0a1a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.graphql
@@ -0,0 +1,20 @@
+union BundleAnalysisComparisonResult =
+ BundleAnalysisComparison
+ | FirstPullRequest
+ | MissingBaseCommit
+ | MissingHeadCommit
+ | MissingHeadReport
+ | MissingBaseReport
+
+type BundleAnalysisComparison {
+ bundles: [BundleComparison]!
+ bundleData: BundleData!
+ bundleChange: BundleData!
+}
+
+type BundleComparison {
+ name: String!
+ changeType: String!
+ bundleData: BundleData!
+ bundleChange: BundleData!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.py b/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.py
new file mode 100644
index 0000000000..cfd1987ea5
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/comparison.py
@@ -0,0 +1,75 @@
+from ariadne import ObjectType, UnionType
+
+from graphql_api.types.comparison.comparison import (
+ FirstPullRequest,
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingHeadCommit,
+ MissingHeadReport,
+)
+from services.bundle_analysis import (
+ BundleAnalysisComparison,
+ BundleComparison,
+ BundleData,
+)
+
+bundle_analysis_comparison_result_bindable = UnionType("BundleAnalysisComparisonResult")
+bundle_analysis_comparison_bindable = ObjectType("BundleAnalysisComparison")
+bundle_comparison_bindable = ObjectType("BundleComparison")
+
+
+@bundle_analysis_comparison_result_bindable.type_resolver
+def resolve_bundle_analysis_comparison_result_type(obj, *_):
+ if isinstance(obj, BundleAnalysisComparison):
+ return "BundleAnalysisComparison"
+ elif isinstance(obj, MissingHeadCommit):
+ return "MissingHeadCommit"
+ elif isinstance(obj, MissingBaseCommit):
+ return "MissingBaseCommit"
+ elif isinstance(obj, FirstPullRequest):
+ return "FirstPullRequest"
+ elif isinstance(obj, MissingHeadReport):
+ return "MissingHeadReport"
+ elif isinstance(obj, MissingBaseReport):
+ return "MissingBaseReport"
+
+
+@bundle_analysis_comparison_bindable.field("bundles")
+def resolve_ba_comparison_bundles(
+ bundles_analysis_comparison: BundleAnalysisComparison, info
+):
+ return bundles_analysis_comparison.bundles
+
+
+@bundle_analysis_comparison_bindable.field("bundleData")
+def resolve_ba_comparison_bundle_data(
+ bundles_analysis_comparison: BundleAnalysisComparison, info
+) -> BundleData:
+ return BundleData(bundles_analysis_comparison.size_total)
+
+
+@bundle_analysis_comparison_bindable.field("bundleChange")
+def resolve_ba_comparison_bundle_delta(
+ bundles_analysis_comparison: BundleAnalysisComparison, info
+) -> BundleData:
+ return BundleData(bundles_analysis_comparison.size_delta)
+
+
+@bundle_comparison_bindable.field("name")
+def resolve_name(bundle_comparison: BundleComparison, info):
+ return bundle_comparison.bundle_name
+
+
+@bundle_comparison_bindable.field("changeType")
+def resolve_change_type(bundle_comparison: BundleComparison, info):
+ return bundle_comparison.change_type
+
+
+@bundle_comparison_bindable.field("bundleData")
+def resolve_bundle_data(bundle_comparison: BundleComparison, info) -> BundleData:
+ return BundleData(bundle_comparison.size_total)
+
+
+@bundle_comparison_bindable.field("bundleChange")
+def resolve_bundle_delta(bundle_comparison: BundleComparison, info) -> BundleData:
+ return BundleData(bundle_comparison.size_delta)
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/report.graphql b/apps/codecov-api/graphql_api/types/bundle_analysis/report.graphql
new file mode 100644
index 0000000000..4dcfbc8ee7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/report.graphql
@@ -0,0 +1,10 @@
+union BundleAnalysisReportResult =
+ BundleAnalysisReport
+ | MissingHeadReport
+
+type BundleAnalysisReport {
+ bundles: [BundleReport]!
+ bundleData: BundleData!
+ bundle(name: String!, filters: BundleAnalysisReportFilters): BundleReport
+ isCached: Boolean!
+}
diff --git a/apps/codecov-api/graphql_api/types/bundle_analysis/report.py b/apps/codecov-api/graphql_api/types/bundle_analysis/report.py
new file mode 100644
index 0000000000..a57f5cf4cf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/bundle_analysis/report.py
@@ -0,0 +1,85 @@
+from typing import Any, List, Optional, Union
+
+from ariadne import ObjectType, UnionType
+from graphql import GraphQLResolveInfo
+
+from graphql_api.types.comparison.comparison import MissingHeadReport
+from graphql_api.types.enums import BundleLoadTypes
+from services.bundle_analysis import BundleAnalysisReport, BundleData, BundleReport
+
+bundle_analysis_report_result_bindable = UnionType("BundleAnalysisReportResult")
+bundle_analysis_report_bindable = ObjectType("BundleAnalysisReport")
+
+
+@bundle_analysis_report_result_bindable.type_resolver
+def resolve_bundle_analysis_report_result_type(
+ obj: Union[BundleAnalysisReport, MissingHeadReport], *_: Any
+) -> str:
+ if isinstance(obj, BundleAnalysisReport):
+ return "BundleAnalysisReport"
+ elif isinstance(obj, MissingHeadReport):
+ return "MissingHeadReport"
+
+
+@bundle_analysis_report_bindable.field("bundles")
+def resolve_bundles(
+ bundles_analysis_report: BundleAnalysisReport, info: GraphQLResolveInfo
+) -> List[BundleReport]:
+ return bundles_analysis_report.bundles
+
+
+@bundle_analysis_report_bindable.field("bundle")
+def resolve_bundle(
+ bundles_analysis_report: BundleAnalysisReport,
+ info: GraphQLResolveInfo,
+ name: str,
+ filters: dict[str, list[str]] = {},
+) -> Optional[BundleReport]:
+ asset_types = None
+ if filters.get("report_groups"):
+ asset_types = filters.get("report_groups")
+
+ chunk_entry, chunk_initial = None, None
+ if filters.get("load_types"):
+ load_types = filters.get("load_types")
+
+ # Compute chunk entry boolean
+ if BundleLoadTypes.ENTRY in load_types and (
+ BundleLoadTypes.INITIAL in load_types or BundleLoadTypes.LAZY in load_types
+ ):
+ chunk_entry = None
+ elif BundleLoadTypes.ENTRY in load_types:
+ chunk_entry = True
+ elif (
+ BundleLoadTypes.INITIAL in load_types or BundleLoadTypes.LAZY in load_types
+ ):
+ chunk_entry = False
+
+ # Compute chunk initial boolean
+ if BundleLoadTypes.INITIAL in load_types and BundleLoadTypes.LAZY in load_types:
+ chunk_initial = None
+ elif BundleLoadTypes.INITIAL in load_types:
+ chunk_initial = True
+ elif BundleLoadTypes.LAZY in load_types:
+ chunk_initial = False
+
+ return bundles_analysis_report.bundle(
+ name,
+ {
+ "asset_types": asset_types,
+ "chunk_entry": chunk_entry,
+ "chunk_initial": chunk_initial,
+ },
+ )
+
+
+@bundle_analysis_report_bindable.field("bundleData")
+def resolve_bundle_data(
+ bundles_analysis_report: BundleAnalysisReport, info: GraphQLResolveInfo
+) -> BundleData:
+ return BundleData(bundles_analysis_report.size_total)
+
+
+@bundle_analysis_report_bindable.field("isCached")
+def resolve_is_cached(bundle_report: BundleReport, info: GraphQLResolveInfo) -> bool:
+ return bundle_report.is_cached
diff --git a/apps/codecov-api/graphql_api/types/commit/__init__.py b/apps/codecov-api/graphql_api/types/commit/__init__.py
new file mode 100644
index 0000000000..b4b6f2583d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/commit/__init__.py
@@ -0,0 +1,16 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .commit import (
+ commit_bindable,
+ commit_bundle_analysis_bindable,
+ commit_coverage_analytics_bindable,
+)
+
+commit = ariadne_load_local_graphql(__file__, "commit.graphql")
+
+
+__all__ = [
+ "commit_bindable",
+ "commit_coverage_analytics_bindable",
+ "commit_bundle_analysis_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/commit/commit.graphql b/apps/codecov-api/graphql_api/types/commit/commit.graphql
new file mode 100644
index 0000000000..d60006302b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/commit/commit.graphql
@@ -0,0 +1,74 @@
+type Commit {
+ state: String
+ message: String
+ createdAt: DateTime!
+ commitid: String!
+ author: Owner
+ parent: Commit
+ pullId: Int
+ branchName: String
+ yaml: String
+ yamlState: YamlStates
+ ciPassed: Boolean
+ compareWithParent: ComparisonResult
+ uploads(
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): UploadConnection @cost(complexity: 10, multipliers: ["first", "last"])
+ pathContents(path: String, filters: PathContentsFilters): PathContentsResult
+ deprecatedPathContents(path: String, filters: PathContentsFilters, first: Int, after: String, last: Int, before: String): DeprecatedPathContentsResult
+ errors(errorType: CommitErrorType!): CommitErrorsConnection!
+ totalUploads: Int!
+ bundleStatus: CommitStatus
+ coverageStatus: CommitStatus
+ coverageAnalytics: CommitCoverageAnalytics
+ bundleAnalysis: CommitBundleAnalysis
+ latestUploadError: LatestUploadError
+}
+
+type LatestUploadError {
+ errorCode: UploadErrorEnum
+ errorMessage: String
+}
+
+"fields related to Codecov's Coverage product offering"
+type CommitCoverageAnalytics {
+ components(filters: ComponentsFilters): [Component!]!
+ coverageFile(path: String!, flags: [String], components: [String]): File
+ flagNames: [String]
+ totals: CoverageTotals
+}
+
+"fields related to Codecov's Bundle Analysis product offering"
+type CommitBundleAnalysis {
+ bundleAnalysisCompareWithParent: BundleAnalysisComparisonResult
+ bundleAnalysisReport: BundleAnalysisReportResult
+}
+
+type CommitErrorsConnection {
+ edges: [CommitErrorEdge]
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type CommitErrorEdge {
+ cursor: String!
+ node: CommitError!
+}
+
+type CommitError {
+ errorCode: CommitErrorCode!
+}
+
+type UploadConnection {
+ edges: [UploadEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type UploadEdge {
+ cursor: String!
+ node: Upload!
+}
diff --git a/apps/codecov-api/graphql_api/types/commit/commit.py b/apps/codecov-api/graphql_api/types/commit/commit.py
new file mode 100644
index 0000000000..0f1505284b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/commit/commit.py
@@ -0,0 +1,447 @@
+import logging
+from typing import Any, List, Optional, Union
+
+import sentry_sdk
+import shared.reports.api_report_service as report_service
+import yaml
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+from shared.reports.api_report_service import ReadOnlyReport
+from shared.reports.filtered import FilteredReportFile
+from shared.reports.resources import ReportFile
+from shared.reports.types import ReportTotals
+
+import services.components as components_service
+import services.path as path_service
+from codecov_auth.models import Owner
+from core.models import Commit
+from graphql_api.actions.commits import commit_status
+from graphql_api.actions.comparison import validate_commit_comparison
+from graphql_api.actions.path_contents import sort_path_contents
+from graphql_api.dataloader.bundle_analysis import (
+ load_bundle_analysis_comparison,
+ load_bundle_analysis_report,
+)
+from graphql_api.dataloader.commit import CommitLoader
+from graphql_api.dataloader.comparison import ComparisonLoader
+from graphql_api.dataloader.owner import OwnerLoader
+from graphql_api.helpers.connection import (
+ queryset_to_connection,
+ queryset_to_connection_sync,
+)
+from graphql_api.helpers.requested_fields import selected_fields
+from graphql_api.types.comparison.comparison import (
+ MissingBaseCommit,
+ MissingHeadReport,
+)
+from graphql_api.types.enums import (
+ OrderingDirection,
+ PathContentDisplayType,
+)
+from graphql_api.types.errors import MissingCoverage, UnknownPath
+from graphql_api.types.errors.errors import UnknownFlags
+from reports.models import CommitReport
+from services.bundle_analysis import BundleAnalysisComparison, BundleAnalysisReport
+from services.comparison import Comparison, ComparisonReport
+from services.components import Component
+from services.path import Dir, File, ReportPaths
+from services.yaml import (
+ YamlStates,
+ get_yaml_state,
+)
+
+commit_bindable = ObjectType("Commit")
+commit_coverage_analytics_bindable = ObjectType("CommitCoverageAnalytics")
+commit_bundle_analysis_bindable = ObjectType("CommitBundleAnalysis")
+
+commit_bindable.set_alias("createdAt", "timestamp")
+commit_bindable.set_alias("pullId", "pullid")
+commit_bindable.set_alias("branchName", "branch")
+
+log = logging.getLogger(__name__)
+
+
+@commit_bindable.field("author")
+def resolve_author(commit: Commit, info: GraphQLResolveInfo) -> Owner | None:
+ if commit.author_id:
+ return OwnerLoader.loader(info).load(commit.author_id)
+
+
+@commit_bindable.field("parent")
+def resolve_parent(commit: Commit, info: GraphQLResolveInfo) -> Commit | None:
+ if commit.parent_commit_id:
+ return CommitLoader.loader(info, commit.repository_id).load(
+ commit.parent_commit_id
+ )
+
+
+@commit_bindable.field("yaml")
+async def resolve_yaml(commit: Commit, info: GraphQLResolveInfo) -> dict:
+ command = info.context["executor"].get_command("commit")
+ final_yaml = await command.get_final_yaml(commit)
+ return yaml.dump(final_yaml)
+
+
+@commit_bindable.field("yamlState")
+async def resolve_yaml_state(commit: Commit, info: GraphQLResolveInfo) -> YamlStates:
+ command = info.context["executor"].get_command("commit")
+ final_yaml = await command.get_final_yaml(commit)
+ return get_yaml_state(yaml=final_yaml)
+
+
+@commit_bindable.field("uploads")
+@sync_to_async
+def resolve_list_uploads(commit: Commit, info: GraphQLResolveInfo, **kwargs):
+ if not commit.commitreport:
+ return queryset_to_connection_sync([])
+
+ queryset = commit.commitreport.sessions
+
+ requested_fields = selected_fields(info)
+
+ # the `requested_fields` here are prefixed with `edges.node`, as this is a `Connection`
+ # and using `uploads { edges { node { ... } } }` is the way this is queried.
+ if "edges.node.flags" in requested_fields:
+ queryset = queryset.prefetch_related("flags")
+ if "edges.node.errors" in requested_fields:
+ queryset = queryset.prefetch_related("errors")
+
+ if not kwargs: # temp to override kwargs -> return all current uploads
+ kwargs["first"] = queryset.count()
+
+ return queryset_to_connection_sync(
+ queryset, ordering=("id",), ordering_direction=OrderingDirection.ASC, **kwargs
+ )
+
+
+@commit_bindable.field("compareWithParent")
+@sentry_sdk.trace
+async def resolve_compare_with_parent(
+ commit: Commit, info: GraphQLResolveInfo, **kwargs
+):
+ if not commit.parent_commit_id:
+ return MissingBaseCommit()
+
+ comparison_loader = ComparisonLoader.loader(info, commit.repository_id)
+ commit_comparison = await comparison_loader.load(
+ (commit.parent_commit_id, commit.commitid)
+ )
+
+ comparison_error = validate_commit_comparison(commit_comparison=commit_comparison)
+
+ if comparison_error:
+ return comparison_error
+
+ if commit_comparison and commit_comparison.is_processed:
+ current_owner = info.context["request"].current_owner
+ parent_commit = await CommitLoader.loader(info, commit.repository_id).load(
+ commit.parent_commit_id
+ )
+ comparison = Comparison(
+ user=current_owner, base_commit=parent_commit, head_commit=commit
+ )
+ info.context["comparison"] = comparison
+
+ if commit_comparison:
+ return ComparisonReport(commit_comparison)
+
+
+@sentry_sdk.trace
+def get_sorted_path_contents(
+ current_owner: Owner,
+ commit: Commit,
+ path: str | None = None,
+ filters: dict | None = None,
+) -> (
+ list[File | Dir] | MissingHeadReport | MissingCoverage | UnknownFlags | UnknownPath
+):
+ # TODO: Might need to add reports here filtered by flags in the future
+ report = report_service.build_report_from_commit(
+ commit, report_class=ReadOnlyReport
+ )
+ if not report:
+ return MissingHeadReport()
+
+ if filters is None:
+ filters = {}
+ search_value = filters.get("search_value")
+ display_type = filters.get("display_type")
+
+ flags_filter = filters.get("flags", [])
+ component_filter = filters.get("components", [])
+
+ component_paths = []
+ component_flags = []
+
+ report_flags = report.get_flag_names()
+
+ if component_filter:
+ all_components = components_service.commit_components(commit, current_owner)
+ filtered_components = components_service.filter_components_by_name_or_id(
+ all_components, component_filter
+ )
+
+ if not filtered_components:
+ return MissingCoverage(
+ f"missing coverage for report with components: {component_filter}"
+ )
+
+ for component in filtered_components:
+ component_paths.extend(component.paths)
+ if report_flags:
+ component_flags.extend(component.get_matching_flags(report_flags))
+
+ if component_flags:
+ if flags_filter:
+ flags_filter = list(set(flags_filter) & set(component_flags))
+ else:
+ flags_filter = component_flags
+
+ if flags_filter and not report_flags:
+ return UnknownFlags(f"No coverage with chosen flags: {flags_filter}")
+
+ report_paths = ReportPaths(
+ report=report,
+ path=path,
+ search_term=search_value,
+ filter_flags=flags_filter,
+ filter_paths=component_paths,
+ )
+
+ if len(report_paths.paths) == 0:
+ # we do not know about this path
+
+ if path_service.provider_path_exists(path, commit, current_owner) is False:
+ # file doesn't exist
+ return UnknownPath(f"path does not exist: {path}")
+
+ # we're just missing coverage for the file
+ return MissingCoverage(f"missing coverage for path: {path}")
+
+ items: list[File | Dir]
+ if search_value or display_type == PathContentDisplayType.LIST:
+ items = report_paths.full_filelist()
+ else:
+ items = report_paths.single_directory()
+ return sort_path_contents(items, filters)
+
+
+@commit_bindable.field("pathContents")
+@sync_to_async
+def resolve_path_contents(
+ commit: Commit,
+ info: GraphQLResolveInfo,
+ path: str | None = None,
+ filters: dict | None = None,
+) -> Any:
+ """
+ The file directory tree is a list of all the files and directories
+ extracted from the commit report of the latest, head commit.
+ The is resolver results in a list that represent the tree with files
+ and nested directories.
+ """
+ current_owner = info.context["request"].current_owner
+
+ contents = get_sorted_path_contents(current_owner, commit, path, filters)
+ if isinstance(contents, list):
+ return {"results": contents}
+ return contents
+
+
+@commit_bindable.field("deprecatedPathContents")
+@sync_to_async
+def resolve_deprecated_path_contents(
+ commit: Commit,
+ info: GraphQLResolveInfo,
+ path: str | None = None,
+ filters: dict | None = None,
+ first: Any = None,
+ after: Any = None,
+ last: Any = None,
+ before: Any = None,
+) -> Any:
+ """
+ The file directory tree is a list of all the files and directories
+ extracted from the commit report of the latest, head commit.
+ The is resolver results in a list that represent the tree with files
+ and nested directories.
+ """
+ current_owner = info.context["request"].current_owner
+
+ contents = get_sorted_path_contents(current_owner, commit, path, filters)
+ if not isinstance(contents, list):
+ return contents
+
+ return queryset_to_connection_sync(
+ contents,
+ ordering_direction=OrderingDirection.ASC,
+ first=first,
+ last=last,
+ before=before,
+ after=after,
+ )
+
+
+@commit_bindable.field("errors")
+async def resolve_errors(commit, info, error_type):
+ command = info.context["executor"].get_command("commit")
+ queryset = await command.get_commit_errors(commit, error_type=error_type)
+ return await queryset_to_connection(
+ queryset,
+ ordering=("updated_at",),
+ ordering_direction=OrderingDirection.ASC,
+ )
+
+
+@commit_bindable.field("totalUploads")
+async def resolve_total_uploads(commit, info):
+ command = info.context["executor"].get_command("commit")
+ return await command.get_uploads_number(commit)
+
+
+@commit_bindable.field("bundleStatus")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_bundle_status(commit: Commit, info: GraphQLResolveInfo) -> str | None:
+ return commit_status(info, commit, CommitReport.ReportType.BUNDLE_ANALYSIS)
+
+
+@commit_bindable.field("coverageStatus")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_coverage_status(commit: Commit, info: GraphQLResolveInfo) -> str | None:
+ return commit_status(info, commit, CommitReport.ReportType.COVERAGE)
+
+
+@commit_bindable.field("coverageAnalytics")
+def resolve_commit_coverage(commit, info):
+ return commit
+
+
+@commit_bindable.field("bundleAnalysis")
+def resolve_commit_bundle_analysis(commit, info):
+ return commit
+
+
+### Commit Coverage Bindable ###
+
+
+@commit_coverage_analytics_bindable.field("totals")
+@sentry_sdk.trace
+def resolve_coverage_totals(
+ commit: Commit, info: GraphQLResolveInfo
+) -> Optional[ReportTotals]:
+ command = info.context["executor"].get_command("commit")
+ return command.fetch_totals(commit)
+
+
+@commit_coverage_analytics_bindable.field("flagNames")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_coverage_flags(commit: Commit, info: GraphQLResolveInfo) -> list[str]:
+ return commit.full_report.get_flag_names() if commit.full_report else []
+
+
+@commit_coverage_analytics_bindable.field("coverageFile")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_coverage_file(commit, info, path, flags=None, components=None):
+ fallback_file, paths = None, []
+ if components:
+ all_components = components_service.commit_components(
+ commit, info.context["request"].current_owner
+ )
+ filtered_components = components_service.filter_components_by_name_or_id(
+ all_components, components
+ )
+ for fc in filtered_components:
+ paths.extend(fc.paths)
+ fallback_file = FilteredReportFile(ReportFile(path), [])
+
+ commit_report = commit.full_report.filter(flags=flags, paths=paths)
+ file_report = commit_report.get(path) or fallback_file
+
+ return {
+ "commit_report": commit_report,
+ "file_report": file_report,
+ "commit": commit,
+ "path": path,
+ "flags": flags,
+ "components": components,
+ }
+
+
+@commit_coverage_analytics_bindable.field("components")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_coverage_components(commit: Commit, info, filters=None) -> List[Component]:
+ info.context["component_commit"] = commit
+ current_owner = info.context["request"].current_owner
+ all_components = components_service.commit_components(commit, current_owner)
+
+ if filters and filters.get("components"):
+ return components_service.filter_components_by_name_or_id(
+ all_components, filters["components"]
+ )
+
+ return all_components
+
+
+### Commit Bundle Analysis Bindable ###
+
+
+@commit_bundle_analysis_bindable.field("bundleAnalysisCompareWithParent")
+@sentry_sdk.trace
+async def resolve_commit_bundle_analysis_compare_with_parent(
+ commit: Commit, info: GraphQLResolveInfo
+) -> Union[BundleAnalysisComparison, Any]:
+ if not commit.parent_commit_id:
+ return MissingBaseCommit()
+ base_commit = await CommitLoader.loader(info, commit.repository_id).load(
+ commit.parent_commit_id
+ )
+
+ bundle_analysis_comparison = await sync_to_async(load_bundle_analysis_comparison)(
+ base_commit, commit
+ )
+
+ # Store the created SQLite DB path in info.context
+ # when the request is fully handled, have the file deleted
+ if isinstance(bundle_analysis_comparison, BundleAnalysisComparison):
+ info.context[
+ "request"
+ ].bundle_analysis_base_report_db_path = (
+ bundle_analysis_comparison.comparison.base_report.db_path
+ )
+ info.context[
+ "request"
+ ].bundle_analysis_head_report_db_path = (
+ bundle_analysis_comparison.comparison.head_report.db_path
+ )
+
+ return bundle_analysis_comparison
+
+
+@commit_bundle_analysis_bindable.field("bundleAnalysisReport")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_commit_bundle_analysis_report(commit: Commit, info) -> BundleAnalysisReport:
+ bundle_analysis_report = load_bundle_analysis_report(commit)
+
+ # Store the created SQLite DB path in info.context
+ # when the request is fully handled, have the file deleted
+ if isinstance(bundle_analysis_report, BundleAnalysisReport):
+ info.context[
+ "request"
+ ].bundle_analysis_head_report_db_path = bundle_analysis_report.report.db_path
+
+ info.context["commit"] = commit
+
+ return bundle_analysis_report
+
+
+@commit_bindable.field("latestUploadError")
+async def resolve_latest_upload_error(commit, info):
+ command = info.context["executor"].get_command("commit")
+ return await command.get_latest_upload_error(commit)
diff --git a/apps/codecov-api/graphql_api/types/comparison/__init__.py b/apps/codecov-api/graphql_api/types/comparison/__init__.py
new file mode 100644
index 0000000000..6344af7cbd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/comparison/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .comparison import comparison_bindable, comparison_result_bindable
+
+comparison = ariadne_load_local_graphql(__file__, "comparison.graphql")
+
+
+__all__ = ["comparison_bindable", "comparison_result_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/comparison/comparison.graphql b/apps/codecov-api/graphql_api/types/comparison/comparison.graphql
new file mode 100644
index 0000000000..8ae51b0c22
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/comparison/comparison.graphql
@@ -0,0 +1,50 @@
+type Comparison {
+ state: String!
+ impactedFile(path: String!): ImpactedFile
+ impactedFiles(filters: ImpactedFilesFilters): ImpactedFilesResult!
+ impactedFilesCount: Int!
+ indirectChangedFilesCount: Int!
+ patchTotals: CoverageTotals
+ directChangedFilesCount: Int!
+ baseTotals: CoverageTotals
+ headTotals: CoverageTotals
+ changeCoverage: Float
+ flagComparisons(filters: FlagComparisonFilters): [FlagComparison]
+ componentComparisons(filters: ComponentsFilters): [ComponentComparison!]
+ hasDifferentNumberOfHeadAndBaseReports: Boolean!
+ flagComparisonsCount: Int!
+ componentComparisonsCount: Int!
+}
+
+type MissingBaseCommit implements ResolverError {
+ message: String!
+}
+
+type MissingHeadCommit implements ResolverError {
+ message: String!
+}
+
+type MissingComparison implements ResolverError {
+ message: String!
+}
+
+type MissingBaseReport implements ResolverError {
+ message: String!
+}
+
+type MissingHeadReport implements ResolverError {
+ message: String!
+}
+
+type FirstPullRequest {
+ message: String!
+}
+
+union ComparisonResult =
+ Comparison
+ | FirstPullRequest
+ | MissingBaseCommit
+ | MissingHeadCommit
+ | MissingComparison
+ | MissingBaseReport
+ | MissingHeadReport
diff --git a/apps/codecov-api/graphql_api/types/comparison/comparison.py b/apps/codecov-api/graphql_api/types/comparison/comparison.py
new file mode 100644
index 0000000000..19e6651fd3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/comparison/comparison.py
@@ -0,0 +1,277 @@
+from asyncio import gather
+from typing import List, Optional
+
+import sentry_sdk
+from ariadne import ObjectType, UnionType
+from asgiref.sync import sync_to_async
+from graphql.type.definition import GraphQLResolveInfo
+
+import services.components as components_service
+from compare.commands.compare.compare import CompareCommands
+from compare.models import ComponentComparison, FlagComparison
+from graphql_api.actions.flags import get_flag_comparisons
+from graphql_api.dataloader.commit import CommitLoader
+from graphql_api.types.errors import (
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingComparison,
+ MissingHeadCommit,
+ MissingHeadReport,
+)
+from graphql_api.types.errors.errors import UnknownFlags
+from reports.models import ReportLevelTotals
+from services.comparison import (
+ Comparison,
+ ComparisonReport,
+ FirstPullRequest,
+ ImpactedFile,
+)
+
+comparison_bindable = ObjectType("Comparison")
+
+
+@comparison_bindable.field("state")
+def resolve_state(comparison: ComparisonReport, info: GraphQLResolveInfo) -> str:
+ return comparison.commit_comparison.state
+
+
+@comparison_bindable.field("impactedFiles")
+@sync_to_async
+def resolve_impacted_files(
+ comparison_report: ComparisonReport, info: GraphQLResolveInfo, filters=None
+):
+ command: CompareCommands = info.context["executor"].get_command("compare")
+ comparison: Comparison = info.context.get("comparison", None)
+
+ if filters and comparison:
+ flags = filters.get("flags", [])
+ if flags and set(flags).isdisjoint(
+ set(comparison.head_report.get_flag_names())
+ ):
+ return UnknownFlags()
+
+ return {
+ "results": command.fetch_impacted_files(comparison_report, comparison, filters)
+ }
+
+
+@comparison_bindable.field("impactedFilesCount")
+@sync_to_async
+def resolve_impacted_files_count(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> int:
+ return len(comparison.impacted_files)
+
+
+@comparison_bindable.field("directChangedFilesCount")
+@sync_to_async
+def resolve_direct_changed_files_count(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> int:
+ return len(comparison.impacted_files_with_direct_changes)
+
+
+@comparison_bindable.field("indirectChangedFilesCount")
+@sync_to_async
+def resolve_indirect_changed_files_count(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> int:
+ return len(comparison.impacted_files_with_unintended_changes)
+
+
+@comparison_bindable.field("impactedFile")
+@sync_to_async
+def resolve_impacted_file(
+ comparison: ComparisonReport, info: GraphQLResolveInfo, path
+) -> ImpactedFile:
+ return comparison.impacted_file(path)
+
+
+# TODO: rename `changeCoverage`
+@comparison_bindable.field("changeCoverage")
+async def resolve_change_coverage(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> Optional[float]:
+ repository_id = comparison.commit_comparison.compare_commit.repository_id
+ loader = CommitLoader.loader(info, repository_id)
+
+ # the loader prefetches everything we need to get the totals
+ base_commit, head_commit = await gather(
+ loader.load(comparison.commit_comparison.base_commit.commitid),
+ loader.load(comparison.commit_comparison.compare_commit.commitid),
+ )
+
+ base_totals = None
+ head_totals = None
+ if (
+ base_commit
+ and base_commit.commitreport
+ and hasattr(base_commit.commitreport, "reportleveltotals")
+ ):
+ base_totals = base_commit.commitreport.reportleveltotals
+ if (
+ head_commit
+ and head_commit.commitreport
+ and hasattr(head_commit.commitreport, "reportleveltotals")
+ ):
+ head_totals = head_commit.commitreport.reportleveltotals
+
+ if base_totals and head_totals:
+ return head_totals.coverage - base_totals.coverage
+
+
+@comparison_bindable.field("baseTotals")
+async def resolve_base_totals(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> Optional[ReportLevelTotals]:
+ repository_id = comparison.commit_comparison.base_commit.repository_id
+ loader = CommitLoader.loader(info, repository_id)
+
+ # the loader prefetches everything we need to get the totals
+ base_commit = await loader.load(comparison.commit_comparison.base_commit.commitid)
+ if (
+ base_commit
+ and base_commit.commitreport
+ and hasattr(base_commit.commitreport, "reportleveltotals")
+ ):
+ return base_commit.commitreport.reportleveltotals
+
+
+@comparison_bindable.field("headTotals")
+async def resolve_head_totals(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> Optional[ReportLevelTotals]:
+ repository_id = comparison.commit_comparison.compare_commit.repository_id
+ loader = CommitLoader.loader(info, repository_id)
+
+ # the loader prefetches everything we need to get the totals
+ head_commit = await loader.load(
+ comparison.commit_comparison.compare_commit.commitid
+ )
+ if (
+ head_commit
+ and head_commit.commitreport
+ and hasattr(head_commit.commitreport, "reportleveltotals")
+ ):
+ return head_commit.commitreport.reportleveltotals
+
+
+@comparison_bindable.field("patchTotals")
+def resolve_patch_totals(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> dict | None:
+ totals = comparison.commit_comparison.patch_totals
+ if not totals:
+ return None
+
+ coverage = totals["coverage"]
+ if coverage is not None:
+ # we always return `coverage` as a percentage but it's stored
+ # in the database as 0 <= value <= 1
+ coverage *= 100
+
+ return {**totals, "coverage": coverage}
+
+
+@comparison_bindable.field("flagComparisons")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_flag_comparisons(
+ comparison: ComparisonReport, info: GraphQLResolveInfo, filters=None
+) -> List[FlagComparison]:
+ all_flags = get_flag_comparisons(comparison.commit_comparison)
+
+ if filters and filters.get("term"):
+ filtered_flags = [
+ flag
+ for flag in all_flags
+ if filters["term"] in flag.repositoryflag.flag_name
+ ]
+ return filtered_flags
+
+ return list(all_flags)
+
+
+@comparison_bindable.field("componentComparisons")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_component_comparisons(
+ comparison_report: ComparisonReport, info: GraphQLResolveInfo, filters=None
+) -> List[ComponentComparison]:
+ current_owner = info.context["request"].current_owner
+ head_commit = comparison_report.commit_comparison.compare_commit
+ components = components_service.commit_components(head_commit, current_owner)
+ list_components = comparison_report.commit_comparison.component_comparisons.all()
+
+ if filters and filters.get("components"):
+ components = components_service.filter_components_by_name_or_id(
+ components, filters["components"]
+ )
+
+ list_components = list_components.filter(
+ component_id__in=[component.component_id for component in components]
+ )
+
+ # store for child resolvers (needed to get the component name, for example)
+ info.context["components"] = {
+ component.component_id: component for component in components
+ }
+
+ return list(list_components)
+
+
+@comparison_bindable.field("componentComparisonsCount")
+@sync_to_async
+def resolve_component_comparisons_count(
+ comparison_report: ComparisonReport, info: GraphQLResolveInfo
+) -> int:
+ return comparison_report.commit_comparison.component_comparisons.count()
+
+
+@comparison_bindable.field("flagComparisonsCount")
+@sync_to_async
+def resolve_flag_comparisons_count(
+ comparison: ComparisonReport, info: GraphQLResolveInfo
+) -> int:
+ """
+ Resolver to return if the head and base of a pull request have
+ different number of reports on the head and base. This implementation
+ excludes commits that have carried forward sessions.
+ """
+ return get_flag_comparisons(comparison.commit_comparison).count()
+
+
+@comparison_bindable.field("hasDifferentNumberOfHeadAndBaseReports")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_has_different_number_of_head_and_base_reports(
+ comparison: ComparisonReport,
+ info: GraphQLResolveInfo,
+ **kwargs, # type: ignore
+) -> bool:
+ # TODO: can we remove the need for `info.context["comparison"]` here?
+ if "comparison" not in info.context:
+ return False
+ comparison: Comparison = info.context["comparison"]
+ return comparison.has_different_number_of_head_and_base_sessions
+
+
+comparison_result_bindable = UnionType("ComparisonResult")
+
+
+@comparison_result_bindable.type_resolver
+def resolve_comparison_result_type(obj, *_):
+ if isinstance(obj, ComparisonReport):
+ return "Comparison"
+ elif isinstance(obj, MissingBaseCommit):
+ return "MissingBaseCommit"
+ elif isinstance(obj, MissingHeadCommit):
+ return "MissingHeadCommit"
+ elif isinstance(obj, MissingComparison):
+ return "MissingComparison"
+ elif isinstance(obj, MissingBaseReport):
+ return "MissingBaseReport"
+ elif isinstance(obj, MissingHeadReport):
+ return "MissingHeadReport"
+ elif isinstance(obj, FirstPullRequest):
+ return "FirstPullRequest"
diff --git a/apps/codecov-api/graphql_api/types/component/__init__.py b/apps/codecov-api/graphql_api/types/component/__init__.py
new file mode 100644
index 0000000000..bd81cc7112
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component/__init__.py
@@ -0,0 +1,7 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .component import component_bindable
+
+component = ariadne_load_local_graphql(__file__, "component.graphql")
+
+__all__ = ["component_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/component/component.graphql b/apps/codecov-api/graphql_api/types/component/component.graphql
new file mode 100644
index 0000000000..fa2398d016
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component/component.graphql
@@ -0,0 +1,19 @@
+type Component {
+ id: String!
+ name: String!
+ totals: CoverageTotals
+}
+
+type ComponentMeasurements {
+ componentId: String!
+ name: String!
+ percentCovered: Float
+ percentChange: Float
+ measurements: [Measurement!]!
+ lastUploaded: DateTime
+}
+
+type ComponentsYaml {
+ id: String!
+ name: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/component/component.py b/apps/codecov-api/graphql_api/types/component/component.py
new file mode 100644
index 0000000000..862751d68b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component/component.py
@@ -0,0 +1,29 @@
+from typing import Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from shared.reports.types import ReportTotals
+
+from core.models import Commit
+from services.components import Component, component_filtered_report
+
+component_bindable = ObjectType("Component")
+
+
+@component_bindable.field("id")
+def resolve_id(component: Component, info) -> str:
+ return component.component_id
+
+
+@component_bindable.field("name")
+def resolve_name(component: Component, info) -> str:
+ return component.get_display_name()
+
+
+@component_bindable.field("totals")
+@sync_to_async
+def resolve_totals(component: Component, info) -> Optional[ReportTotals]:
+ commit: Commit = info.context["component_commit"]
+ report = commit.full_report
+ filtered_report = component_filtered_report(report, [component])
+ return filtered_report.totals
diff --git a/apps/codecov-api/graphql_api/types/component_comparison/__init__.py b/apps/codecov-api/graphql_api/types/component_comparison/__init__.py
new file mode 100644
index 0000000000..43598a549f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component_comparison/__init__.py
@@ -0,0 +1,10 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .component_comparison import component_comparison_bindable
+
+component_comparison = ariadne_load_local_graphql(
+ __file__, "component_comparison.graphql"
+)
+
+
+__all__ = ["component_comparison_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.graphql b/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.graphql
new file mode 100644
index 0000000000..2a845288ee
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.graphql
@@ -0,0 +1,7 @@
+type ComponentComparison {
+ id: String!
+ name: String!
+ baseTotals: CoverageTotals
+ headTotals: CoverageTotals
+ patchTotals: CoverageTotals
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.py b/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.py
new file mode 100644
index 0000000000..5271bda950
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/component_comparison/component_comparison.py
@@ -0,0 +1,47 @@
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from shared.reports.types import ReportTotals
+
+from compare.models import ComponentComparison
+from services.components import Component
+
+component_comparison_bindable = ObjectType("ComponentComparison")
+
+
+@component_comparison_bindable.field("id")
+def resolve_id(component_comparison: ComponentComparison, info) -> str:
+ return component_comparison.component_id
+
+
+@component_comparison_bindable.field("name")
+def resolve_name(component_comparison: ComponentComparison, info) -> str:
+ components: dict[str, Component] = info.context["components"]
+ component = components.get(component_comparison.component_id)
+ if component:
+ return component.get_display_name()
+ else:
+ # not sure when we would ever get here
+ # (yaml components out-of-sync with database for some reason)
+ return component_comparison.component_id
+
+
+@component_comparison_bindable.field("baseTotals")
+def resolve_base_totals(
+ component_comparison: ComponentComparison, info
+) -> ReportTotals:
+ return component_comparison.base_totals
+
+
+@component_comparison_bindable.field("headTotals")
+def resolve_head_totals(
+ component_comparison: ComponentComparison, info
+) -> ReportTotals:
+ return component_comparison.head_totals
+
+
+@component_comparison_bindable.field("patchTotals")
+@sync_to_async
+def resolve_patch_totals(
+ component_comparison: ComponentComparison, info
+) -> ReportTotals:
+ return component_comparison.patch_totals
diff --git a/apps/codecov-api/graphql_api/types/config/__init__.py b/apps/codecov-api/graphql_api/types/config/__init__.py
new file mode 100644
index 0000000000..9efff1aca2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/config/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .config import config_bindable
+
+config = ariadne_load_local_graphql(__file__, "config.graphql")
+
+
+__all__ = ["config_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/config/config.graphql b/apps/codecov-api/graphql_api/types/config/config.graphql
new file mode 100644
index 0000000000..e4d393f33b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/config/config.graphql
@@ -0,0 +1,13 @@
+type Config {
+ loginProviders: [LoginProvider!]!
+ planAutoActivate: Boolean
+ seatsUsed: Int
+ seatsLimit: Int
+ isTimescaleEnabled: Boolean!
+ hasAdmins: Boolean
+ githubEnterpriseURL: String
+ gitlabEnterpriseURL: String
+ bitbucketServerURL: String
+ selfHostedLicense: SelfHostedLicense
+ syncProviders: [SyncProvider!]!
+}
diff --git a/apps/codecov-api/graphql_api/types/config/config.py b/apps/codecov-api/graphql_api/types/config/config.py
new file mode 100644
index 0000000000..32d97a4df8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/config/config.py
@@ -0,0 +1,149 @@
+from typing import List, Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from graphql.type.definition import GraphQLResolveInfo
+
+import services.self_hosted as self_hosted
+from graphql_api.types.enums.enums import LoginProvider, SyncProvider
+from utils import strtobool
+
+config_bindable = ObjectType("Config")
+
+
+@config_bindable.field("loginProviders")
+def resolve_login_providers(_, info) -> List[str]:
+ login_providers = []
+
+ if not settings.DISABLE_GIT_BASED_LOGIN:
+ if settings.GITHUB_CLIENT_ID:
+ login_providers.append(LoginProvider("github"))
+
+ if settings.GITHUB_ENTERPRISE_CLIENT_ID:
+ login_providers.append(LoginProvider("github_enterprise"))
+
+ if settings.GITLAB_CLIENT_ID:
+ login_providers.append(LoginProvider("gitlab"))
+
+ if settings.GITLAB_ENTERPRISE_CLIENT_ID:
+ login_providers.append(LoginProvider("gitlab_enterprise"))
+
+ if settings.BITBUCKET_CLIENT_ID:
+ login_providers.append(LoginProvider("bitbucket"))
+
+ if settings.BITBUCKET_SERVER_CLIENT_ID:
+ login_providers.append(LoginProvider("bitbucket_server"))
+
+ if settings.OKTA_OAUTH_CLIENT_ID:
+ login_providers.append(LoginProvider("okta"))
+
+ return login_providers
+
+
+@config_bindable.field("syncProviders")
+def resolve_sync_providers(_, info) -> List[str]:
+ sync_providers = []
+
+ if settings.GITHUB_CLIENT_ID:
+ sync_providers.append(SyncProvider("github"))
+
+ if settings.GITHUB_ENTERPRISE_CLIENT_ID:
+ sync_providers.append(SyncProvider("github_enterprise"))
+
+ if settings.GITLAB_CLIENT_ID:
+ sync_providers.append(SyncProvider("gitlab"))
+
+ if settings.GITLAB_ENTERPRISE_CLIENT_ID:
+ sync_providers.append(SyncProvider("gitlab_enterprise"))
+
+ if settings.BITBUCKET_CLIENT_ID:
+ sync_providers.append(SyncProvider("bitbucket"))
+
+ if settings.BITBUCKET_SERVER_CLIENT_ID:
+ sync_providers.append(SyncProvider("bitbucket_server"))
+
+ return sync_providers
+
+
+@config_bindable.field("planAutoActivate")
+@sync_to_async
+def resolve_plan_auto_activate(_, info: GraphQLResolveInfo) -> Optional[bool]:
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ return self_hosted.is_autoactivation_enabled()
+
+
+@config_bindable.field("seatsUsed")
+@sync_to_async
+def resolve_seats_used(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ return self_hosted.activated_owners().count()
+
+
+@config_bindable.field("seatsLimit")
+@sync_to_async
+def resolve_seats_limit(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ return self_hosted.license_seats()
+
+
+@config_bindable.field("isTimescaleEnabled")
+@sync_to_async
+def resolve_is_timescale_enabled(_, info):
+ if isinstance(settings.TIMESERIES_ENABLED, str):
+ return bool(strtobool(settings.TIMESERIES_ENABLED))
+
+ return settings.TIMESERIES_ENABLED
+
+
+@config_bindable.field("selfHostedLicense")
+def resolve_self_hosted_license(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+ license = self_hosted.get_current_license()
+
+ if not license.is_valid:
+ return None
+
+ return license
+
+
+@config_bindable.field("hasAdmins")
+def resolve_has_admins(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ return len(settings.ADMINS_LIST) != 0
+
+
+@config_bindable.field("githubEnterpriseURL")
+def resolve_github_enterprise_url(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ if settings.GITHUB_ENTERPRISE_CLIENT_ID:
+ return settings.GITHUB_ENTERPRISE_URL
+
+
+@config_bindable.field("gitlabEnterpriseURL")
+def resolve_gitlab_enterprise_url(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ if settings.GITLAB_ENTERPRISE_CLIENT_ID:
+ return settings.GITLAB_ENTERPRISE_URL
+
+
+@config_bindable.field("bitbucketServerURL")
+def resolve_bitbucket_server_url(_, info):
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ if settings.BITBUCKET_SERVER_CLIENT_ID:
+ return settings.BITBUCKET_SERVER_URL
diff --git a/apps/codecov-api/graphql_api/types/coverage_analytics/__init__.py b/apps/codecov-api/graphql_api/types/coverage_analytics/__init__.py
new file mode 100644
index 0000000000..0664a6415b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_analytics/__init__.py
@@ -0,0 +1,13 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .coverage_analytics import (
+ coverage_analytics_bindable,
+ coverage_analytics_result_bindable,
+)
+
+coverage_analytics = ariadne_load_local_graphql(__file__, "coverage_analytics.graphql")
+
+__all__ = [
+ "coverage_analytics_bindable",
+ "coverage_analytics_result_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.graphql b/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.graphql
new file mode 100644
index 0000000000..1f0cff6268
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.graphql
@@ -0,0 +1,62 @@
+"""
+CoverageAnalytics is information related to a repo's test coverage
+"""
+type CoverageAnalytics {
+ "Hits is the number of hits in the latest commit's coverage report"
+ hits: Int # formerly repository.hits
+ "Misses is the number of misses in the latest commit's coverage report"
+ misses: Int # formerly repository.misses
+ "Lines is the number of lines in the latest commit's coverage report"
+ lines: Int # formerly repository.lines
+ "Commit sha is the sha hash of the commit in the latest commit's coverage report"
+ commitSha: String # formerly repository.coverageSha
+ "PercentCovered is percent of lines covered (e.g., 87.25)"
+ percentCovered: Float # formerly repository.coverage
+
+ "Measurements are points in the time series for coverage over time"
+ measurements(
+ interval: MeasurementInterval!
+ after: DateTime
+ before: DateTime
+ branch: String
+ ): [Measurement!]! # formerly repository.measurements
+
+ ## Flags ##
+ "Flags are the measurements by flag for this repository"
+ flags(
+ filters: FlagSetFilters
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): FlagConnection! @cost(complexity: 3, multipliers: ["first", "last"])
+ "FlagsCount are how many flags for the given repo"
+ flagsCount: Int!
+ "FlagsMeasurementsActive is whether the flag measurements are currently getting populated"
+ flagsMeasurementsActive: Boolean!
+ "FlagsMeasurementsBackfilled is whether the flag data has been backfilled"
+ flagsMeasurementsBackfilled: Boolean!
+
+ ## Components ##
+ "Components are the measurements by component for this repository"
+ components(
+ interval: MeasurementInterval!
+ before: DateTime!
+ after: DateTime!
+ branch: String
+ filters: ComponentMeasurementsSetFilters
+ orderingDirection: OrderingDirection
+ ): [ComponentMeasurements!]!
+ "ComponentsCount is how many components are configured for the given repo"
+ componentsCount: Int!
+ "ComponentsMeasurementsActive is whether the components measurements are currently getting populated"
+ componentsMeasurementsActive: Boolean!
+ "ComponentsMeasurementsBackfilled is whether the components data has been backfilled"
+ componentsMeasurementsBackfilled: Boolean!
+ "ComponentsYaml is the information related to the configuration yaml for Components"
+ componentsYaml(termId: String): [ComponentsYaml]!
+}
+
+"CoverageAnalyticsResult is CoverageAnalytics or potential error(s)"
+union CoverageAnalyticsResult = CoverageAnalytics | NotFoundError
diff --git a/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.py b/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.py
new file mode 100644
index 0000000000..9285f52ef5
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_analytics/coverage_analytics.py
@@ -0,0 +1,342 @@
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Iterable, List, Mapping, Optional, Union
+
+import sentry_sdk
+from ariadne import ObjectType, UnionType
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from django.forms.utils import from_current_timezone
+from graphql.type.definition import GraphQLResolveInfo
+from shared.yaml import UserYaml
+
+import timeseries.helpers as timeseries_helpers
+from core.models import Repository
+from graphql_api.actions.components import (
+ component_measurements,
+ component_measurements_last_uploaded,
+)
+from graphql_api.actions.flags import flag_measurements, flags_for_repo
+from graphql_api.helpers.connection import (
+ queryset_to_connection_sync,
+)
+from graphql_api.helpers.lookahead import lookahead
+from graphql_api.types.enums import OrderingDirection
+from graphql_api.types.errors.errors import NotFoundError
+from services.components import ComponentMeasurements
+from timeseries.helpers import fill_sparse_measurements
+from timeseries.models import Dataset, Interval, MeasurementName, MeasurementSummary
+
+log = logging.getLogger(__name__)
+
+# Bindings for GraphQL types
+coverage_analytics_bindable: ObjectType = ObjectType("CoverageAnalytics")
+coverage_analytics_result_bindable: UnionType = UnionType("CoverageAnalyticsResult")
+
+
+# CoverageAnalyticsProps is information passed from parent resolver (repository)
+# to the coverage analytics resolver
+@dataclass
+class CoverageAnalyticsProps:
+ repository: Repository
+
+
+@coverage_analytics_result_bindable.type_resolver
+def resolve_coverage_analytics_result_type(
+ obj: Union[CoverageAnalyticsProps, NotFoundError], *_: Any
+) -> Optional[str]:
+ if isinstance(obj, CoverageAnalyticsProps):
+ return "CoverageAnalyticsProps"
+ elif isinstance(obj, NotFoundError):
+ return "NotFoundError"
+ return None
+
+
+@coverage_analytics_bindable.field("percentCovered")
+def resolve_percent_covered(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> Optional[float]:
+ return parent.repository.recent_coverage if parent else None
+
+
+@coverage_analytics_bindable.field("commitSha")
+def resolve_commit_sha(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> Optional[str]:
+ return parent.repository.coverage_sha if parent else None
+
+
+@coverage_analytics_bindable.field("hits")
+def resolve_hits(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> Optional[int]:
+ return parent.repository.hits if parent else None
+
+
+@coverage_analytics_bindable.field("misses")
+def resolve_misses(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> Optional[int]:
+ return parent.repository.misses if parent else None
+
+
+@coverage_analytics_bindable.field("lines")
+def resolve_lines(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> Optional[int]:
+ return parent.repository.lines if parent else None
+
+
+@coverage_analytics_bindable.field("measurements")
+async def resolve_measurements(
+ parent: CoverageAnalyticsProps,
+ info: GraphQLResolveInfo,
+ interval: Interval,
+ before: Optional[datetime] = None,
+ after: Optional[datetime] = None,
+ branch: Optional[str] = None,
+) -> Iterable[MeasurementSummary]:
+ coverage_data = await sync_to_async(
+ timeseries_helpers.repository_coverage_measurements_with_fallback
+ )(
+ parent.repository,
+ interval,
+ start_date=after,
+ end_date=before,
+ branch=branch,
+ )
+
+ measurements = await sync_to_async(fill_sparse_measurements)(
+ coverage_data,
+ interval,
+ start_date=after,
+ end_date=before,
+ )
+
+ return measurements
+
+
+@coverage_analytics_bindable.field("components")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_components_measurements(
+ parent: CoverageAnalyticsProps,
+ info: GraphQLResolveInfo,
+ interval: Interval,
+ before: datetime,
+ after: datetime,
+ branch: Optional[str] = None,
+ filters: Optional[Mapping] = None,
+ ordering_direction: Optional[OrderingDirection] = OrderingDirection.ASC,
+):
+ components = UserYaml.get_final_yaml(
+ owner_yaml=parent.repository.author.yaml,
+ repo_yaml=parent.repository.yaml,
+ ownerid=parent.repository.author.ownerid,
+ ).get_components()
+
+ if not settings.TIMESERIES_ENABLED or not components:
+ return []
+
+ if filters and "components" in filters:
+ components = [c for c in components if c.component_id in filters["components"]]
+
+ component_ids = [c.component_id for c in components]
+ all_measurements = component_measurements(
+ parent.repository, component_ids, interval, after, before, branch
+ )
+
+ last_measurements = component_measurements_last_uploaded(
+ owner_id=parent.repository.author.ownerid,
+ repo_id=parent.repository.repoid,
+ measurable_ids=component_ids,
+ branch=branch,
+ )
+ last_measurements_mapping = {
+ row["measurable_id"]: row["last_uploaded"] for row in last_measurements
+ }
+
+ components_mapping = {
+ component.component_id: component.name for component in components
+ }
+
+ queried_measurements = [
+ ComponentMeasurements(
+ raw_measurements=all_measurements.get(component_id, []),
+ component_id=component_id,
+ interval=interval,
+ after=after,
+ before=before,
+ last_measurement=last_measurements_mapping.get(component_id),
+ components_mapping=components_mapping,
+ )
+ for component_id in component_ids
+ ]
+
+ return sorted(
+ queried_measurements,
+ key=lambda c: c.name,
+ reverse=ordering_direction == OrderingDirection.DESC,
+ )
+
+
+@coverage_analytics_bindable.field("componentsYaml")
+def resolve_components_yaml(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo, term_id: Optional[str]
+) -> List[str]:
+ components = UserYaml.get_final_yaml(
+ owner_yaml=parent.repository.author.yaml,
+ repo_yaml=parent.repository.yaml,
+ ownerid=parent.repository.author.ownerid,
+ ).get_components()
+
+ components = [
+ {
+ "id": c.component_id,
+ "name": c.name,
+ }
+ for c in components
+ ]
+
+ if term_id:
+ components = filter(lambda c: term_id in c["id"], components)
+
+ return components
+
+
+@coverage_analytics_bindable.field("componentsMeasurementsActive")
+@sync_to_async
+def resolve_components_measurements_active(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> bool:
+ if not settings.TIMESERIES_ENABLED:
+ return False
+
+ return Dataset.objects.filter(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=parent.repository.pk,
+ ).exists()
+
+
+@coverage_analytics_bindable.field("componentsMeasurementsBackfilled")
+@sync_to_async
+def resolve_components_measurements_backfilled(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> bool:
+ if not settings.TIMESERIES_ENABLED:
+ return False
+
+ dataset = Dataset.objects.filter(
+ name=MeasurementName.COMPONENT_COVERAGE.value,
+ repository_id=parent.repository.pk,
+ ).first()
+
+ if not dataset:
+ return False
+
+ return dataset.is_backfilled()
+
+
+@coverage_analytics_bindable.field("componentsCount")
+@sync_to_async
+def resolve_components_count(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> int:
+ repo_yaml_components = UserYaml.get_final_yaml(
+ owner_yaml=parent.repository.author.yaml,
+ repo_yaml=parent.repository.yaml,
+ ownerid=parent.repository.author.ownerid,
+ ).get_components()
+
+ return len(repo_yaml_components)
+
+
+@coverage_analytics_bindable.field("flags")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_flags(
+ parent: CoverageAnalyticsProps,
+ info: GraphQLResolveInfo,
+ filters: Mapping = None,
+ ordering_direction: OrderingDirection = OrderingDirection.ASC,
+ **kwargs,
+):
+ queryset = flags_for_repo(parent.repository, filters)
+ connection = queryset_to_connection_sync(
+ queryset,
+ ordering=("flag_name",),
+ ordering_direction=ordering_direction,
+ **kwargs,
+ )
+
+ # We fetch the measurements in this resolver since there are multiple child
+ # flag resolvers that depend on this data. Additionally, we're able to fetch
+ # measurements for all the flags being returned at once.
+ # Use the lookahead to make sure we don't overfetch measurements that we don't
+ # need.
+ node = lookahead(info, ("edges", "node", "measurements"))
+ if node:
+ if settings.TIMESERIES_ENABLED:
+ # TODO: is there a way to have these automatically casted at a
+ # lower level (i.e. based on the schema)?
+ interval = node.args["interval"]
+ if isinstance(interval, str):
+ interval = Interval[interval]
+ after = node.args["after"]
+ if isinstance(after, str):
+ after = from_current_timezone(datetime.fromisoformat(after))
+ before = node.args["before"]
+ if isinstance(before, str):
+ before = from_current_timezone(datetime.fromisoformat(before))
+
+ flag_ids = [edge["node"].pk for edge in connection.edges]
+
+ info.context["flag_measurements"] = flag_measurements(
+ parent.repository, flag_ids, interval, after, before
+ )
+ else:
+ info.context["flag_measurements"] = {}
+
+ return connection
+
+
+@coverage_analytics_bindable.field("flagsCount")
+@sync_to_async
+def resolve_flags_count(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> int:
+ return parent.repository.flags.filter(deleted__isnot=True).count()
+
+
+@coverage_analytics_bindable.field("flagsMeasurementsActive")
+@sync_to_async
+def resolve_flags_measurements_active(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> bool:
+ if not settings.TIMESERIES_ENABLED:
+ return False
+
+ return Dataset.objects.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=parent.repository.pk,
+ ).exists()
+
+
+@coverage_analytics_bindable.field("flagsMeasurementsBackfilled")
+@sync_to_async
+def resolve_flags_measurements_backfilled(
+ parent: CoverageAnalyticsProps, info: GraphQLResolveInfo
+) -> bool:
+ if not settings.TIMESERIES_ENABLED:
+ return False
+
+ dataset = Dataset.objects.filter(
+ name=MeasurementName.FLAG_COVERAGE.value,
+ repository_id=parent.repository.pk,
+ ).first()
+
+ if not dataset:
+ return False
+
+ return dataset.is_backfilled()
diff --git a/apps/codecov-api/graphql_api/types/coverage_totals/__init__.py b/apps/codecov-api/graphql_api/types/coverage_totals/__init__.py
new file mode 100644
index 0000000000..4b7d597f06
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_totals/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .coverage_totals import coverage_totals_bindable
+
+coverage_totals = ariadne_load_local_graphql(__file__, "coverage_totals.graphql")
+
+
+__all__ = ["coverage_totals_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.graphql b/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.graphql
new file mode 100644
index 0000000000..cd75052a93
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.graphql
@@ -0,0 +1,11 @@
+
+type CoverageTotals {
+ percentCovered: Float
+ fileCount: Int
+ lineCount: Int
+ hitsCount: Int
+ missesCount: Int
+ partialsCount: Int
+
+ coverage: Float @deprecated(reason: "Use `percentCovered`")
+}
diff --git a/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.py b/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.py
new file mode 100644
index 0000000000..f437a293c2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/coverage_totals/coverage_totals.py
@@ -0,0 +1,10 @@
+from ariadne import ObjectType
+
+coverage_totals_bindable = ObjectType("CoverageTotals")
+
+coverage_totals_bindable.set_alias("percentCovered", "coverage")
+coverage_totals_bindable.set_alias("fileCount", "files")
+coverage_totals_bindable.set_alias("lineCount", "lines")
+coverage_totals_bindable.set_alias("hitsCount", "hits")
+coverage_totals_bindable.set_alias("missesCount", "misses")
+coverage_totals_bindable.set_alias("partialsCount", "partials")
diff --git a/apps/codecov-api/graphql_api/types/enums/__init__.py b/apps/codecov-api/graphql_api/types/enums/__init__.py
new file mode 100644
index 0000000000..3ad148f7ef
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/__init__.py
@@ -0,0 +1,45 @@
+from .enums import (
+ AssetOrdering,
+ BundleLoadTypes,
+ CommitErrorCode,
+ CommitErrorGeneralType,
+ CommitStatus,
+ CoverageLine,
+ GoalOnboarding,
+ LoginProvider,
+ OrderingDirection,
+ OrderingParameter,
+ PathContentDisplayType,
+ PullRequestState,
+ RepositoryOrdering,
+ SyncProvider,
+ TestResultsFilterParameter,
+ TestResultsOrderingParameter,
+ TypeProjectOnboarding,
+ UploadErrorEnum,
+ UploadState,
+ UploadType,
+)
+
+__all__ = [
+ "AssetOrdering",
+ "BundleLoadTypes",
+ "CommitErrorCode",
+ "CommitErrorGeneralType",
+ "CommitStatus",
+ "CoverageLine",
+ "GoalOnboarding",
+ "LoginProvider",
+ "OrderingDirection",
+ "OrderingParameter",
+ "PathContentDisplayType",
+ "PullRequestState",
+ "RepositoryOrdering",
+ "SyncProvider",
+ "TestResultsFilterParameter",
+ "TestResultsOrderingParameter",
+ "TypeProjectOnboarding",
+ "UploadErrorEnum",
+ "UploadState",
+ "UploadType",
+]
diff --git a/apps/codecov-api/graphql_api/types/enums/asset_ordering.graphql b/apps/codecov-api/graphql_api/types/enums/asset_ordering.graphql
new file mode 100644
index 0000000000..fb11939dbf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/asset_ordering.graphql
@@ -0,0 +1,5 @@
+enum AssetOrdering {
+ NAME
+ SIZE
+ TYPE
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/commit_error_code.graphql b/apps/codecov-api/graphql_api/types/enums/commit_error_code.graphql
new file mode 100644
index 0000000000..9c7c89ea1f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/commit_error_code.graphql
@@ -0,0 +1,6 @@
+enum CommitErrorCode {
+ repo_bot_invalid
+ invalid_yaml
+ yaml_client_error
+ yaml_unknown_error
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/commit_error_type.graphql b/apps/codecov-api/graphql_api/types/enums/commit_error_type.graphql
new file mode 100644
index 0000000000..a9fe85a97f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/commit_error_type.graphql
@@ -0,0 +1,4 @@
+enum CommitErrorType {
+ YAML_ERROR
+ BOT_ERROR
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/commit_state.graphql b/apps/codecov-api/graphql_api/types/enums/commit_state.graphql
new file mode 100644
index 0000000000..9e5e616c5d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/commit_state.graphql
@@ -0,0 +1,6 @@
+enum CommitState {
+ COMPLETE
+ PENDING
+ ERROR
+ SKIPPED
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/commit_status.graphql b/apps/codecov-api/graphql_api/types/enums/commit_status.graphql
new file mode 100644
index 0000000000..7d060c42f6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/commit_status.graphql
@@ -0,0 +1,5 @@
+enum CommitStatus {
+ COMPLETED
+ ERROR
+ PENDING
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/coverage_line.graphql b/apps/codecov-api/graphql_api/types/enums/coverage_line.graphql
new file mode 100644
index 0000000000..5b6eefbd57
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/coverage_line.graphql
@@ -0,0 +1,8 @@
+"""
+Possible value for the coverage of a line, using single letter for a more compact response
+"""
+enum CoverageLine {
+ H # hit
+ M # miss
+ P # partial
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/enum_types.py b/apps/codecov-api/graphql_api/types/enums/enum_types.py
new file mode 100644
index 0000000000..a8033b12ba
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/enum_types.py
@@ -0,0 +1,59 @@
+from ariadne import EnumType
+from shared.plan.constants import TierName, TrialStatus
+
+from codecov_auth.models import RepositoryToken
+from compare.commands.compare.interactors.fetch_impacted_files import (
+ ImpactedFileParameter,
+)
+from core.models import Commit
+from services.yaml import YamlStates
+from timeseries.models import Interval as MeasurementInterval
+from timeseries.models import MeasurementName
+
+from .enums import (
+ AssetOrdering,
+ BundleLoadTypes,
+ CoverageLine,
+ GoalOnboarding,
+ LoginProvider,
+ OrderingDirection,
+ OrderingParameter,
+ PathContentDisplayType,
+ PullRequestState,
+ RepositoryOrdering,
+ SyncProvider,
+ TestResultsFilterParameter,
+ TestResultsOrderingParameter,
+ TypeProjectOnboarding,
+ UploadErrorEnum,
+ UploadState,
+ UploadType,
+)
+
+enum_types = [
+ EnumType("RepositoryOrdering", RepositoryOrdering),
+ EnumType("OrderingDirection", OrderingDirection),
+ EnumType("CoverageLine", CoverageLine),
+ EnumType("PathContentDisplayType", PathContentDisplayType),
+ EnumType("TypeProjectOnboarding", TypeProjectOnboarding),
+ EnumType("GoalOnboarding", GoalOnboarding),
+ EnumType("OrderingParameter", OrderingParameter),
+ EnumType("PullRequestState", PullRequestState),
+ EnumType("UploadState", UploadState),
+ EnumType("UploadType", UploadType),
+ EnumType("UploadErrorEnum", UploadErrorEnum),
+ EnumType("MeasurementInterval", MeasurementInterval),
+ EnumType("LoginProvider", LoginProvider),
+ EnumType("ImpactedFileParameter", ImpactedFileParameter),
+ EnumType("CommitState", Commit.CommitStates),
+ EnumType("MeasurementType", MeasurementName),
+ EnumType("RepositoryTokenType", RepositoryToken.TokenType),
+ EnumType("SyncProvider", SyncProvider),
+ EnumType("TierName", TierName),
+ EnumType("TrialStatus", TrialStatus),
+ EnumType("YamlStates", YamlStates),
+ EnumType("BundleLoadTypes", BundleLoadTypes),
+ EnumType("TestResultsOrderingParameter", TestResultsOrderingParameter),
+ EnumType("TestResultsFilterParameter", TestResultsFilterParameter),
+ EnumType("AssetOrdering", AssetOrdering),
+]
diff --git a/apps/codecov-api/graphql_api/types/enums/enums.py b/apps/codecov-api/graphql_api/types/enums/enums.py
new file mode 100644
index 0000000000..c143654b2e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/enums.py
@@ -0,0 +1,146 @@
+import enum
+from typing import Self
+
+from shared.upload.constants import UploadErrorCode as SharedUploadErrorCode
+
+
+class OrderingParameter(enum.Enum):
+ NAME = "name"
+ COVERAGE = "coverage"
+ HITS = "hits"
+ MISSES = "misses"
+ PARTIALS = "partials"
+ LINES = "lines"
+
+
+class TestResultsFilterParameter(enum.Enum):
+ FLAKY_TESTS = "flaky_tests"
+ FAILED_TESTS = "failed_tests"
+ SLOWEST_TESTS = "slowest_tests"
+ SKIPPED_TESTS = "skipped_tests"
+
+
+class TestResultsOrderingParameter(enum.Enum):
+ LAST_DURATION = "last_duration"
+ AVG_DURATION = "avg_duration"
+ FAILURE_RATE = "failure_rate"
+ FLAKE_RATE = "flake_rate"
+ COMMITS_WHERE_FAIL = "commits_where_fail"
+ UPDATED_AT = "updated_at"
+
+
+class PathContentDisplayType(enum.Enum):
+ TREE = "tree"
+ LIST = "list"
+
+
+class RepositoryOrdering(enum.Enum):
+ COMMIT_DATE = "latest_commit_at"
+ COVERAGE = "coverage"
+ ID = "repoid"
+ NAME = "name"
+
+
+class OrderingDirection(enum.Enum):
+ ASC = "ascending"
+ DESC = "descending"
+
+
+class CoverageLine(enum.Enum):
+ H = "hit"
+ M = "miss"
+ P = "partial"
+
+
+class TypeProjectOnboarding(enum.Enum):
+ PERSONAL = "PERSONAL"
+ YOUR_ORG = "YOUR_ORG"
+ OPEN_SOURCE = "OPEN_SOURCE"
+ EDUCATIONAL = "EDUCATIONAL"
+
+
+class GoalOnboarding(enum.Enum):
+ STARTING_WITH_TESTS = "STARTING_WITH_TESTS"
+ IMPROVE_COVERAGE = "IMPROVE_COVERAGE"
+ MAINTAIN_COVERAGE = "MAINTAIN_COVERAGE"
+ TEAM_REQUIREMENTS = "TEAM_REQUIREMENTS"
+ OTHER = "OTHER"
+
+
+class PullRequestState(enum.Enum):
+ OPEN = "open"
+ CLOSED = "closed"
+ MERGED = "merged"
+
+
+class UploadState(enum.Enum):
+ STARTED = "started"
+ UPLOADED = "uploaded"
+ PROCESSED = "processed"
+ ERROR = "error"
+ COMPLETE = "complete"
+
+
+class UploadType(enum.Enum):
+ UPLOADED = "uploaded"
+ CARRIEDFORWARD = "carriedforward"
+
+
+UploadErrorEnum = SharedUploadErrorCode
+
+
+class LoginProvider(enum.Enum):
+ GITHUB = "github"
+ GITHUB_ENTERPRISE = "github_enterprise"
+ GITLAB = "gitlab"
+ GITLAB_ENTERPRISE = "gitlab_enterprise"
+ BITBUCKET = "bitbucket"
+ BITBUCKET_SERVER = "bitbucket_server"
+ OKTA = "okta"
+
+
+class SyncProvider(enum.Enum):
+ GITHUB = "github"
+ GITHUB_ENTERPRISE = "github_enterprise"
+ GITLAB = "gitlab"
+ GITLAB_ENTERPRISE = "gitlab_enterprise"
+ BITBUCKET = "bitbucket"
+ BITBUCKET_SERVER = "bitbucket_server"
+
+
+class CommitErrorGeneralType(enum.Enum):
+ yaml_error = "YAML_ERROR"
+ bot_error = "BOT_ERROR"
+
+
+class CommitErrorCode(enum.Enum):
+ invalid_yaml = ("invalid_yaml", CommitErrorGeneralType.yaml_error)
+ yaml_client_error = ("yaml_client_error", CommitErrorGeneralType.yaml_error)
+ yaml_unknown_error = ("yaml_unknown_error", CommitErrorGeneralType.yaml_error)
+ repo_bot_invalid = ("repo_bot_invalid", CommitErrorGeneralType.bot_error)
+
+ def __init__(self, db_string: str, error_type: CommitErrorGeneralType):
+ self.db_string = db_string
+ self.error_type = error_type
+
+ @classmethod
+ def get_codes_from_type(cls, error_type: CommitErrorGeneralType) -> list[Self]:
+ return [item for item in cls if item.error_type == error_type]
+
+
+class CommitStatus(enum.Enum):
+ COMPLETED = "COMPLETED"
+ ERROR = "ERROR"
+ PENDING = "PENDING"
+
+
+class BundleLoadTypes(enum.Enum):
+ ENTRY = "ENTRY"
+ INITIAL = "INITIAL"
+ LAZY = "LAZY"
+
+
+class AssetOrdering(enum.Enum):
+ NAME = "name"
+ SIZE = "size"
+ TYPE = "asset_type"
diff --git a/apps/codecov-api/graphql_api/types/enums/goal_onboarding.graphql b/apps/codecov-api/graphql_api/types/enums/goal_onboarding.graphql
new file mode 100644
index 0000000000..917b3ad215
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/goal_onboarding.graphql
@@ -0,0 +1,7 @@
+enum GoalOnboarding {
+ STARTING_WITH_TESTS
+ IMPROVE_COVERAGE
+ MAINTAIN_COVERAGE
+ TEAM_REQUIREMENTS
+ OTHER
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/impacted_files_parameters.graphql b/apps/codecov-api/graphql_api/types/enums/impacted_files_parameters.graphql
new file mode 100644
index 0000000000..1668c74e3a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/impacted_files_parameters.graphql
@@ -0,0 +1,10 @@
+"""
+Possible value for the impacted file list
+"""
+enum ImpactedFileParameter {
+ FILE_NAME
+ CHANGE_COVERAGE
+ HEAD_COVERAGE
+ MISSES_COUNT
+ PATCH_COVERAGE
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/login_provider.graphql b/apps/codecov-api/graphql_api/types/enums/login_provider.graphql
new file mode 100644
index 0000000000..68768d5508
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/login_provider.graphql
@@ -0,0 +1,9 @@
+enum LoginProvider {
+ GITHUB
+ GITHUB_ENTERPRISE
+ GITLAB
+ GITLAB_ENTERPRISE
+ BITBUCKET
+ BITBUCKET_SERVER
+ OKTA
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/measurement_interval.graphql b/apps/codecov-api/graphql_api/types/enums/measurement_interval.graphql
new file mode 100644
index 0000000000..b900307963
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/measurement_interval.graphql
@@ -0,0 +1,5 @@
+enum MeasurementInterval {
+ INTERVAL_1_DAY
+ INTERVAL_7_DAY
+ INTERVAL_30_DAY
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/measurement_type.graphql b/apps/codecov-api/graphql_api/types/enums/measurement_type.graphql
new file mode 100644
index 0000000000..e4aa7b0ed3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/measurement_type.graphql
@@ -0,0 +1,11 @@
+enum MeasurementType {
+ COVERAGE
+ FLAG_COVERAGE
+ COMPONENT_COVERAGE
+ BUNDLE_ANALYSIS_REPORT_SIZE
+ BUNDLE_ANALYSIS_JAVASCRIPT_SIZE
+ BUNDLE_ANALYSIS_STYLESHEET_SIZE
+ BUNDLE_ANALYSIS_FONT_SIZE
+ BUNDLE_ANALYSIS_IMAGE_SIZE
+ BUNDLE_ANALYSIS_ASSET_SIZE
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/ordering_direction.graphql b/apps/codecov-api/graphql_api/types/enums/ordering_direction.graphql
new file mode 100644
index 0000000000..7a3ab869d0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/ordering_direction.graphql
@@ -0,0 +1,4 @@
+enum OrderingDirection {
+ ASC
+ DESC
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/path_contents_value.graphql b/apps/codecov-api/graphql_api/types/enums/path_contents_value.graphql
new file mode 100644
index 0000000000..45d57c0dcd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/path_contents_value.graphql
@@ -0,0 +1,16 @@
+"""
+Possible value for the search values for a path tree of a pull request
+"""
+enum OrderingParameter{
+ NAME
+ COVERAGE
+ HITS
+ MISSES
+ PARTIALS
+ LINES
+}
+
+enum PathContentDisplayType{
+ LIST
+ TREE
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/pull_request_state.graphql b/apps/codecov-api/graphql_api/types/enums/pull_request_state.graphql
new file mode 100644
index 0000000000..6621136766
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/pull_request_state.graphql
@@ -0,0 +1,8 @@
+"""
+Possible value for the state of a pull request
+"""
+enum PullRequestState{
+ OPEN
+ CLOSED
+ MERGED
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/repository_ordering.graphql b/apps/codecov-api/graphql_api/types/enums/repository_ordering.graphql
new file mode 100644
index 0000000000..34bebbc2a9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/repository_ordering.graphql
@@ -0,0 +1,6 @@
+enum RepositoryOrdering {
+ COMMIT_DATE
+ COVERAGE
+ ID
+ NAME
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/repository_token.graphql b/apps/codecov-api/graphql_api/types/enums/repository_token.graphql
new file mode 100644
index 0000000000..59d93e3494
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/repository_token.graphql
@@ -0,0 +1,5 @@
+enum RepositoryTokenType {
+ UPLOAD
+ PROFILING
+ STATIC_ANALYSIS
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/enums/sync_provider.graphql b/apps/codecov-api/graphql_api/types/enums/sync_provider.graphql
new file mode 100644
index 0000000000..4e65d1732d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/sync_provider.graphql
@@ -0,0 +1,8 @@
+enum SyncProvider {
+ GITHUB
+ GITHUB_ENTERPRISE
+ GITLAB
+ GITLAB_ENTERPRISE
+ BITBUCKET
+ BITBUCKET_SERVER
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/test_results_filtering_parameter.graphql b/apps/codecov-api/graphql_api/types/enums/test_results_filtering_parameter.graphql
new file mode 100644
index 0000000000..9d9207e66d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/test_results_filtering_parameter.graphql
@@ -0,0 +1,6 @@
+enum TestResultsFilterParameter {
+ FLAKY_TESTS,
+ FAILED_TESTS,
+ SLOWEST_TESTS,
+ SKIPPED_TESTS
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/test_results_ordering_parameter.graphql b/apps/codecov-api/graphql_api/types/enums/test_results_ordering_parameter.graphql
new file mode 100644
index 0000000000..d4d7bb9c46
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/test_results_ordering_parameter.graphql
@@ -0,0 +1,8 @@
+enum TestResultsOrderingParameter {
+ LAST_DURATION
+ AVG_DURATION
+ FAILURE_RATE
+ FLAKE_RATE
+ COMMITS_WHERE_FAIL
+ UPDATED_AT
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/tier_name.graphql b/apps/codecov-api/graphql_api/types/enums/tier_name.graphql
new file mode 100644
index 0000000000..aeb825d0d4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/tier_name.graphql
@@ -0,0 +1,8 @@
+enum TierName {
+ BASIC
+ TEAM
+ PRO
+ ENTERPRISE
+ SENTRY
+ TRIAL
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/trial_status.graphql b/apps/codecov-api/graphql_api/types/enums/trial_status.graphql
new file mode 100644
index 0000000000..5583bc69b6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/trial_status.graphql
@@ -0,0 +1,6 @@
+enum TrialStatus {
+ NOT_STARTED
+ ONGOING
+ EXPIRED
+ CANNOT_TRIAL
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/type_projects.graphql b/apps/codecov-api/graphql_api/types/enums/type_projects.graphql
new file mode 100644
index 0000000000..25c783cd4e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/type_projects.graphql
@@ -0,0 +1,6 @@
+enum TypeProjectOnboarding {
+ PERSONAL
+ YOUR_ORG
+ OPEN_SOURCE
+ EDUCATIONAL
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/upload_error_enum.graphql b/apps/codecov-api/graphql_api/types/enums/upload_error_enum.graphql
new file mode 100644
index 0000000000..8e07d3d5b2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/upload_error_enum.graphql
@@ -0,0 +1,10 @@
+enum UploadErrorEnum {
+ FILE_NOT_IN_STORAGE
+ REPORT_EXPIRED
+ REPORT_EMPTY
+ PROCESSING_TIMEOUT
+ UNSUPPORTED_FILE_FORMAT
+
+ UNKNOWN_PROCESSING
+ UNKNOWN_STORAGE
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/upload_state.graphql b/apps/codecov-api/graphql_api/types/enums/upload_state.graphql
new file mode 100644
index 0000000000..5254b847b7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/upload_state.graphql
@@ -0,0 +1,7 @@
+enum UploadState {
+ STARTED
+ UPLOADED
+ PROCESSED
+ ERROR
+ COMPLETE
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/upload_type.graphql b/apps/codecov-api/graphql_api/types/enums/upload_type.graphql
new file mode 100644
index 0000000000..779bdb964b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/upload_type.graphql
@@ -0,0 +1,4 @@
+enum UploadType {
+ UPLOADED
+ CARRIEDFORWARD
+}
diff --git a/apps/codecov-api/graphql_api/types/enums/yaml_states.graphql b/apps/codecov-api/graphql_api/types/enums/yaml_states.graphql
new file mode 100644
index 0000000000..6bd4079cc4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/enums/yaml_states.graphql
@@ -0,0 +1,3 @@
+enum YamlStates {
+ DEFAULT
+}
diff --git a/apps/codecov-api/graphql_api/types/errors/__init__.py b/apps/codecov-api/graphql_api/types/errors/__init__.py
new file mode 100644
index 0000000000..2351d803c6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/errors/__init__.py
@@ -0,0 +1,21 @@
+from .errors import (
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingComparison,
+ MissingCoverage,
+ MissingHeadCommit,
+ MissingHeadReport,
+ ProviderError,
+ UnknownPath,
+)
+
+__all__ = [
+ "MissingBaseCommit",
+ "MissingBaseReport",
+ "MissingComparison",
+ "MissingCoverage",
+ "MissingHeadCommit",
+ "MissingHeadReport",
+ "ProviderError",
+ "UnknownPath",
+]
diff --git a/apps/codecov-api/graphql_api/types/errors/errors.graphql b/apps/codecov-api/graphql_api/types/errors/errors.graphql
new file mode 100644
index 0000000000..68b1d84971
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/errors/errors.graphql
@@ -0,0 +1,43 @@
+interface ResolverError {
+ message: String!
+}
+
+type UnauthenticatedError implements ResolverError {
+ message: String!
+}
+
+type UnauthorizedError implements ResolverError {
+ message: String!
+}
+
+type NotFoundError implements ResolverError {
+ message: String!
+}
+
+type ValidationError implements ResolverError {
+ message: String!
+}
+
+type MissingCoverage implements ResolverError {
+ message: String!
+}
+
+type UnknownPath implements ResolverError {
+ message: String!
+}
+
+type UnknownFlags implements ResolverError {
+ message: String!
+}
+
+type ProviderError implements ResolverError {
+ message: String!
+}
+
+type OwnerNotActivatedError implements ResolverError {
+ message: String!
+}
+
+type MissingService implements ResolverError {
+ message: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/errors/errors.py b/apps/codecov-api/graphql_api/types/errors/errors.py
new file mode 100644
index 0000000000..7df524a99b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/errors/errors.py
@@ -0,0 +1,45 @@
+class MissingHeadReport:
+ message = "Missing head report"
+
+
+class UnknownFlags:
+ def __init__(self, message="No coverage with chosen flags"):
+ self.message = message
+
+
+class MissingBaseCommit:
+ message = "Invalid base commit"
+
+
+class MissingHeadCommit:
+ message = "Invalid head commit"
+
+
+class MissingComparison:
+ message = "Missing comparison"
+
+
+class MissingBaseReport:
+ message = "Missing base report"
+
+
+class MissingCoverage:
+ def __init__(self, message="Missing coverage"):
+ self.message = message
+
+
+class UnknownPath:
+ def __init__(self, message="Unkown path"):
+ self.message = message
+
+
+class ProviderError:
+ message = "Error fetching data from the provider"
+
+
+class OwnerNotActivatedError:
+ message = "You must be activated in the org"
+
+
+class NotFoundError:
+ message = "Not found"
diff --git a/apps/codecov-api/graphql_api/types/file/__init__.py b/apps/codecov-api/graphql_api/types/file/__init__.py
new file mode 100644
index 0000000000..09e7e120d9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/file/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .file import file_bindable
+
+commit_file = ariadne_load_local_graphql(__file__, "file.graphql")
+
+
+__all__ = ["file_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/file/file.graphql b/apps/codecov-api/graphql_api/types/file/file.graphql
new file mode 100644
index 0000000000..bc70e71104
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/file/file.graphql
@@ -0,0 +1,11 @@
+type File {
+ content: String
+ coverage: [CoverageAnnotation]
+ totals: CoverageTotals
+ hashedPath: String!
+}
+
+type CoverageAnnotation {
+ line: Int
+ coverage: CoverageLine
+}
diff --git a/apps/codecov-api/graphql_api/types/file/file.py b/apps/codecov-api/graphql_api/types/file/file.py
new file mode 100644
index 0000000000..c8289d6b6a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/file/file.py
@@ -0,0 +1,53 @@
+import hashlib
+
+from ariadne import ObjectType
+from shared.utils.merge import LineType, line_type
+
+from graphql_api.types.enums import CoverageLine
+
+file_bindable = ObjectType("File")
+
+
+@file_bindable.field("content")
+def resolve_content(data, info):
+ command = info.context["executor"].get_command("commit")
+ return command.get_file_content(data.get("commit"), data.get("path"))
+
+
+def get_coverage_type(line_report):
+ # Get the coverage type from the line_report
+ coverage = line_type(line_report.coverage)
+ # Convert the LineType enum from shared to the GraphQL one
+ return {
+ LineType.hit: CoverageLine.H,
+ LineType.miss: CoverageLine.M,
+ LineType.partial: CoverageLine.P,
+ }.get(coverage)
+
+
+@file_bindable.field("coverage")
+def resolve_coverage(data, info):
+ file_report = data.get("file_report")
+
+ if not file_report:
+ return []
+
+ return [
+ {"line": line_report[0], "coverage": get_coverage_type(line_report[1])}
+ for line_report in file_report.lines
+ ]
+
+
+@file_bindable.field("totals")
+def resolve_totals(data, info):
+ file_report = data.get("file_report")
+ return file_report.totals if file_report else None
+
+
+@file_bindable.field("hashedPath")
+def resolve_hashed_path(data, info):
+ path = data.get("path")
+ encoded_path = path.encode()
+ md5_path = hashlib.md5(encoded_path)
+
+ return md5_path.hexdigest()
diff --git a/apps/codecov-api/graphql_api/types/flag/__init__.py b/apps/codecov-api/graphql_api/types/flag/__init__.py
new file mode 100644
index 0000000000..97313d98c6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag/__init__.py
@@ -0,0 +1,10 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+from graphql_api.helpers.connection import build_connection_graphql
+
+from .flag import flag_bindable
+
+flag = ariadne_load_local_graphql(__file__, "flag.graphql")
+flag += build_connection_graphql("FlagConnection", "Flag")
+
+
+__all__ = ["flag_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/flag/flag.graphql b/apps/codecov-api/graphql_api/types/flag/flag.graphql
new file mode 100644
index 0000000000..6acb6afa1b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag/flag.graphql
@@ -0,0 +1,6 @@
+type Flag {
+ name: String!
+ percentCovered: Float
+ percentChange: Float
+ measurements(interval: MeasurementInterval!, after: DateTime!, before: DateTime!): [Measurement!]!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/flag/flag.py b/apps/codecov-api/graphql_api/types/flag/flag.py
new file mode 100644
index 0000000000..64427ddf47
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag/flag.py
@@ -0,0 +1,51 @@
+from datetime import datetime
+from typing import Iterable
+
+from ariadne import ObjectType
+
+from reports.models import RepositoryFlag
+from timeseries.helpers import fill_sparse_measurements
+from timeseries.models import Interval, MeasurementSummary
+
+flag_bindable = ObjectType("Flag")
+
+# NOTE: measurements are fetched in the parent resolver (repository) and
+# placed in the context so that they can be used in multiple resolvers here
+
+
+@flag_bindable.field("name")
+def resolve_timestamp(flag: RepositoryFlag, info) -> str:
+ return flag.flag_name
+
+
+@flag_bindable.field("percentCovered")
+def resolve_percent_covered(flag: RepositoryFlag, info) -> float:
+ if "flag_measurements" not in info.context:
+ # we rely on measurements for this computed value
+ return None
+
+ measurements = info.context["flag_measurements"].get(flag.pk, [])
+ if len(measurements) > 0:
+ # coverage returned is the most recent measurement average
+ return measurements[-1]["avg"]
+
+
+@flag_bindable.field("percentChange")
+def resolve_percent_change(flag: RepositoryFlag, info) -> float:
+ if "flag_measurements" not in info.context:
+ # we rely on measurements for this computed value
+ return None
+
+ measurements = info.context["flag_measurements"].get(flag.pk, [])
+ if len(measurements) > 1:
+ return measurements[-1]["avg"] - measurements[0]["avg"]
+
+
+@flag_bindable.field("measurements")
+def resolve_measurements(
+ flag: RepositoryFlag, info, interval: Interval, after: datetime, before: datetime
+) -> Iterable[MeasurementSummary]:
+ measurements = info.context["flag_measurements"].get(flag.pk, [])
+ if len(measurements) == 0:
+ return []
+ return fill_sparse_measurements(measurements, interval, after, before)
diff --git a/apps/codecov-api/graphql_api/types/flag_comparison/__init__.py b/apps/codecov-api/graphql_api/types/flag_comparison/__init__.py
new file mode 100644
index 0000000000..2589d8e9bb
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag_comparison/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+from graphql_api.helpers.connection import build_connection_graphql
+
+from .flag_comparison import flag_comparison_bindable
+
+flag_comparison = ariadne_load_local_graphql(__file__, "flag_comparison.graphql")
+
+
+__all__ = ["flag_comparison_bindable", "build_connection_graphql"]
diff --git a/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.graphql b/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.graphql
new file mode 100644
index 0000000000..2f0492ef64
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.graphql
@@ -0,0 +1,6 @@
+type FlagComparison {
+ name: String!
+ headTotals: CoverageTotals
+ baseTotals: CoverageTotals
+ patchTotals: CoverageTotals
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.py b/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.py
new file mode 100644
index 0000000000..8a02571873
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flag_comparison/flag_comparison.py
@@ -0,0 +1,25 @@
+from ariadne import ObjectType
+
+from compare.models import FlagComparison
+
+flag_comparison_bindable = ObjectType("FlagComparison")
+
+
+@flag_comparison_bindable.field("name")
+def resolve_name(flag_comparison: FlagComparison, info) -> str:
+ return flag_comparison.repositoryflag.flag_name
+
+
+@flag_comparison_bindable.field("patchTotals")
+def resolve_patch_totals(flag_comparison: FlagComparison, info) -> dict:
+ return flag_comparison.patch_totals
+
+
+@flag_comparison_bindable.field("headTotals")
+def resolve_head_totals(flag_comparison: FlagComparison, info) -> dict:
+ return flag_comparison.head_totals
+
+
+@flag_comparison_bindable.field("baseTotals")
+def resolve_base_totals(flag_comparison: FlagComparison, info) -> dict:
+ return flag_comparison.base_totals
diff --git a/apps/codecov-api/graphql_api/types/flake_aggregates/__init__.py b/apps/codecov-api/graphql_api/types/flake_aggregates/__init__.py
new file mode 100644
index 0000000000..c8692bbeae
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flake_aggregates/__init__.py
@@ -0,0 +1,7 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .flake_aggregates import flake_aggregates_bindable
+
+flake_aggregates = ariadne_load_local_graphql(__file__, "flake_aggregates.graphql")
+
+__all__ = ["flake_aggregates_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.graphql b/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.graphql
new file mode 100644
index 0000000000..92149c4f38
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.graphql
@@ -0,0 +1,6 @@
+type FlakeAggregates {
+ flakeCount: Int!
+ flakeCountPercentChange: Float
+ flakeRate: Float!
+ flakeRatePercentChange: Float
+}
diff --git a/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.py b/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.py
new file mode 100644
index 0000000000..4b6d8603e4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/flake_aggregates/flake_aggregates.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass
+
+import polars as pl
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+from shared.django_apps.core.models import Repository
+
+from graphql_api.types.enums.enum_types import MeasurementInterval
+from utils.test_results import get_results
+
+
+@dataclass
+class FlakeAggregates:
+ flake_count: int
+ flake_rate: float
+ flake_count_percent_change: float | None = None
+ flake_rate_percent_change: float | None = None
+
+
+def calculate_flake_aggregates(table: pl.DataFrame) -> pl.DataFrame:
+ return table.select(
+ (pl.col("total_flaky_fail_count") > 0).sum().alias("flake_count"),
+ (
+ pl.col("total_flaky_fail_count").sum()
+ / (pl.col("total_fail_count").sum() + pl.col("total_pass_count").sum())
+ ).alias("flake_rate"),
+ )
+
+
+def flake_aggregates_from_table(table: pl.DataFrame) -> FlakeAggregates:
+ aggregates = calculate_flake_aggregates(table).row(0, named=True)
+ return FlakeAggregates(**aggregates)
+
+
+def flake_aggregates_with_percentage(
+ curr_results: pl.DataFrame,
+ past_results: pl.DataFrame,
+) -> FlakeAggregates:
+ curr_aggregates = calculate_flake_aggregates(curr_results)
+ past_aggregates = calculate_flake_aggregates(past_results)
+
+ merged_results: pl.DataFrame = pl.concat([past_aggregates, curr_aggregates])
+
+ merged_results = merged_results.with_columns(
+ pl.all()
+ .pct_change()
+ .replace([float("inf"), float("-inf")], None)
+ .fill_nan(0)
+ .name.suffix("_percent_change")
+ )
+ aggregates = merged_results.row(1, named=True)
+
+ return FlakeAggregates(**aggregates)
+
+
+def generate_flake_aggregates(
+ repoid: int, interval: MeasurementInterval
+) -> FlakeAggregates | None:
+ repo = Repository.objects.get(repoid=repoid)
+
+ curr_results = get_results(repo.repoid, repo.branch, interval.value)
+ if curr_results is None:
+ return None
+ past_results = get_results(
+ repo.repoid, repo.branch, interval.value * 2, interval.value
+ )
+ if past_results is None:
+ return flake_aggregates_from_table(curr_results)
+ else:
+ return flake_aggregates_with_percentage(curr_results, past_results)
+
+
+flake_aggregates_bindable = ObjectType("FlakeAggregates")
+
+
+@flake_aggregates_bindable.field("flakeCount")
+def resolve_flake_count(obj: FlakeAggregates, _: GraphQLResolveInfo) -> int:
+ return obj.flake_count
+
+
+@flake_aggregates_bindable.field("flakeCountPercentChange")
+def resolve_flake_count_percent_change(
+ obj: FlakeAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.flake_count_percent_change
+
+
+@flake_aggregates_bindable.field("flakeRate")
+def resolve_flake_rate(obj: FlakeAggregates, _: GraphQLResolveInfo) -> float:
+ return obj.flake_rate
+
+
+@flake_aggregates_bindable.field("flakeRatePercentChange")
+def resolve_flake_rate_percent_change(
+ obj: FlakeAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.flake_rate_percent_change
diff --git a/apps/codecov-api/graphql_api/types/impacted_file/__init__.py b/apps/codecov-api/graphql_api/types/impacted_file/__init__.py
new file mode 100644
index 0000000000..2412e2ff20
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/impacted_file/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .impacted_file import impacted_file_bindable, impacted_files_result_bindable
+
+impacted_file = ariadne_load_local_graphql(__file__, "impacted_file.graphql")
+
+
+__all__ = ["impacted_file_bindable", "impacted_files_result_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.graphql b/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.graphql
new file mode 100644
index 0000000000..e9eb681c41
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.graphql
@@ -0,0 +1,23 @@
+type ImpactedFile {
+ fileName: String
+ baseName: String
+ headName: String
+ isNewFile: Boolean!
+ isRenamedFile: Boolean!
+ isDeletedFile: Boolean!
+ baseCoverage: CoverageTotals
+ headCoverage: CoverageTotals
+ patchCoverage: CoverageTotals
+ changeCoverage: Float
+ missesCount: Int!
+ hashedPath: String!
+ segments(filters: SegmentsFilters): SegmentsResult!
+}
+
+type ImpactedFiles {
+ results: [ImpactedFile]
+}
+
+union ImpactedFilesResult =
+ ImpactedFiles
+ | UnknownFlags
diff --git a/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.py b/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.py
new file mode 100644
index 0000000000..19441eb70c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/impacted_file/impacted_file.py
@@ -0,0 +1,144 @@
+import hashlib
+from typing import List, Union
+
+import sentry_sdk
+from ariadne import ObjectType, UnionType
+from asgiref.sync import sync_to_async
+from shared.reports.types import ReportTotals
+from shared.torngit.exceptions import TorngitClientError
+
+from graphql_api.types.errors import ProviderError, UnknownPath
+from graphql_api.types.errors.errors import UnknownFlags
+from graphql_api.types.segment_comparison.segment_comparison import SegmentComparisons
+from services.comparison import (
+ Comparison,
+ ImpactedFile,
+ MissingComparisonReport,
+)
+
+impacted_file_bindable = ObjectType("ImpactedFile")
+
+
+@impacted_file_bindable.field("fileName")
+def resolve_file_name(impacted_file: ImpactedFile, info) -> str:
+ return impacted_file.file_name
+
+
+@impacted_file_bindable.field("headName")
+def resolve_head_name(impacted_file: ImpactedFile, info) -> str:
+ return impacted_file.head_name
+
+
+@impacted_file_bindable.field("baseName")
+def resolve_base_name(impacted_file: ImpactedFile, info) -> str:
+ return impacted_file.base_name
+
+
+@impacted_file_bindable.field("headCoverage")
+def resolve_head_coverage(impacted_file: ImpactedFile, info) -> ReportTotals:
+ return impacted_file.head_coverage
+
+
+@impacted_file_bindable.field("baseCoverage")
+def resolve_base_coverage(impacted_file: ImpactedFile, info) -> ReportTotals:
+ return impacted_file.base_coverage
+
+
+@impacted_file_bindable.field("patchCoverage")
+def resolve_patch_coverage(impacted_file: ImpactedFile, info) -> ReportTotals:
+ return impacted_file.patch_coverage
+
+
+@impacted_file_bindable.field("changeCoverage")
+def resolve_change_coverage(impacted_file: ImpactedFile, info) -> float:
+ return impacted_file.change_coverage
+
+
+@impacted_file_bindable.field("hashedPath")
+def resolve_hashed_path(impacted_file: ImpactedFile, info) -> str:
+ path = impacted_file.head_name
+ encoded_path = path.encode()
+ md5_path = hashlib.md5(encoded_path)
+
+ return md5_path.hexdigest()
+
+
+@impacted_file_bindable.field("segments")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_segments(
+ impacted_file: ImpactedFile, info, filters=None
+) -> Union[UnknownPath, ProviderError, SegmentComparisons]:
+ if filters is None:
+ filters = {}
+ if "comparison" not in info.context:
+ return SegmentComparisons(results=[])
+
+ comparison: Comparison = info.context["comparison"]
+ try:
+ comparison.validate()
+ except MissingComparisonReport:
+ return SegmentComparisons(results=[])
+ path = impacted_file.head_name
+
+ try:
+ file_comparison = comparison.get_file_comparison(
+ path, with_src=True, bypass_max_diff=True
+ )
+ except TorngitClientError as e:
+ if e.code == 404:
+ return UnknownPath(f"path does not exist: {path}")
+ else:
+ return ProviderError()
+
+ segments = file_comparison.segments
+
+ if filters.get("has_unintended_changes") is True:
+ # segments with no diff changes and at least 1 unintended change
+ segments = [segment for segment in segments if segment.has_unintended_changes]
+ elif filters.get("has_unintended_changes") is False:
+ new_segments = []
+ for segment in segments:
+ if segment.has_diff_changes:
+ segment.remove_unintended_changes()
+ new_segments.append(segment)
+ segments = new_segments
+
+ return SegmentComparisons(results=segments)
+
+
+@impacted_file_bindable.field("isNewFile")
+def resolve_is_new_file(impacted_file: ImpactedFile, info) -> bool:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return base_name is None and head_name is not None
+
+
+@impacted_file_bindable.field("isRenamedFile")
+def resolve_is_renamed_file(impacted_file: ImpactedFile, info) -> bool:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return base_name is not None and head_name is not None and base_name != head_name
+
+
+@impacted_file_bindable.field("isDeletedFile")
+def resolve_is_deleted_file(impacted_file: ImpactedFile, info) -> bool:
+ base_name = impacted_file.base_name
+ head_name = impacted_file.head_name
+ return base_name is not None and head_name is None
+
+
+@impacted_file_bindable.field("missesCount")
+def resolve_misses_count(impacted_file: ImpactedFile, info) -> int:
+ return impacted_file.misses_count
+
+
+impacted_files_result_bindable = UnionType("ImpactedFilesResult")
+
+
+@impacted_files_result_bindable.type_resolver
+def resolve_files_result_type(res, *_):
+ if isinstance(res, UnknownFlags):
+ return "UnknownFlags"
+ elif isinstance(res, type({"results": List})):
+ return "ImpactedFiles"
diff --git a/apps/codecov-api/graphql_api/types/inputs/activate_measurements.graphql b/apps/codecov-api/graphql_api/types/inputs/activate_measurements.graphql
new file mode 100644
index 0000000000..54597a9beb
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/activate_measurements.graphql
@@ -0,0 +1,5 @@
+input ActivateMeasurementsInput {
+ owner: String!
+ repoName: String!
+ measurementType: MeasurementType!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/branches_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/branches_set_filters.graphql
new file mode 100644
index 0000000000..7e982bae80
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/branches_set_filters.graphql
@@ -0,0 +1,4 @@
+input BranchesSetFilters {
+ searchValue: String
+ mergedBranches: Boolean
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_cache_config.graphql b/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_cache_config.graphql
new file mode 100644
index 0000000000..5500da6b1a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_cache_config.graphql
@@ -0,0 +1,10 @@
+input BundleCacheConfigInput {
+ bundleName: String!
+ toggleCaching: Boolean!
+}
+
+input UpdateBundleCacheConfigInput {
+ owner: String!
+ repoName: String!
+ bundles: [BundleCacheConfigInput!]!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_filters.graphql
new file mode 100644
index 0000000000..ea79fa2fd4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/bundle_analysis_filters.graphql
@@ -0,0 +1,12 @@
+input BundleAnalysisReportFilters {
+ reportGroups: [BundleReportGroups!]
+ loadTypes: [BundleLoadTypes!]
+}
+
+input BundleAnalysisMeasurementsSetFilters {
+ assetTypes: [BundleAnalysisMeasurementsAssetType!]
+}
+
+input BundleReportFilters {
+ reportGroup: BundleReportGroups
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/cancel_trial.graphql b/apps/codecov-api/graphql_api/types/inputs/cancel_trial.graphql
new file mode 100644
index 0000000000..82fb15461e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/cancel_trial.graphql
@@ -0,0 +1,3 @@
+input CancelTrialInput {
+ orgUsername: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/commit_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/commit_set_filters.graphql
new file mode 100644
index 0000000000..ce9d88542a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/commit_set_filters.graphql
@@ -0,0 +1,8 @@
+input CommitsSetFilters {
+ hideFailedCI: Boolean
+ branchName: String
+ pullId: Int
+ search: String
+ states: [CommitState!]
+ coverageStatus: [CommitStatus!]
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/components_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/components_filters.graphql
new file mode 100644
index 0000000000..d89964e8bf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/components_filters.graphql
@@ -0,0 +1,7 @@
+input ComponentsFilters {
+ components: [String!]
+}
+
+input ComponentMeasurementsSetFilters {
+ components: [String]
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/create_api_token.graphql b/apps/codecov-api/graphql_api/types/inputs/create_api_token.graphql
new file mode 100644
index 0000000000..60faf99e15
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/create_api_token.graphql
@@ -0,0 +1,3 @@
+input CreateApiTokenInput {
+ name: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/create_stripe_setup_intent.graphql b/apps/codecov-api/graphql_api/types/inputs/create_stripe_setup_intent.graphql
new file mode 100644
index 0000000000..5fe147ba5b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/create_stripe_setup_intent.graphql
@@ -0,0 +1,3 @@
+input CreateStripeSetupIntentInput {
+ owner: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/create_user_token.graphql b/apps/codecov-api/graphql_api/types/inputs/create_user_token.graphql
new file mode 100644
index 0000000000..3af1753fe1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/create_user_token.graphql
@@ -0,0 +1,4 @@
+input CreateUserTokenInput {
+ name: String!
+ tokenType: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/delete_component_measurements.graphql b/apps/codecov-api/graphql_api/types/inputs/delete_component_measurements.graphql
new file mode 100644
index 0000000000..81fd122ec1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/delete_component_measurements.graphql
@@ -0,0 +1,5 @@
+input DeleteComponentMeasurementsInput {
+ ownerUsername: String!
+ repoName: String!
+ componentId: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/delete_flag.graphql b/apps/codecov-api/graphql_api/types/inputs/delete_flag.graphql
new file mode 100644
index 0000000000..c0ca94206d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/delete_flag.graphql
@@ -0,0 +1,5 @@
+input DeleteFlagInput {
+ ownerUsername: String!
+ repoName: String!
+ flagName: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/delete_session.graphql b/apps/codecov-api/graphql_api/types/inputs/delete_session.graphql
new file mode 100644
index 0000000000..69cc894179
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/delete_session.graphql
@@ -0,0 +1,3 @@
+input DeleteSessionInput {
+ sessionid: Int!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/encode_secret_string.graphql b/apps/codecov-api/graphql_api/types/inputs/encode_secret_string.graphql
new file mode 100644
index 0000000000..2ea2e0910a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/encode_secret_string.graphql
@@ -0,0 +1,4 @@
+input EncodeSecretStringInput {
+ repoName: String!
+ value: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/flag_comparison_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/flag_comparison_filters.graphql
new file mode 100644
index 0000000000..1e368014a7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/flag_comparison_filters.graphql
@@ -0,0 +1,3 @@
+input FlagComparisonFilters {
+ term: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/flag_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/flag_set_filters.graphql
new file mode 100644
index 0000000000..6dcc6b2557
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/flag_set_filters.graphql
@@ -0,0 +1,4 @@
+input FlagSetFilters {
+ term: String
+ flagsNames: [String]
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/impacted_files_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/impacted_files_filters.graphql
new file mode 100644
index 0000000000..2ca3d2d932
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/impacted_files_filters.graphql
@@ -0,0 +1,11 @@
+input ImpactedFilesFilters {
+ ordering: ImpactedFilesOrdering
+ hasUnintendedChanges: Boolean
+ flags: [String!]
+ components: [String!]
+}
+
+input ImpactedFilesOrdering {
+ direction: OrderingDirection
+ parameter: ImpactedFileParameter
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/measurement_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/measurement_set_filters.graphql
new file mode 100644
index 0000000000..ba8bbc8595
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/measurement_set_filters.graphql
@@ -0,0 +1,7 @@
+input MeasurementSetFilters {
+ repoId: Int
+ branch: String
+ flagId: Int
+ after: DateTime!
+ before: DateTime!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/onboard_user.graphql b/apps/codecov-api/graphql_api/types/inputs/onboard_user.graphql
new file mode 100644
index 0000000000..4574144e55
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/onboard_user.graphql
@@ -0,0 +1,7 @@
+input OnboardUserInput {
+ email: String
+ businessEmail: String
+ typeProjects: [TypeProjectOnboarding]!
+ goals: [GoalOnboarding]!
+ otherGoal: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/organization_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/organization_set_filters.graphql
new file mode 100644
index 0000000000..2ce97fd32b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/organization_set_filters.graphql
@@ -0,0 +1,3 @@
+input OrganizationSetFilters {
+ term: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/path_contents_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/path_contents_filters.graphql
new file mode 100644
index 0000000000..70be00e30e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/path_contents_filters.graphql
@@ -0,0 +1,12 @@
+input PathContentsFilters {
+ searchValue: String
+ displayType: PathContentDisplayType
+ ordering: PathContentsOrdering
+ flags: [String!]
+ components: [String!]
+}
+
+input PathContentsOrdering {
+ direction: OrderingDirection
+ parameter: OrderingParameter
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/pulls_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/pulls_set_filters.graphql
new file mode 100644
index 0000000000..8cd23efbad
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/pulls_set_filters.graphql
@@ -0,0 +1,3 @@
+input PullsSetFilters {
+ state: [PullRequestState]
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/regenerate_org_upload_token.graphql b/apps/codecov-api/graphql_api/types/inputs/regenerate_org_upload_token.graphql
new file mode 100644
index 0000000000..363c8a6b4f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/regenerate_org_upload_token.graphql
@@ -0,0 +1,3 @@
+input RegenerateOrgUploadTokenInput {
+ owner: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_token.graphql b/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_token.graphql
new file mode 100644
index 0000000000..707ae7084a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_token.graphql
@@ -0,0 +1,5 @@
+input RegenerateRepositoryTokenInput {
+ owner: String!
+ repoName: String!
+ tokenType: RepositoryTokenType!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_upload_token.graphql b/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_upload_token.graphql
new file mode 100644
index 0000000000..d0d290508d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/regenerate_repository_upload_token.graphql
@@ -0,0 +1,4 @@
+input RegenerateRepositoryUploadTokenInput {
+ repoName: String!
+ owner: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/repository_set_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/repository_set_filters.graphql
new file mode 100644
index 0000000000..4669688398
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/repository_set_filters.graphql
@@ -0,0 +1,8 @@
+input RepositorySetFilters {
+ term: String
+ repoNames: [String]
+ active: Boolean
+ activated: Boolean
+ isPublic: Boolean
+ aiEnabled: Boolean
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/revoke_user_token.graphql b/apps/codecov-api/graphql_api/types/inputs/revoke_user_token.graphql
new file mode 100644
index 0000000000..9f5b768738
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/revoke_user_token.graphql
@@ -0,0 +1,3 @@
+input RevokeUserTokenInput {
+ tokenid: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/save_sentry_state.graphql b/apps/codecov-api/graphql_api/types/inputs/save_sentry_state.graphql
new file mode 100644
index 0000000000..ab24c40a89
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/save_sentry_state.graphql
@@ -0,0 +1,3 @@
+input SaveSentryStateInput {
+ state: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/inputs/segments_filter.graphql b/apps/codecov-api/graphql_api/types/inputs/segments_filter.graphql
new file mode 100644
index 0000000000..f848e272d1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/segments_filter.graphql
@@ -0,0 +1,3 @@
+input SegmentsFilters {
+ hasUnintendedChanges: Boolean
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/set_yaml_on_owner.graphql b/apps/codecov-api/graphql_api/types/inputs/set_yaml_on_owner.graphql
new file mode 100644
index 0000000000..d3d08044ac
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/set_yaml_on_owner.graphql
@@ -0,0 +1,4 @@
+input SetYamlOnOwnerInput {
+ username: String!
+ yaml: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/start_trial.graphql b/apps/codecov-api/graphql_api/types/inputs/start_trial.graphql
new file mode 100644
index 0000000000..0d18c044b9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/start_trial.graphql
@@ -0,0 +1,3 @@
+input StartTrialInput {
+ orgUsername: String
+}
diff --git a/apps/codecov-api/graphql_api/types/inputs/test_results_filters.graphql b/apps/codecov-api/graphql_api/types/inputs/test_results_filters.graphql
new file mode 100644
index 0000000000..5358ccd9ac
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/inputs/test_results_filters.graphql
@@ -0,0 +1,13 @@
+input TestResultsFilters {
+ branch: String
+ parameter: TestResultsFilterParameter
+ test_suites: [String!]
+ flags: [String!]
+ interval: MeasurementInterval
+ term: String
+}
+
+input TestResultsOrdering {
+ direction: OrderingDirection
+ parameter: TestResultsOrderingParameter
+}
diff --git a/apps/codecov-api/graphql_api/types/invoice/__init__.py b/apps/codecov-api/graphql_api/types/invoice/__init__.py
new file mode 100644
index 0000000000..1b1a23413e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/invoice/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .invoice import invoice_bindable
+
+invoice = ariadne_load_local_graphql(__file__, "invoice.graphql")
+
+
+__all__ = ["invoice_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/invoice/invoice.graphql b/apps/codecov-api/graphql_api/types/invoice/invoice.graphql
new file mode 100644
index 0000000000..ef11d76607
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/invoice/invoice.graphql
@@ -0,0 +1,71 @@
+type Invoice {
+ amountDue: Float!
+ amountPaid: Float!
+ created: Int!
+ currency: String!
+ customerAddress: String
+ customerEmail: String
+ customerName: String
+ defaultPaymentMethod: PaymentMethod
+ dueDate: Int
+ footer: String
+ id: String!
+ lineItems: [LineItem]!
+ number: String
+ periodEnd: Int!
+ periodStart: Int!
+ status: String
+ subtotal: Float!
+ total: Float!
+ taxIds: [TaxInfo]
+}
+
+type LineItem {
+ amount: Float!
+ currency: String!
+ description: String!
+}
+
+type Period {
+ end: Int!
+ start: Int!
+}
+
+type PaymentMethod {
+ billingDetails: BillingDetails
+ card: Card
+ usBankAccount: USBankAccount
+}
+
+type Card {
+ brand: String
+ expMonth: Int
+ expYear: Int
+ last4: String
+}
+
+type USBankAccount {
+ bankName: String
+ last4: String
+}
+
+type BillingDetails {
+ address: Address
+ email: String
+ name: String
+ phone: String
+}
+
+type Address {
+ city: String
+ country: String
+ line1: String
+ line2: String
+ postalCode: String
+ state: String
+}
+
+type TaxInfo {
+ type: String!
+ value: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/invoice/invoice.py b/apps/codecov-api/graphql_api/types/invoice/invoice.py
new file mode 100644
index 0000000000..fedb1ea857
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/invoice/invoice.py
@@ -0,0 +1,119 @@
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+from stripe import (
+ Invoice,
+ InvoiceLineItem,
+ ListObject,
+ PaymentMethod,
+)
+
+invoice_bindable = ObjectType("Invoice")
+
+
+@invoice_bindable.field("id")
+def resolve_invoice_id(invoice: Invoice, info: GraphQLResolveInfo) -> str | None:
+ return invoice["id"]
+
+
+@invoice_bindable.field("number")
+def resolve_invoice_number(invoice: Invoice, info: GraphQLResolveInfo) -> str | None:
+ return invoice["number"]
+
+
+@invoice_bindable.field("status")
+def resolve_invoice_status(invoice: Invoice, info: GraphQLResolveInfo) -> str | None:
+ return invoice["status"]
+
+
+@invoice_bindable.field("created")
+def resolve_invoice_created(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["created"]
+
+
+@invoice_bindable.field("periodStart")
+def resolve_invoice_period_start(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["period_start"]
+
+
+@invoice_bindable.field("periodEnd")
+def resolve_invoice_period_end(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["period_end"]
+
+
+@invoice_bindable.field("dueDate")
+def resolve_invoice_due_date(invoice: Invoice, info: GraphQLResolveInfo) -> int | None:
+ return invoice["due_date"]
+
+
+@invoice_bindable.field("customerName")
+def resolve_invoice_customer_name(
+ invoice: Invoice, info: GraphQLResolveInfo
+) -> str | None:
+ return invoice["customer_name"]
+
+
+# NOTE: Not currently used in Gazebo, keep for address updates
+@invoice_bindable.field("customerAddress")
+def resolve_invoice_customer_address(
+ invoice: Invoice, info: GraphQLResolveInfo
+) -> str | None:
+ if invoice["customer_address"]:
+ return str(invoice["customer_address"])
+ return None
+
+
+# NOTE: Not currently used in Gazebo, keep for tax id updates
+@invoice_bindable.field("currency")
+def resolve_invoice_currency(invoice: Invoice, info: GraphQLResolveInfo) -> str:
+ return invoice["currency"]
+
+
+@invoice_bindable.field("amountPaid")
+def resolve_invoice_amount_paid(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["amount_paid"]
+
+
+@invoice_bindable.field("amountDue")
+def resolve_invoice_amount_due(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["amount_due"]
+
+
+@invoice_bindable.field("total")
+def resolve_invoice_total(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["total"]
+
+
+@invoice_bindable.field("subtotal")
+def resolve_invoice_subtotal(invoice: Invoice, info: GraphQLResolveInfo) -> int:
+ return invoice["subtotal"]
+
+
+@invoice_bindable.field("lineItems")
+def resolve_invoice_line_items(
+ invoice: Invoice, info: GraphQLResolveInfo
+) -> ListObject[InvoiceLineItem]:
+ return invoice["lines"]["data"]
+
+
+@invoice_bindable.field("footer")
+def resolve_invoice_footer(invoice: Invoice, info: GraphQLResolveInfo) -> str | None:
+ return invoice["footer"]
+
+
+@invoice_bindable.field("customerEmail")
+def resolve_invoice_customer_email(
+ invoice: Invoice, info: GraphQLResolveInfo
+) -> str | None:
+ return invoice["customer_email"]
+
+
+@invoice_bindable.field("defaultPaymentMethod")
+def resolve_invoice_default_payment_method(
+ invoice: Invoice, info: GraphQLResolveInfo
+) -> PaymentMethod | None:
+ return invoice["default_payment_method"]
+
+
+@invoice_bindable.field("taxIds")
+def resolve_invoice_tax_ids(invoice: Invoice, info: GraphQLResolveInfo) -> list:
+ return invoice["customer_tax_ids"]
diff --git a/apps/codecov-api/graphql_api/types/line_comparison/__init__.py b/apps/codecov-api/graphql_api/types/line_comparison/__init__.py
new file mode 100644
index 0000000000..b8f87ab586
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/line_comparison/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .line_comparison import line_comparison_bindable
+
+line_comparison = ariadne_load_local_graphql(__file__, "line_comparison.graphql")
+
+
+__all__ = ["line_comparison_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.graphql b/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.graphql
new file mode 100644
index 0000000000..796d64e788
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.graphql
@@ -0,0 +1,13 @@
+type CoverageInfo {
+ hitCount: Int
+ hitUploadIds: [Int!]
+}
+
+type LineComparison {
+ baseNumber: String
+ headNumber: String
+ baseCoverage: CoverageLine
+ headCoverage: CoverageLine
+ content: String
+ coverageInfo(ignoredUploadIds: [Int!]): CoverageInfo!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.py b/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.py
new file mode 100644
index 0000000000..2692118de5
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/line_comparison/line_comparison.py
@@ -0,0 +1,80 @@
+from functools import cached_property
+from typing import List, Optional
+
+from ariadne import ObjectType
+from shared.utils.merge import LineType
+
+from graphql_api.types.enums import CoverageLine
+from services.comparison import LineComparison
+
+line_coverages = {
+ LineType.hit: CoverageLine.H,
+ LineType.miss: CoverageLine.M,
+ LineType.partial: CoverageLine.P,
+}
+
+
+class CoverageInfo:
+ def __init__(
+ self,
+ line_comparison: LineComparison,
+ ignored_upload_ids: Optional[List[int]] = None,
+ ):
+ self.line_comparison = line_comparison
+ self.ignored_upload_ids = set(ignored_upload_ids or [])
+
+ @cached_property
+ def hit_count(self):
+ upload_ids = self.hit_upload_ids
+ if upload_ids is not None:
+ return len(upload_ids)
+
+ @cached_property
+ def hit_upload_ids(self):
+ upload_ids = self.line_comparison.hit_session_ids
+ if upload_ids is not None:
+ return set(upload_ids) - self.ignored_upload_ids
+
+
+line_comparison_bindable = ObjectType("LineComparison")
+
+
+@line_comparison_bindable.field("baseNumber")
+def resolve_base_number(line_comparison: LineComparison, info) -> Optional[str]:
+ return line_comparison.number["base"]
+
+
+@line_comparison_bindable.field("headNumber")
+def resolve_head_number(line_comparison: LineComparison, info) -> Optional[str]:
+ return line_comparison.number["head"]
+
+
+@line_comparison_bindable.field("baseCoverage")
+def resolve_base_coverage(line_comparison: LineComparison, info) -> Optional[str]:
+ line_type: LineType = line_comparison.coverage["base"]
+ if line_type is not None:
+ return line_coverages.get(line_type)
+
+
+@line_comparison_bindable.field("headCoverage")
+def resolve_head_coverage(line_comparison: LineComparison, info) -> Optional[str]:
+ line_type: LineType = line_comparison.coverage["head"]
+ if line_type is not None:
+ return line_coverages.get(line_type)
+
+
+@line_comparison_bindable.field("content")
+def resolve_content(line_comparison: LineComparison, info) -> str:
+ value = line_comparison.value
+ if value and line_comparison.is_diff:
+ return f"{value[0]} {value[1:]}"
+ return f" {value}"
+
+
+@line_comparison_bindable.field("coverageInfo")
+def resolve_coverage_info(
+ line_comparison: LineComparison,
+ info,
+ ignored_upload_ids: Optional[List[int]] = None,
+) -> CoverageInfo:
+ return CoverageInfo(line_comparison, ignored_upload_ids=ignored_upload_ids)
diff --git a/apps/codecov-api/graphql_api/types/me/__init__.py b/apps/codecov-api/graphql_api/types/me/__init__.py
new file mode 100644
index 0000000000..f589aad98d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/me/__init__.py
@@ -0,0 +1,3 @@
+from .me import me, me_bindable, tracking_metadata_bindable
+
+__all__ = ["me", "me_bindable", "tracking_metadata_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/me/me.graphql b/apps/codecov-api/graphql_api/types/me/me.graphql
new file mode 100644
index 0000000000..aa9e0c2c2a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/me/me.graphql
@@ -0,0 +1,60 @@
+type Me {
+ email: String
+ businessEmail: String
+ onboardingCompleted: Boolean!
+ user: User!
+ owner: Owner!
+ sessions(
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): SessionConnection! @cost(complexity: 7, multipliers: ["first", "last"])
+ tokens(
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): UserTokenConnection! @cost(complexity: 5, multipliers: ["first", "last"])
+ viewableRepositories(
+ filters: RepositorySetFilters
+ ordering: RepositoryOrdering
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): ViewableRepositoryConnection!
+ @cost(complexity: 25, multipliers: ["first", "last"])
+ myOrganizations(
+ filters: OrganizationSetFilters
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): MyOrganizationConnection!
+ @cost(complexity: 15, multipliers: ["first", "last"])
+ isSyncingWithGitProvider: Boolean!
+ trackingMetadata: trackingMetadata! # temporary solution to expose the user metadata
+ privateAccess: Boolean
+ termsAgreement: Boolean
+}
+
+# Temporary type to gather the different attributes for tracking until we have
+# a valid place in the graph for those attributes
+type trackingMetadata {
+ ownerid: Int!
+ serviceId: String!
+ plan: String
+ staff: Boolean
+ hasYaml: Boolean!
+ service: String!
+ bot: String
+ delinquent: Boolean
+ didTrial: Boolean
+ planProvider: String
+ planUserCount: Int
+ createstamp: DateTime
+ updatestamp: DateTime
+ profile: Profile
+}
diff --git a/apps/codecov-api/graphql_api/types/me/me.py b/apps/codecov-api/graphql_api/types/me/me.py
new file mode 100644
index 0000000000..f08b0ee081
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/me/me.py
@@ -0,0 +1,147 @@
+from typing import Optional
+
+import sentry_sdk
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+
+from codecov_auth.models import Owner, OwnerProfile
+from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY
+from graphql_api.actions.owner import (
+ get_owner_login_sessions,
+ get_user_tokens,
+ search_my_owners,
+)
+from graphql_api.actions.repository import search_repos
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+from graphql_api.helpers.connection import (
+ build_connection_graphql,
+ queryset_to_connection,
+)
+from graphql_api.types.enums import OrderingDirection, RepositoryOrdering
+
+me = ariadne_load_local_graphql(__file__, "me.graphql")
+me = me + build_connection_graphql("ViewableRepositoryConnection", "Repository")
+me = me + build_connection_graphql("MyOrganizationConnection", "Owner")
+me = me + build_connection_graphql("SessionConnection", "Session")
+me = me + build_connection_graphql("UserTokenConnection", "UserToken")
+me_bindable = ObjectType("Me")
+
+
+@me_bindable.field("user")
+def resolve_user(user, _):
+ return user
+
+
+@me_bindable.field("owner")
+def resolve_owner(user, _):
+ """
+ Current user is also an owner in which we can fetch repositories
+ """
+ return user
+
+
+@me_bindable.field("viewableRepositories")
+@sentry_sdk.trace
+def resolve_viewable_repositories(
+ current_user,
+ info: GraphQLResolveInfo,
+ filters=None,
+ ordering=RepositoryOrdering.ID,
+ ordering_direction=OrderingDirection.ASC,
+ **kwargs,
+):
+ okta_authenticated_accounts: list[int] = info.context["request"].session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ )
+ is_impersonation = info.context["request"].impersonation
+ # If the user is impersonating another user, we want to show all the Okta repos.
+ # This means we do not want to filter out the Okta enforced repos
+ exclude_okta_enforced_repos = not is_impersonation
+
+ queryset = search_repos(
+ current_user, filters, okta_authenticated_accounts, exclude_okta_enforced_repos
+ )
+ return queryset_to_connection(
+ queryset,
+ ordering=(ordering, RepositoryOrdering.ID),
+ ordering_direction=ordering_direction,
+ **kwargs,
+ )
+
+
+@me_bindable.field("myOrganizations")
+def resolve_my_organizations(current_user, _, filters=None, **kwargs):
+ queryset = search_my_owners(current_user, filters)
+ return queryset_to_connection(
+ queryset,
+ ordering=("ownerid",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+
+@me_bindable.field("sessions")
+def resolve_sessions(current_user, _, **kwargs):
+ queryset = get_owner_login_sessions(current_user)
+ return queryset_to_connection(
+ queryset,
+ ordering=("sessionid",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+
+@me_bindable.field("tokens")
+def resolve_tokens(current_user, _, **kwargs):
+ queryset = get_user_tokens(current_user)
+ return queryset_to_connection(
+ queryset,
+ ordering=("created_at",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+
+@me_bindable.field("isSyncingWithGitProvider")
+def resolve_is_syncing_with_git_provider(_, info):
+ command = info.context["executor"].get_command("owner")
+ return command.is_syncing()
+
+
+@me_bindable.field("trackingMetadata")
+def resolve_tracking_data(current_user, _, **kwargs):
+ return current_user
+
+
+@me_bindable.field("termsAgreement")
+@sync_to_async
+def resolve_terms_agreement(current_owner: Owner, _, **kwargs) -> Optional[bool]:
+ if current_owner.user is None:
+ return None
+ return current_owner.user.terms_agreement
+
+
+@me_bindable.field("businessEmail")
+def resolve_business_email(current_owner: Owner, _, **kwargs) -> Optional[str]:
+ return current_owner.business_email
+
+
+@me_bindable.field("privateAccess")
+@sync_to_async
+def resolve_private_access(owner: Owner, info) -> bool:
+ if owner.private_access is None:
+ return False
+ return owner.private_access
+
+
+tracking_metadata_bindable = ObjectType("trackingMetadata")
+
+
+@tracking_metadata_bindable.field("profile")
+@sync_to_async
+def resolve_profile(owner: Owner, info) -> OwnerProfile:
+ try:
+ return owner.profile
+ except OwnerProfile.DoesNotExist:
+ return None
diff --git a/apps/codecov-api/graphql_api/types/measurement/__init__.py b/apps/codecov-api/graphql_api/types/measurement/__init__.py
new file mode 100644
index 0000000000..8ce7990a1b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/measurement/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .measurement import measurement_bindable
+
+measurement = ariadne_load_local_graphql(__file__, "measurement.graphql")
+
+
+__all__ = ["measurement_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/measurement/measurement.graphql b/apps/codecov-api/graphql_api/types/measurement/measurement.graphql
new file mode 100644
index 0000000000..0a13b57275
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/measurement/measurement.graphql
@@ -0,0 +1,6 @@
+type Measurement {
+ timestamp: DateTime!
+ avg: Float
+ min: Float
+ max: Float
+}
diff --git a/apps/codecov-api/graphql_api/types/measurement/measurement.py b/apps/codecov-api/graphql_api/types/measurement/measurement.py
new file mode 100644
index 0000000000..198c240125
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/measurement/measurement.py
@@ -0,0 +1,26 @@
+from datetime import datetime
+from typing import Mapping
+
+from ariadne import ObjectType
+
+measurement_bindable = ObjectType("Measurement")
+
+
+@measurement_bindable.field("timestamp")
+def resolve_timestamp(measurement: Mapping, info) -> datetime:
+ return measurement["timestamp_bin"]
+
+
+@measurement_bindable.field("avg")
+def resolve_avg(measurement: Mapping, info) -> float:
+ return measurement["avg"]
+
+
+@measurement_bindable.field("min")
+def resolve_min(measurement: Mapping, info) -> float:
+ return measurement["min"]
+
+
+@measurement_bindable.field("max")
+def resolve_max(measurement: Mapping, info) -> float:
+ return measurement["max"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/__init__.py b/apps/codecov-api/graphql_api/types/mutation/__init__.py
new file mode 100644
index 0000000000..24ddd033ff
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/__init__.py
@@ -0,0 +1,61 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .activate_measurements import gql_activate_measurements
+from .cancel_trial import gql_cancel_trial
+from .create_api_token import gql_create_api_token
+from .create_stripe_setup_intent import gql_create_stripe_setup_intent
+from .create_user_token import gql_create_user_token
+from .delete_component_measurements import gql_delete_component_measurements
+from .delete_flag import gql_delete_flag
+from .delete_session import gql_delete_session
+from .encode_secret_string import gql_encode_secret_string
+from .erase_repository import gql_erase_repository
+from .mutation import mutation_resolvers # noqa: F401
+from .onboard_user import gql_onboard_user
+from .regenerate_org_upload_token import gql_regenerate_org_upload_token
+from .regenerate_repository_token import gql_regenerate_repository_token
+from .regenerate_repository_upload_token import gql_regenerate_repository_upload_token
+from .revoke_user_token import gql_revoke_user_token
+from .save_okta_config import gql_save_okta_config
+from .save_sentry_state import gql_save_sentry_state
+from .save_terms_agreement import gql_save_terms_agreement
+from .set_upload_token_required import gql_set_upload_token_required
+from .set_yaml_on_owner import gql_set_yaml_on_owner
+from .start_trial import gql_start_trial
+from .store_event_metrics import gql_store_event_metrics
+from .sync_with_git_provider import gql_sync_with_git_provider
+from .update_bundle_cache_config import gql_update_bundle_cache_config
+from .update_default_organization import gql_update_default_organization
+from .update_profile import gql_update_profile
+from .update_repository import gql_update_repository
+from .update_self_hosted_settings import gql_update_self_hosted_settings
+
+mutation = ariadne_load_local_graphql(__file__, "mutation.graphql")
+mutation = mutation + gql_create_api_token
+mutation = mutation + gql_create_stripe_setup_intent
+mutation = mutation + gql_sync_with_git_provider
+mutation = mutation + gql_delete_session
+mutation = mutation + gql_set_yaml_on_owner
+mutation = mutation + gql_update_profile
+mutation = mutation + gql_update_default_organization
+mutation = mutation + gql_onboard_user
+mutation = mutation + gql_regenerate_repository_token
+mutation = mutation + gql_activate_measurements
+mutation = mutation + gql_regenerate_org_upload_token
+mutation = mutation + gql_create_user_token
+mutation = mutation + gql_revoke_user_token
+mutation = mutation + gql_delete_flag
+mutation = mutation + gql_save_sentry_state
+mutation = mutation + gql_save_terms_agreement
+mutation = mutation + gql_start_trial
+mutation = mutation + gql_cancel_trial
+mutation = mutation + gql_delete_component_measurements
+mutation = mutation + gql_erase_repository
+mutation = mutation + gql_update_repository
+mutation = mutation + gql_update_self_hosted_settings
+mutation = mutation + gql_regenerate_repository_upload_token
+mutation = mutation + gql_encode_secret_string
+mutation = mutation + gql_store_event_metrics
+mutation = mutation + gql_save_okta_config
+mutation = mutation + gql_set_upload_token_required
+mutation = mutation + gql_update_bundle_cache_config
diff --git a/apps/codecov-api/graphql_api/types/mutation/activate_measurements/__init__.py b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/__init__.py
new file mode 100644
index 0000000000..104281a26d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/__init__.py
@@ -0,0 +1,13 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .activate_measurements import (
+ error_activate_measurements,
+ resolve_activate_measurements,
+)
+
+gql_activate_measurements = ariadne_load_local_graphql(
+ __file__, "activate_measurements.graphql"
+)
+
+
+__all__ = ["error_activate_measurements", "resolve_activate_measurements"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.graphql b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.graphql
new file mode 100644
index 0000000000..7792a87f88
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.graphql
@@ -0,0 +1,5 @@
+union ActivateMeasurementsError = UnauthenticatedError | ValidationError
+
+type activateMeasurementsPayload {
+ error: ActivateMeasurementsError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.py b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.py
new file mode 100644
index 0000000000..88aad675b2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/activate_measurements/activate_measurements.py
@@ -0,0 +1,24 @@
+from ariadne import UnionType
+
+from core.commands.repository import RepositoryCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_activate_measurements(_, info, input):
+ command: RepositoryCommands = info.context["executor"].get_command("repository")
+ await command.activate_measurements(
+ owner_name=input.get("owner"),
+ repo_name=input.get("repo_name"),
+ measurement_type=input.get("measurement_type"),
+ )
+ return None
+
+
+error_activate_measurements = UnionType("ActivateMeasurementsError")
+error_activate_measurements.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/cancel_trial/__init__.py b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/__init__.py
new file mode 100644
index 0000000000..826a34ff61
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .cancel_trial import error_cancel_trial, resolve_cancel_trial
+
+gql_cancel_trial = ariadne_load_local_graphql(__file__, "cancel_trial.graphql")
+
+
+__all__ = ["error_cancel_trial", "resolve_cancel_trial"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.graphql b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.graphql
new file mode 100644
index 0000000000..ec0fc953fa
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.graphql
@@ -0,0 +1,5 @@
+union CancelTrialError = UnauthenticatedError | ValidationError
+
+type CancelTrialPayload {
+ error: CancelTrialError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.py b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.py
new file mode 100644
index 0000000000..b6f0f59d36
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/cancel_trial/cancel_trial.py
@@ -0,0 +1,20 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_cancel_trial(_, info, input) -> None:
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ await command.cancel_trial(input.get("org_username"))
+ return None
+
+
+error_cancel_trial = UnionType("CancelTrialError")
+error_cancel_trial.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_api_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/create_api_token/__init__.py
new file mode 100644
index 0000000000..7b1ff06527
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_api_token/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .create_api_token import error_create_api_token, resolve_create_api_token
+
+gql_create_api_token = ariadne_load_local_graphql(__file__, "create_api_token.graphql")
+
+
+__all__ = ["error_create_api_token", "resolve_create_api_token"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.graphql b/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.graphql
new file mode 100644
index 0000000000..7068fcd789
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.graphql
@@ -0,0 +1,7 @@
+union CreateApiTokenError = UnauthenticatedError | ValidationError
+
+type CreateApiTokenPayload {
+ error: CreateApiTokenError
+ session: Session
+ fullToken: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.py b/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.py
new file mode 100644
index 0000000000..74f7a5a673
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_api_token/create_api_token.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_create_api_token(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ session = await command.create_api_token(input.get("name"))
+ return {"session": session, "full_token": session.token}
+
+
+error_create_api_token = UnionType("CreateApiTokenError")
+error_create_api_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/__init__.py b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/__init__.py
new file mode 100644
index 0000000000..967ae52579
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .create_stripe_setup_intent import (
+ error_create_stripe_setup_intent,
+ resolve_create_stripe_setup_intent,
+)
+
+gql_create_stripe_setup_intent = ariadne_load_local_graphql(
+ __file__, "create_stripe_setup_intent.graphql"
+)
+
+__all__ = ["error_create_stripe_setup_intent", "resolve_create_stripe_setup_intent"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.graphql b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.graphql
new file mode 100644
index 0000000000..9f33c57626
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.graphql
@@ -0,0 +1,6 @@
+union CreateStripeSetupIntentError = UnauthenticatedError | UnauthorizedError | ValidationError
+
+type CreateStripeSetupIntentPayload {
+ error: CreateStripeSetupIntentError
+ clientSecret: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.py b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.py
new file mode 100644
index 0000000000..0b1f0590e2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_stripe_setup_intent/create_stripe_setup_intent.py
@@ -0,0 +1,24 @@
+from typing import Any, Dict
+
+from ariadne import UnionType
+from ariadne.types import GraphQLResolveInfo
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_create_stripe_setup_intent(
+ _: Any, info: GraphQLResolveInfo, input: Dict[str, str]
+) -> Dict[str, str]:
+ command = info.context["executor"].get_command("owner")
+ resp = await command.create_stripe_setup_intent(input.get("owner"))
+ return {
+ "client_secret": resp["client_secret"],
+ }
+
+
+error_create_stripe_setup_intent = UnionType("CreateStripeSetupIntentError")
+error_create_stripe_setup_intent.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_user_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/create_user_token/__init__.py
new file mode 100644
index 0000000000..dd8b1a10b2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_user_token/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .create_user_token import error_create_user_token, resolve_create_user_token
+
+gql_create_user_token = ariadne_load_local_graphql(
+ __file__, "create_user_token.graphql"
+)
+
+__all__ = ["error_create_user_token", "resolve_create_user_token"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.graphql b/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.graphql
new file mode 100644
index 0000000000..463a8394d9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.graphql
@@ -0,0 +1,7 @@
+union CreateUserTokenError = UnauthenticatedError | ValidationError
+
+type CreateUserTokenPayload {
+ error: CreateUserTokenError
+ token: UserToken
+ fullToken: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.py b/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.py
new file mode 100644
index 0000000000..b012bd96c8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/create_user_token/create_user_token.py
@@ -0,0 +1,23 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_create_user_token(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ user_token = await command.create_user_token(
+ name=input.get("name"),
+ token_type=input.get("token_type"),
+ )
+ return {
+ "token": user_token,
+ "full_token": user_token.token,
+ }
+
+
+error_create_user_token = UnionType("CreateUserTokenError")
+error_create_user_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/__init__.py b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/__init__.py
new file mode 100644
index 0000000000..656d8689c0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/__init__.py
@@ -0,0 +1,16 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .delete_component_measurements import (
+ error_delete_component_measurements,
+ resolve_delete_component_measurements,
+)
+
+gql_delete_component_measurements = ariadne_load_local_graphql(
+ __file__, "delete_component_measurements.graphql"
+)
+
+
+__all__ = [
+ "error_delete_component_measurements",
+ "resolve_delete_component_measurements",
+]
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.graphql b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.graphql
new file mode 100644
index 0000000000..ca23d438e3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.graphql
@@ -0,0 +1,5 @@
+union DeleteComponentMeasurementsError = UnauthenticatedError | UnauthorizedError | ValidationError | NotFoundError
+
+type DeleteComponentMeasurementsPayload {
+ error: DeleteComponentMeasurementsError
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.py b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.py
new file mode 100644
index 0000000000..44aa4cfd3e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_component_measurements/delete_component_measurements.py
@@ -0,0 +1,23 @@
+from ariadne import UnionType
+from asgiref.sync import sync_to_async
+
+from core.commands.component import ComponentCommands
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@sync_to_async
+def resolve_delete_component_measurements(_, info, input):
+ command: ComponentCommands = info.context["executor"].get_command("component")
+ command.delete_component_measurements(
+ owner_username=input["owner_username"],
+ repo_name=input["repo_name"],
+ component_id=input["component_id"],
+ )
+
+
+error_delete_component_measurements = UnionType("DeleteComponentMeasurementsError")
+error_delete_component_measurements.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_flag/__init__.py b/apps/codecov-api/graphql_api/types/mutation/delete_flag/__init__.py
new file mode 100644
index 0000000000..b4aead7635
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_flag/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .delete_flag import error_delete_flag, resolve_delete_flag
+
+gql_delete_flag = ariadne_load_local_graphql(__file__, "delete_flag.graphql")
+
+
+__all__ = ["error_delete_flag", "resolve_delete_flag"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.graphql b/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.graphql
new file mode 100644
index 0000000000..04745fe7b4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.graphql
@@ -0,0 +1,5 @@
+union DeleteFlagError = UnauthenticatedError | UnauthorizedError | ValidationError | NotFoundError
+
+type DeleteFlagPayload {
+ error: DeleteFlagError
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.py b/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.py
new file mode 100644
index 0000000000..ed8b72a2b8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_flag/delete_flag.py
@@ -0,0 +1,23 @@
+from ariadne import UnionType
+from asgiref.sync import sync_to_async
+
+from core.commands.flag import FlagCommands
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@sync_to_async
+def resolve_delete_flag(_, info, input):
+ command: FlagCommands = info.context["executor"].get_command("flag")
+ command.delete_flag(
+ owner_username=input["owner_username"],
+ repo_name=input["repo_name"],
+ flag_name=input["flag_name"],
+ )
+
+
+error_delete_flag = UnionType("DeleteFlagError")
+error_delete_flag.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_session/__init__.py b/apps/codecov-api/graphql_api/types/mutation/delete_session/__init__.py
new file mode 100644
index 0000000000..a33094e0ca
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_session/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .delete_session import error_delete_session, resolve_delete_session
+
+gql_delete_session = ariadne_load_local_graphql(__file__, "delete_session.graphql")
+
+
+__all__ = ["error_delete_session", "resolve_delete_session"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.graphql b/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.graphql
new file mode 100644
index 0000000000..52406ba2a3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.graphql
@@ -0,0 +1,5 @@
+union DeleteSessionError = UnauthenticatedError
+
+type DeleteSessionPayload {
+ error: DeleteSessionError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.py b/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.py
new file mode 100644
index 0000000000..167d666b68
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/delete_session/delete_session.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_delete_session(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ await command.delete_session(input.get("sessionid"))
+ return None
+
+
+error_delete_session = UnionType("DeleteSessionError")
+error_delete_session.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/__init__.py b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/__init__.py
new file mode 100644
index 0000000000..110dd77c94
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .encode_secret_string import (
+ error_encode_secret_string,
+ resolve_encode_secret_string,
+)
+
+gql_encode_secret_string = ariadne_load_local_graphql(
+ __file__, "encode_secret_string.graphql"
+)
+
+__all__ = ["error_encode_secret_string", "resolve_encode_secret_string"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.graphql b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.graphql
new file mode 100644
index 0000000000..03662a7167
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.graphql
@@ -0,0 +1,6 @@
+union EncodeSecretStringError = ValidationError | UnauthenticatedError
+
+type EncodeSecretStringPayload {
+ error: EncodeSecretStringError
+ value: String
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.py b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.py
new file mode 100644
index 0000000000..caaa1e5be9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/encode_secret_string/encode_secret_string.py
@@ -0,0 +1,24 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_encode_secret_string(_, info, input) -> None:
+ command = info.context["executor"].get_command("repository")
+ repo_name = input.get("repo_name")
+ value = input.get("value")
+ current_owner = info.context["request"].current_owner
+ value = command.encode_secret_string(
+ repo_name=repo_name, owner=current_owner, value=value
+ )
+ return {"value": value}
+
+
+error_encode_secret_string = UnionType("EraseRepositoryError")
+error_encode_secret_string.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/erase_repository/__init__.py b/apps/codecov-api/graphql_api/types/mutation/erase_repository/__init__.py
new file mode 100644
index 0000000000..1ad6b3e491
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/erase_repository/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .erase_repository import error_erase_repository, resolve_erase_repository
+
+gql_erase_repository = ariadne_load_local_graphql(__file__, "erase_repository.graphql")
+
+
+__all__ = ["error_erase_repository", "resolve_erase_repository"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.graphql b/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.graphql
new file mode 100644
index 0000000000..e7025a58fa
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.graphql
@@ -0,0 +1,10 @@
+union EraseRepositoryError = UnauthorizedError | ValidationError | UnauthenticatedError
+
+type EraseRepositoryPayload {
+ error: EraseRepositoryError
+}
+
+input EraseRepositoryInput {
+ owner: String
+ repoName: String!
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.py b/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.py
new file mode 100644
index 0000000000..d321ce611f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/erase_repository/erase_repository.py
@@ -0,0 +1,29 @@
+from typing import Any
+
+from ariadne import UnionType
+from graphql import GraphQLResolveInfo
+
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_erase_repository(
+ _: Any, info: GraphQLResolveInfo, input: dict[str, Any]
+) -> None:
+ command = info.context["executor"].get_command("repository")
+ current_owner = info.context["request"].current_owner
+
+ owner_username = input.get("owner") or current_owner.username
+ repo_name = input.get("repo_name")
+
+ await command.erase_repository(owner_username, repo_name)
+ return None
+
+
+error_erase_repository = UnionType("EraseRepositoryError")
+error_erase_repository.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/mutation.graphql b/apps/codecov-api/graphql_api/types/mutation/mutation.graphql
new file mode 100644
index 0000000000..0c91bc768c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/mutation.graphql
@@ -0,0 +1,44 @@
+type Mutation {
+ createApiToken(input: CreateApiTokenInput!): CreateApiTokenPayload
+ createStripeSetupIntent(input: CreateStripeSetupIntentInput!): CreateStripeSetupIntentPayload
+ createUserToken(input: CreateUserTokenInput!): CreateUserTokenPayload
+ revokeUserToken(input: RevokeUserTokenInput!): RevokeUserTokenPayload
+ setYamlOnOwner(input: SetYamlOnOwnerInput!): SetYamlOnOwnerPayload
+ startTrial(input: StartTrialInput!): StartTrialPayload
+ cancelTrial(input: CancelTrialInput!): CancelTrialPayload
+ syncWithGitProvider: SyncWithGitProviderPayload
+ updateProfile(input: UpdateProfileInput!): UpdateProfilePayload
+ updateDefaultOrganization(
+ input: UpdateDefaultOrganizationInput!
+ ): UpdateDefaultOrganizationPayload
+ deleteSession(input: DeleteSessionInput!): DeleteSessionPayload
+ onboardUser(input: OnboardUserInput!): OnboardUserPayload
+ regenerateRepositoryToken(
+ input: RegenerateRepositoryTokenInput!
+ ): RegenerateRepositoryTokenPayload
+ activateMeasurements(
+ input: ActivateMeasurementsInput!
+ ): activateMeasurementsPayload
+ regenerateOrgUploadToken(
+ input: RegenerateOrgUploadTokenInput!
+ ): RegenerateOrgUploadTokenPayload
+ deleteFlag(input: DeleteFlagInput!): DeleteFlagPayload
+ saveSentryState(input: SaveSentryStateInput!): SaveSentryStatePayload
+ saveTermsAgreement(input: SaveTermsAgreementInput!): SaveTermsAgreementPayload
+ deleteComponentMeasurements(
+ input: DeleteComponentMeasurementsInput!
+ ): DeleteComponentMeasurementsPayload
+ eraseRepository(input: EraseRepositoryInput!): EraseRepositoryPayload
+ updateRepository(input: UpdateRepositoryInput!): UpdateRepositoryPayload
+ updateSelfHostedSettings(
+ input: UpdateSelfHostedSettingsInput!
+ ): UpdateSelfHostedSettingsPayload
+ regenerateRepositoryUploadToken(
+ input: RegenerateRepositoryUploadTokenInput!
+ ): RegenerateRepositoryUploadTokenPayload
+ encodeSecretString(input: EncodeSecretStringInput!): EncodeSecretStringPayload
+ storeEventMetric(input: StoreEventMetricsInput!): StoreEventMetricsPayload
+ saveOktaConfig(input: SaveOktaConfigInput!): SaveOktaConfigPayload
+ setUploadTokenRequired(input: SetUploadTokenRequiredInput!): SetUploadTokenRequiredPayload
+ updateBundleCacheConfig(input: UpdateBundleCacheConfigInput!): UpdateBundleCacheConfigPayload
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/mutation.py b/apps/codecov-api/graphql_api/types/mutation/mutation.py
new file mode 100644
index 0000000000..ecf39f05d2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/mutation.py
@@ -0,0 +1,143 @@
+from ariadne import MutationType
+
+from .activate_measurements import (
+ error_activate_measurements,
+ resolve_activate_measurements,
+)
+from .cancel_trial import error_cancel_trial, resolve_cancel_trial
+from .create_api_token import error_create_api_token, resolve_create_api_token
+from .create_stripe_setup_intent import (
+ error_create_stripe_setup_intent,
+ resolve_create_stripe_setup_intent,
+)
+from .create_user_token import error_create_user_token, resolve_create_user_token
+from .delete_component_measurements import (
+ error_delete_component_measurements,
+ resolve_delete_component_measurements,
+)
+from .delete_flag import error_delete_flag, resolve_delete_flag
+from .delete_session import error_delete_session, resolve_delete_session
+from .encode_secret_string import (
+ error_encode_secret_string,
+ resolve_encode_secret_string,
+)
+from .erase_repository import error_erase_repository, resolve_erase_repository
+from .onboard_user import error_onboard_user, resolve_onboard_user
+from .regenerate_org_upload_token import (
+ error_generate_org_upload_token,
+ resolve_regenerate_org_upload_token,
+)
+from .regenerate_repository_token import (
+ error_regenerate_repository_token,
+ resolve_regenerate_repository_token,
+)
+from .regenerate_repository_upload_token import (
+ error_regenerate_repository_upload_token,
+ resolve_regenerate_repository_upload_token,
+)
+from .revoke_user_token import error_revoke_user_token, resolve_revoke_user_token
+from .save_okta_config import error_save_okta_config, resolve_save_okta_config
+from .save_sentry_state import error_save_sentry_state, resolve_save_sentry_state
+from .save_terms_agreement import (
+ error_save_terms_agreement,
+ resolve_save_terms_agreement,
+)
+from .set_upload_token_required import (
+ error_set_upload_token_required,
+ resolve_set_upload_token_required,
+)
+from .set_yaml_on_owner import error_set_yaml_error, resolve_set_yaml_on_owner
+from .start_trial import error_start_trial, resolve_start_trial
+from .store_event_metrics import error_store_event_metrics, resolve_store_event_metrics
+from .sync_with_git_provider import (
+ error_sync_with_git_provider,
+ resolve_sync_with_git_provider,
+)
+from .update_bundle_cache_config import (
+ error_update_bundle_cache_config,
+ resolve_update_bundle_cache_config,
+)
+from .update_default_organization import (
+ error_update_default_organization,
+ resolve_update_default_organization,
+)
+from .update_profile import error_update_profile, resolve_update_profile
+from .update_repository import error_update_repository, resolve_update_repository
+from .update_self_hosted_settings import (
+ error_update_self_hosted_settings,
+ resolve_update_self_hosted_settings,
+)
+
+mutation_bindable = MutationType()
+
+# Here, bind the resolvers from each subfolder to the Mutation type
+mutation_bindable.field("createApiToken")(resolve_create_api_token)
+mutation_bindable.field("createStripeSetupIntent")(resolve_create_stripe_setup_intent)
+mutation_bindable.field("createUserToken")(resolve_create_user_token)
+mutation_bindable.field("revokeUserToken")(resolve_revoke_user_token)
+mutation_bindable.field("setYamlOnOwner")(resolve_set_yaml_on_owner)
+mutation_bindable.field("syncWithGitProvider")(resolve_sync_with_git_provider)
+mutation_bindable.field("deleteSession")(resolve_delete_session)
+mutation_bindable.field("updateProfile")(resolve_update_profile)
+mutation_bindable.field("updateDefaultOrganization")(
+ resolve_update_default_organization
+)
+mutation_bindable.field("onboardUser")(resolve_onboard_user)
+mutation_bindable.field("regenerateRepositoryToken")(
+ resolve_regenerate_repository_token
+)
+mutation_bindable.field("activateMeasurements")(resolve_activate_measurements)
+mutation_bindable.field("regenerateOrgUploadToken")(resolve_regenerate_org_upload_token)
+mutation_bindable.field("deleteFlag")(resolve_delete_flag)
+mutation_bindable.field("saveSentryState")(resolve_save_sentry_state)
+mutation_bindable.field("saveTermsAgreement")(resolve_save_terms_agreement)
+mutation_bindable.field("startTrial")(resolve_start_trial)
+mutation_bindable.field("cancelTrial")(resolve_cancel_trial)
+mutation_bindable.field("deleteComponentMeasurements")(
+ resolve_delete_component_measurements
+)
+mutation_bindable.field("eraseRepository")(resolve_erase_repository)
+mutation_bindable.field("updateRepository")(resolve_update_repository)
+mutation_bindable.field("updateSelfHostedSettings")(resolve_update_self_hosted_settings)
+mutation_bindable.field("regenerateRepositoryUploadToken")(
+ resolve_regenerate_repository_upload_token
+)
+mutation_bindable.field("encodeSecretString")(resolve_encode_secret_string)
+
+mutation_bindable.field("storeEventMetric")(resolve_store_event_metrics)
+
+mutation_bindable.field("saveOktaConfig")(resolve_save_okta_config)
+mutation_bindable.field("setUploadTokenRequired")(resolve_set_upload_token_required)
+mutation_bindable.field("updateBundleCacheConfig")(resolve_update_bundle_cache_config)
+
+mutation_resolvers = [
+ mutation_bindable,
+ error_create_api_token,
+ error_create_stripe_setup_intent,
+ error_create_user_token,
+ error_revoke_user_token,
+ error_set_yaml_error,
+ error_sync_with_git_provider,
+ error_delete_session,
+ error_update_profile,
+ error_update_default_organization,
+ error_onboard_user,
+ error_regenerate_repository_token,
+ error_activate_measurements,
+ error_generate_org_upload_token,
+ error_delete_component_measurements,
+ error_delete_flag,
+ error_save_sentry_state,
+ error_save_terms_agreement,
+ error_start_trial,
+ error_cancel_trial,
+ error_erase_repository,
+ error_update_repository,
+ error_update_self_hosted_settings,
+ error_regenerate_repository_upload_token,
+ error_encode_secret_string,
+ error_store_event_metrics,
+ error_save_okta_config,
+ error_set_upload_token_required,
+ error_update_bundle_cache_config,
+]
diff --git a/apps/codecov-api/graphql_api/types/mutation/onboard_user/__init__.py b/apps/codecov-api/graphql_api/types/mutation/onboard_user/__init__.py
new file mode 100644
index 0000000000..cd6db9f451
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/onboard_user/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .onboard_user import error_onboard_user, resolve_onboard_user
+
+gql_onboard_user = ariadne_load_local_graphql(__file__, "onboard_user.graphql")
+
+
+__all__ = ["error_onboard_user", "resolve_onboard_user"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.graphql b/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.graphql
new file mode 100644
index 0000000000..66b931e77f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.graphql
@@ -0,0 +1,6 @@
+union OnboardUserError = UnauthenticatedError | UnauthorizedError | ValidationError
+
+type OnboardUserPayload {
+ error: OnboardUserError
+ me: Me
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.py b/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.py
new file mode 100644
index 0000000000..39ab1aaf1e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/onboard_user/onboard_user.py
@@ -0,0 +1,18 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_onboard_user(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ input["goals"] = [goal.value for goal in input.get("goals", [])]
+ input["type_projects"] = [goal.value for goal in input.get("type_projects", [])]
+ return {"me": await command.onboard_user(input)}
+
+
+error_onboard_user = UnionType("OnboardUserError")
+error_onboard_user.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/__init__.py
new file mode 100644
index 0000000000..111d89fc80
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .regenerate_org_upload_token import (
+ error_generate_org_upload_token,
+ resolve_regenerate_org_upload_token,
+)
+
+gql_regenerate_org_upload_token = ariadne_load_local_graphql(
+ __file__, "regenerate_org_upload_token.graphql"
+)
+
+__all__ = ["error_generate_org_upload_token", "resolve_regenerate_org_upload_token"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.graphql b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.graphql
new file mode 100644
index 0000000000..31a73026b7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.graphql
@@ -0,0 +1,6 @@
+union RegenerateOrgUploadTokenError = UnauthenticatedError | ValidationError | UnauthorizedError
+
+type RegenerateOrgUploadTokenPayload {
+ orgUploadToken: String
+ error: RegenerateOrgUploadTokenError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.py
new file mode 100644
index 0000000000..e5f2523c80
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_org_upload_token/regenerate_org_upload_token.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_regenerate_org_upload_token(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ orgUploadToken = await command.regenerate_org_upload_token(owner=input.get("owner"))
+ return {"org_upload_token": orgUploadToken}
+
+
+error_generate_org_upload_token = UnionType("RegenerateOrgUploadTokenError")
+error_generate_org_upload_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/__init__.py
new file mode 100644
index 0000000000..10cb2852e8
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/__init__.py
@@ -0,0 +1,13 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .regenerate_repository_token import (
+ error_regenerate_repository_token,
+ resolve_regenerate_repository_token,
+)
+
+gql_regenerate_repository_token = ariadne_load_local_graphql(
+ __file__, "regenerate_repository_token.graphql"
+)
+
+
+__all__ = ["error_regenerate_repository_token", "resolve_regenerate_repository_token"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.graphql b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.graphql
new file mode 100644
index 0000000000..d6b102d971
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.graphql
@@ -0,0 +1,6 @@
+union RegenerateRepositoryTokenError = UnauthenticatedError | ValidationError
+
+type RegenerateRepositoryTokenPayload {
+ token: String
+ error: RegenerateRepositoryTokenError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.py
new file mode 100644
index 0000000000..0b592c2701
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_token/regenerate_repository_token.py
@@ -0,0 +1,25 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_regenerate_repository_token(_, info, input):
+ command = info.context["executor"].get_command("repository")
+
+ token = await command.regenerate_repository_token(
+ repo_name=input.get("repo_name"),
+ owner_username=input.get("owner"),
+ token_type=input.get("token_type"),
+ )
+
+ return {"token": token}
+
+
+error_regenerate_repository_token = UnionType("RegenerateRepositoryTokenError")
+error_regenerate_repository_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/__init__.py
new file mode 100644
index 0000000000..93f0511986
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/__init__.py
@@ -0,0 +1,16 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .regenerate_repository_upload_token import (
+ error_regenerate_repository_upload_token,
+ resolve_regenerate_repository_upload_token,
+)
+
+gql_regenerate_repository_upload_token = ariadne_load_local_graphql(
+ __file__, "regenerate_repository_upload_token.graphql"
+)
+
+
+__all__ = [
+ "error_regenerate_repository_upload_token",
+ "resolve_regenerate_repository_upload_token",
+]
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.graphql b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.graphql
new file mode 100644
index 0000000000..e4e52b98ff
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.graphql
@@ -0,0 +1,6 @@
+union RegenerateRepositoryUploadTokenError = ValidationError
+
+type RegenerateRepositoryUploadTokenPayload {
+ token: String
+ error: ValidationError
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.py b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.py
new file mode 100644
index 0000000000..0d6a6a8e99
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/regenerate_repository_upload_token/regenerate_repository_upload_token.py
@@ -0,0 +1,32 @@
+import uuid
+from typing import Any, Dict
+
+from ariadne import UnionType
+from graphql import GraphQLResolveInfo
+
+from core.commands.repository.repository import RepositoryCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_regenerate_repository_upload_token(
+ _: Any, info: GraphQLResolveInfo, input: Dict[str, str]
+) -> Dict[str, uuid.UUID]:
+ command: RepositoryCommands = info.context["executor"].get_command("repository")
+ token = await command.regenerate_repository_upload_token(
+ repo_name=input.get("repo_name", ""),
+ owner_username=input.get("owner", ""),
+ )
+
+ return {"token": token}
+
+
+error_regenerate_repository_upload_token = UnionType(
+ "RegenerateRepositoryUploadTokenError"
+)
+error_regenerate_repository_upload_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/__init__.py b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/__init__.py
new file mode 100644
index 0000000000..cf6f8fd43c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/__init__.py
@@ -0,0 +1,10 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .revoke_user_token import error_revoke_user_token, resolve_revoke_user_token
+
+gql_revoke_user_token = ariadne_load_local_graphql(
+ __file__, "revoke_user_token.graphql"
+)
+
+
+__all__ = ["error_revoke_user_token", "resolve_revoke_user_token"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.graphql b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.graphql
new file mode 100644
index 0000000000..305cb61fd2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.graphql
@@ -0,0 +1,5 @@
+union RevokeUserTokenError = UnauthenticatedError
+
+type RevokeUserTokenPayload {
+ error: RevokeUserTokenError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.py b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.py
new file mode 100644
index 0000000000..e9dd49fe66
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/revoke_user_token/revoke_user_token.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_revoke_user_token(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ await command.revoke_user_token(input.get("tokenid"))
+ return None
+
+
+error_revoke_user_token = UnionType("RevokeUserTokenError")
+error_revoke_user_token.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_okta_config/__init__.py b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/__init__.py
new file mode 100644
index 0000000000..15fd54e5f2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .save_okta_config import error_save_okta_config, resolve_save_okta_config
+
+gql_save_okta_config = ariadne_load_local_graphql(__file__, "save_okta_config.graphql")
+
+
+__all__ = ["error_save_okta_config", "resolve_save_okta_config"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.graphql b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.graphql
new file mode 100644
index 0000000000..2e57fedf59
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.graphql
@@ -0,0 +1,17 @@
+union SaveOktaConfigError =
+ UnauthenticatedError
+ | UnauthorizedError
+ | ValidationError
+
+type SaveOktaConfigPayload {
+ error: SaveOktaConfigError
+}
+
+input SaveOktaConfigInput {
+ clientId: String
+ clientSecret: String
+ url: String
+ enabled: Boolean
+ enforced: Boolean
+ orgUsername: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.py b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.py
new file mode 100644
index 0000000000..8413f40667
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_okta_config/save_okta_config.py
@@ -0,0 +1,23 @@
+from typing import Any, Dict
+
+from ariadne import UnionType
+from graphql import GraphQLResolveInfo
+
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_save_okta_config(
+ _: Any, info: GraphQLResolveInfo, input: Dict[str, Any]
+) -> None:
+ command = info.context["executor"].get_command("owner")
+ return await command.save_okta_config(input)
+
+
+error_save_okta_config = UnionType("SaveOktaConfigError")
+error_save_okta_config.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/__init__.py b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/__init__.py
new file mode 100644
index 0000000000..0d90e90e4f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .save_sentry_state import error_save_sentry_state, resolve_save_sentry_state
+
+gql_save_sentry_state = ariadne_load_local_graphql(
+ __file__, "save_sentry_state.graphql"
+)
+
+__all__ = ["error_save_sentry_state", "resolve_save_sentry_state"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.graphql b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.graphql
new file mode 100644
index 0000000000..a287f256fd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.graphql
@@ -0,0 +1,5 @@
+union SaveSentryStateError = UnauthenticatedError | ValidationError
+
+type SaveSentryStatePayload {
+ error: SaveSentryStateError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.py b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.py
new file mode 100644
index 0000000000..8db5749479
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_sentry_state/save_sentry_state.py
@@ -0,0 +1,27 @@
+from ariadne import UnionType
+from asgiref.sync import sync_to_async
+
+import services.sentry as sentry
+from codecov.commands.exceptions import ValidationError
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+@sync_to_async
+def resolve_save_sentry_state(_, info, input):
+ current_owner = info.context["request"].current_owner
+ try:
+ sentry.save_sentry_state(current_owner, input.get("state"))
+ except sentry.SentryInvalidStateError:
+ raise ValidationError("Invalid state")
+ except sentry.SentryUserAlreadyExistsError:
+ raise ValidationError("Invalid Sentry user")
+
+
+error_save_sentry_state = UnionType("SaveSentryStateError")
+error_save_sentry_state.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/__init__.py b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/__init__.py
new file mode 100644
index 0000000000..d65cf7c938
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .save_terms_agreement import (
+ error_save_terms_agreement,
+ resolve_save_terms_agreement,
+)
+
+gql_save_terms_agreement = ariadne_load_local_graphql(
+ __file__, "save_terms_agreement.graphql"
+)
+
+__all__ = ["error_save_terms_agreement", "resolve_save_terms_agreement"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql
new file mode 100644
index 0000000000..b3fdf5027e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql
@@ -0,0 +1,13 @@
+union SaveTermsAgreementError = UnauthenticatedError | ValidationError
+
+type SaveTermsAgreementPayload {
+ error: SaveTermsAgreementError
+}
+
+input SaveTermsAgreementInput {
+ businessEmail: String
+ termsAgreement: Boolean!
+ marketingConsent: Boolean
+ name: String
+ customerIntent: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.py b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.py
new file mode 100644
index 0000000000..413adbdaa7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.py
@@ -0,0 +1,19 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_save_terms_agreement(_, info, input):
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ return await command.save_terms_agreement(input)
+
+
+error_save_terms_agreement = UnionType("SaveTermsAgreementError")
+error_save_terms_agreement.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/__init__.py b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/__init__.py
new file mode 100644
index 0000000000..55723e1e3c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .set_upload_token_required import (
+ error_set_upload_token_required,
+ resolve_set_upload_token_required,
+)
+
+gql_set_upload_token_required = ariadne_load_local_graphql(
+ __file__, "set_upload_token_required.graphql"
+)
+
+__all__ = ["error_set_upload_token_required", "resolve_set_upload_token_required"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql
new file mode 100644
index 0000000000..df5eec4197
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql
@@ -0,0 +1,13 @@
+union SetUploadTokenRequiredError =
+ UnauthenticatedError
+ | UnauthorizedError
+ | ValidationError
+
+type SetUploadTokenRequiredPayload {
+ error: SetUploadTokenRequiredError
+}
+
+input SetUploadTokenRequiredInput {
+ orgUsername: String!
+ uploadTokenRequired: Boolean!
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py
new file mode 100644
index 0000000000..6e686a9ebb
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py
@@ -0,0 +1,19 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_set_upload_token_required(_, info, input):
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ return await command.set_upload_token_required(input)
+
+
+error_set_upload_token_required = UnionType("SetUploadTokenRequiredError")
+error_set_upload_token_required.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/__init__.py b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/__init__.py
new file mode 100644
index 0000000000..1d5a8aaa10
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .set_yaml_on_owner import error_set_yaml_error, resolve_set_yaml_on_owner
+
+gql_set_yaml_on_owner = ariadne_load_local_graphql(
+ __file__, "set_yaml_on_owner.graphql"
+)
+
+__all__ = ["error_set_yaml_error", "resolve_set_yaml_on_owner"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.graphql b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.graphql
new file mode 100644
index 0000000000..ceca786271
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.graphql
@@ -0,0 +1,6 @@
+union SetYamlOnOwnerError = UnauthenticatedError | ValidationError | UnauthorizedError | NotFoundError
+
+type SetYamlOnOwnerPayload {
+ error: SetYamlOnOwnerError
+ owner: Owner
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.py b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.py
new file mode 100644
index 0000000000..2f3a6b04b3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/set_yaml_on_owner/set_yaml_on_owner.py
@@ -0,0 +1,19 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_set_yaml_on_owner(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ username_input = input.get("username")
+ yaml_input = input.get("yaml")
+ owner = await command.set_yaml_on_owner(username_input, yaml_input)
+ return {"owner": owner}
+
+
+error_set_yaml_error = UnionType("SetYamlOnOwnerError")
+error_set_yaml_error.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/start_trial/__init__.py b/apps/codecov-api/graphql_api/types/mutation/start_trial/__init__.py
new file mode 100644
index 0000000000..46192ad9a4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/start_trial/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .start_trial import error_start_trial, resolve_start_trial
+
+gql_start_trial = ariadne_load_local_graphql(__file__, "start_trial.graphql")
+
+
+__all__ = ["error_start_trial", "resolve_start_trial"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.graphql b/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.graphql
new file mode 100644
index 0000000000..a7571c5be4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.graphql
@@ -0,0 +1,5 @@
+union StartTrialError = UnauthenticatedError | ValidationError
+
+type StartTrialPayload {
+ error: StartTrialError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.py b/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.py
new file mode 100644
index 0000000000..034aedfd37
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/start_trial/start_trial.py
@@ -0,0 +1,20 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_start_trial(_, info, input) -> None:
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ await command.start_trial(input.get("org_username"))
+ return None
+
+
+error_start_trial = UnionType("StartTrialError")
+error_start_trial.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/__init__.py b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/__init__.py
new file mode 100644
index 0000000000..74abfb4f84
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/__init__.py
@@ -0,0 +1,10 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .store_event_metrics import error_store_event_metrics, resolve_store_event_metrics
+
+gql_store_event_metrics = ariadne_load_local_graphql(
+ __file__, "store_event_metrics.graphql"
+)
+
+
+__all__ = ["error_store_event_metrics", "resolve_store_event_metrics"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.graphql b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.graphql
new file mode 100644
index 0000000000..4a0faccc72
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.graphql
@@ -0,0 +1,11 @@
+union StoreEventMetricsError = UnauthenticatedError | ValidationError
+
+type StoreEventMetricsPayload {
+ error: StoreEventMetricsError
+}
+
+input StoreEventMetricsInput {
+ orgUsername: String!
+ eventName: String!
+ jsonPayload: String # The input expects a serialized json string
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.py b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.py
new file mode 100644
index 0000000000..e7c3d322da
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/store_event_metrics/store_event_metrics.py
@@ -0,0 +1,22 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_store_event_metrics(_, info, input) -> None:
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ await command.store_codecov_metric(
+ input.get("org_username"), input.get("event_name"), input.get("json_payload")
+ )
+ return None
+
+
+error_store_event_metrics = UnionType("StoreEventMetricsError")
+error_store_event_metrics.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/__init__.py b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/__init__.py
new file mode 100644
index 0000000000..8f5f43c266
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/__init__.py
@@ -0,0 +1,13 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .sync_with_git_provider import (
+ error_sync_with_git_provider,
+ resolve_sync_with_git_provider,
+)
+
+gql_sync_with_git_provider = ariadne_load_local_graphql(
+ __file__, "sync_with_git_provider.graphql"
+)
+
+
+__all__ = ["error_sync_with_git_provider", "resolve_sync_with_git_provider"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.graphql b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.graphql
new file mode 100644
index 0000000000..0443383dce
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.graphql
@@ -0,0 +1,6 @@
+union SyncWithGitProviderError = UnauthenticatedError
+
+type SyncWithGitProviderPayload {
+ me: Me
+ error: SyncWithGitProviderError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.py b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.py
new file mode 100644
index 0000000000..7dc999456e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/sync_with_git_provider/sync_with_git_provider.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_sync_with_git_provider(_, info):
+ command = info.context["executor"].get_command("owner")
+ await command.trigger_sync()
+ return {"me": info.context["request"].current_owner}
+
+
+error_sync_with_git_provider = UnionType("SyncWithGitProviderError")
+error_sync_with_git_provider.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/__init__.py b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/__init__.py
new file mode 100644
index 0000000000..c434620641
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/__init__.py
@@ -0,0 +1,15 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .update_bundle_cache_config import (
+ error_update_bundle_cache_config,
+ resolve_update_bundle_cache_config,
+)
+
+gql_update_bundle_cache_config = ariadne_load_local_graphql(
+ __file__, "update_bundle_cache_config.graphql"
+)
+
+__all__ = [
+ "error_update_bundle_cache_config",
+ "resolve_update_bundle_cache_config",
+]
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql
new file mode 100644
index 0000000000..e07c60530a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql
@@ -0,0 +1,12 @@
+union UpdateBundleCacheConfigError = UnauthenticatedError | ValidationError
+
+type UpdateBundleCacheConfigResult {
+ bundleName: String
+ isCached: Boolean
+ cacheConfig: Boolean
+}
+
+type UpdateBundleCacheConfigPayload {
+ results: [UpdateBundleCacheConfigResult!]
+ error: UpdateBundleCacheConfigError
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py
new file mode 100644
index 0000000000..ae13c64a25
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py
@@ -0,0 +1,30 @@
+from typing import Any, Dict, List
+
+from ariadne import UnionType
+from graphql import GraphQLResolveInfo
+
+from core.commands.repository.repository import RepositoryCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_update_bundle_cache_config(
+ _: Any, info: GraphQLResolveInfo, input: Dict[str, Any]
+) -> Dict[str, List[Dict[str, str | bool]]]:
+ command: RepositoryCommands = info.context["executor"].get_command("repository")
+
+ results = await command.update_bundle_cache_config(
+ repo_name=input.get("repo_name", ""),
+ owner_username=input.get("owner", ""),
+ cache_config=input.get("bundles", []),
+ )
+ return {"results": results}
+
+
+error_update_bundle_cache_config = UnionType("UpdateBundleCacheConfigError")
+error_update_bundle_cache_config.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_default_organization/__init__.py b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/__init__.py
new file mode 100644
index 0000000000..b0f7faccf4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/__init__.py
@@ -0,0 +1,13 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .update_default_organization import (
+ error_update_default_organization,
+ resolve_update_default_organization,
+)
+
+gql_update_default_organization = ariadne_load_local_graphql(
+ __file__, "update_default_organization.graphql"
+)
+
+
+__all__ = ["error_update_default_organization", "resolve_update_default_organization"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.graphql b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.graphql
new file mode 100644
index 0000000000..2dbe5cf426
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.graphql
@@ -0,0 +1,10 @@
+union UpdateDefaultOrganizationError = UnauthenticatedError | ValidationError | MissingService
+
+type UpdateDefaultOrganizationPayload {
+ error: UpdateDefaultOrganizationError
+ username: String
+}
+
+input UpdateDefaultOrganizationInput {
+ username: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.py b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.py
new file mode 100644
index 0000000000..429f622247
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_default_organization/update_default_organization.py
@@ -0,0 +1,20 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_update_default_organization(_, info, input):
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ default_username = await command.update_default_organization(
+ default_org_username=input.get("username")
+ )
+ return {"username": default_username}
+
+
+error_update_default_organization = UnionType("UpdateDefaultOrganizationError")
+error_update_default_organization.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_profile/__init__.py b/apps/codecov-api/graphql_api/types/mutation/update_profile/__init__.py
new file mode 100644
index 0000000000..86add287c4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_profile/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .update_profile import error_update_profile, resolve_update_profile
+
+gql_update_profile = ariadne_load_local_graphql(__file__, "update_profile.graphql")
+
+
+__all__ = ["error_update_profile", "resolve_update_profile"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.graphql b/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.graphql
new file mode 100644
index 0000000000..f26adacada
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.graphql
@@ -0,0 +1,11 @@
+union UpdateProfileError = UnauthenticatedError | ValidationError
+
+type UpdateProfilePayload {
+ error: CreateApiTokenError
+ me: Me
+}
+
+input UpdateProfileInput {
+ email: String
+ name: String
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.py b/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.py
new file mode 100644
index 0000000000..abd79a5a07
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_profile/update_profile.py
@@ -0,0 +1,17 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_update_profile(_, info, input):
+ command = info.context["executor"].get_command("owner")
+ me = await command.update_profile(email=input.get("email"), name=input.get("name"))
+ return {"me": me}
+
+
+error_update_profile = UnionType("UpdateProfileError")
+error_update_profile.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_repository/__init__.py b/apps/codecov-api/graphql_api/types/mutation/update_repository/__init__.py
new file mode 100644
index 0000000000..c9f3549acf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_repository/__init__.py
@@ -0,0 +1,10 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .update_repository import error_update_repository, resolve_update_repository
+
+gql_update_repository = ariadne_load_local_graphql(
+ __file__, "update_repository.graphql"
+)
+
+
+__all__ = ["error_update_repository", "resolve_update_repository"]
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.graphql b/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.graphql
new file mode 100644
index 0000000000..02127d0d04
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.graphql
@@ -0,0 +1,11 @@
+union UpdateRepositoryError = UnauthenticatedError | ValidationError | UnauthorizedError
+
+type UpdateRepositoryPayload {
+ error: UpdateRepositoryError
+}
+
+input UpdateRepositoryInput {
+ branch: String
+ activated: Boolean
+ repoName: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.py b/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.py
new file mode 100644
index 0000000000..75aa943a3f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_repository/update_repository.py
@@ -0,0 +1,25 @@
+from ariadne import UnionType
+
+from graphql_api.helpers.mutation import (
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+async def resolve_update_repository(_, info, input):
+ command = info.context["executor"].get_command("repository")
+ owner = info.context["request"].current_owner
+ repo_name = input.get("repo_name")
+ default_branch = input.get("branch")
+ activated = input.get("activated")
+ await command.update_repository(
+ repo_name,
+ owner,
+ default_branch,
+ activated,
+ )
+
+
+error_update_repository = UnionType("UpdateRepositoryError")
+error_update_repository.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/__init__.py b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/__init__.py
new file mode 100644
index 0000000000..dbb5299a6e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/__init__.py
@@ -0,0 +1,16 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .update_self_hosted_settings import (
+ error_update_self_hosted_settings,
+ resolve_update_self_hosted_settings,
+)
+
+gql_update_self_hosted_settings = ariadne_load_local_graphql(
+ __file__, "update_self_hosted_settings.graphql"
+)
+
+__all__ = [
+ "gql_update_self_hosted_settings",
+ "error_update_self_hosted_settings",
+ "resolve_update_self_hosted_settings",
+]
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.graphql b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.graphql
new file mode 100644
index 0000000000..e184f904b1
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.graphql
@@ -0,0 +1,9 @@
+union UpdateSelfHostedSettingsError = UnauthenticatedError | ValidationError
+
+type UpdateSelfHostedSettingsPayload {
+ error: UpdateSelfHostedSettingsError
+}
+
+input UpdateSelfHostedSettingsInput {
+ shouldAutoActivate: Boolean!
+}
diff --git a/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.py b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.py
new file mode 100644
index 0000000000..eaf23404b4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/mutation/update_self_hosted_settings/update_self_hosted_settings.py
@@ -0,0 +1,19 @@
+from ariadne import UnionType
+
+from codecov_auth.commands.owner import OwnerCommands
+from graphql_api.helpers.mutation import (
+ require_authenticated,
+ resolve_union_error_type,
+ wrap_error_handling_mutation,
+)
+
+
+@wrap_error_handling_mutation
+@require_authenticated
+async def resolve_update_self_hosted_settings(_, info, input):
+ command: OwnerCommands = info.context["executor"].get_command("owner")
+ return await command.update_self_hosted_settings(input)
+
+
+error_update_self_hosted_settings = UnionType("UpdateSelfHostedSettingsError")
+error_update_self_hosted_settings.type_resolver(resolve_union_error_type)
diff --git a/apps/codecov-api/graphql_api/types/okta_config/__init__.py b/apps/codecov-api/graphql_api/types/okta_config/__init__.py
new file mode 100644
index 0000000000..d34a90550d
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/okta_config/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .okta_config import okta_config_bindable
+
+okta_config = ariadne_load_local_graphql(__file__, "okta_config.graphql")
+
+
+__all__ = ["okta_config_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/okta_config/okta_config.graphql b/apps/codecov-api/graphql_api/types/okta_config/okta_config.graphql
new file mode 100644
index 0000000000..5cb6f4a4bf
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/okta_config/okta_config.graphql
@@ -0,0 +1,7 @@
+type OktaConfig {
+ clientId: String!
+ clientSecret: String!
+ url: String!
+ enabled: Boolean!
+ enforced: Boolean!
+}
diff --git a/apps/codecov-api/graphql_api/types/okta_config/okta_config.py b/apps/codecov-api/graphql_api/types/okta_config/okta_config.py
new file mode 100644
index 0000000000..a9b07ca289
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/okta_config/okta_config.py
@@ -0,0 +1,30 @@
+from ariadne import ObjectType
+
+from codecov_auth.models import OktaSettings
+
+okta_config_bindable = ObjectType("OktaConfig")
+
+
+@okta_config_bindable.field("clientId")
+def resolve_client_id(okta_config: OktaSettings, info) -> str:
+ return okta_config.client_id
+
+
+@okta_config_bindable.field("clientSecret")
+def resolve_client_secret(okta_config: OktaSettings, info) -> str:
+ return okta_config.client_secret
+
+
+@okta_config_bindable.field("url")
+def resolve_url(okta_config: OktaSettings, info) -> str:
+ return okta_config.url
+
+
+@okta_config_bindable.field("enabled")
+def resolve_enabled(okta_config: OktaSettings, info) -> bool:
+ return okta_config.enabled
+
+
+@okta_config_bindable.field("enforced")
+def resolve_enforced(okta_config: OktaSettings, info) -> bool:
+ return okta_config.enforced
diff --git a/apps/codecov-api/graphql_api/types/owner/__init__.py b/apps/codecov-api/graphql_api/types/owner/__init__.py
new file mode 100644
index 0000000000..a484f87370
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/owner/__init__.py
@@ -0,0 +1,6 @@
+from .owner import owner, owner_bindable
+
+__all__ = [
+ "owner",
+ "owner_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/owner/owner.graphql b/apps/codecov-api/graphql_api/types/owner/owner.graphql
new file mode 100644
index 0000000000..530b4cf050
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/owner/owner.graphql
@@ -0,0 +1,47 @@
+type Owner {
+ account: Account
+ availablePlans: [PlanRepresentation!]
+ avatarUrl: String!
+ billing: Billing
+ defaultOrgUsername: String
+ delinquent: Boolean
+ hashOwnerid: String
+ hasActiveRepos: Boolean
+ hasPrivateRepos: Boolean
+ hasPublicRepos: Boolean
+ invoice(invoiceId: String!): Invoice
+ invoices: [Invoice] @cost(complexity: 100)
+ isAdmin: Boolean
+ isCurrentUserActivated: Boolean
+ isCurrentUserPartOfOrg: Boolean!
+ isGithubRateLimited: Boolean
+ isUserOktaAuthenticated: Boolean
+ measurements(
+ interval: MeasurementInterval!
+ after: DateTime
+ before: DateTime
+ repos: [String!]
+ isPublic: Boolean
+ ): [Measurement!]
+ numberOfUploads: Int
+ orgUploadToken: String
+ ownerid: Int
+ plan: Plan
+ pretrialPlan: PlanRepresentation
+ repository(name: String!): RepositoryResult!
+ repositories(
+ filters: RepositorySetFilters
+ ordering: RepositoryOrdering
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): RepositoryConnection! @cost(complexity: 25, multipliers: ["first", "last"])
+ username: String
+ yaml: String
+ aiFeaturesEnabled: Boolean!
+ aiEnabledRepos: [String]
+ uploadTokenRequired: Boolean
+ activatedUserCount: Int
+}
diff --git a/apps/codecov-api/graphql_api/types/owner/owner.py b/apps/codecov-api/graphql_api/types/owner/owner.py
new file mode 100644
index 0000000000..c9bf8a72ae
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/owner/owner.py
@@ -0,0 +1,450 @@
+from datetime import datetime
+from hashlib import sha1
+from typing import Any, Coroutine, Iterable, List, Optional
+
+import sentry_sdk
+import shared.rate_limits as rate_limits
+import stripe
+import yaml
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from graphql import GraphQLResolveInfo
+from shared.helpers.redis import get_redis_connection
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.plan.service import PlanService
+
+import services.activation as activation
+import timeseries.helpers as timeseries_helpers
+from codecov_auth.constants import OWNER_YAML_TO_STRING_KEY
+from codecov_auth.helpers import current_user_part_of_org
+from codecov_auth.models import (
+ SERVICE_GITHUB,
+ SERVICE_GITHUB_ENTERPRISE,
+ Account,
+ GithubAppInstallation,
+ Owner,
+ Plan,
+)
+from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY
+from core.models import Repository
+from graphql_api.actions.repository import list_repository_for_owner
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+from graphql_api.helpers.connection import (
+ Connection,
+ build_connection_graphql,
+ queryset_to_connection_sync,
+)
+from graphql_api.helpers.mutation import (
+ require_part_of_org,
+ require_shared_account_or_part_of_org,
+)
+from graphql_api.helpers.requested_fields import selected_fields
+from graphql_api.types.enums import OrderingDirection, RepositoryOrdering
+from graphql_api.types.errors.errors import NotFoundError
+from graphql_api.types.repository.repository import TOKEN_UNAVAILABLE
+from services.billing import BillingService
+from timeseries.helpers import fill_sparse_measurements
+from timeseries.models import Interval
+from utils.config import get_config
+
+owner = ariadne_load_local_graphql(__file__, "owner.graphql")
+owner = owner + build_connection_graphql("RepositoryConnection", "Repository")
+owner_bindable = ObjectType("Owner")
+AI_FEATURES_GH_APP_ID = get_config("github", "ai_features_app_id")
+
+
+@owner_bindable.field("repositories")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_repositories(
+ owner: Owner,
+ info: GraphQLResolveInfo,
+ filters: Optional[dict] = None,
+ ordering: Optional[RepositoryOrdering] = RepositoryOrdering.ID,
+ ordering_direction: Optional[OrderingDirection] = OrderingDirection.ASC,
+ **kwargs: Any,
+) -> Coroutine[Any, Any, Connection]:
+ current_owner = info.context["request"].current_owner
+ okta_account_auths: list[int] = info.context["request"].session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ )
+
+ is_impersonation = info.context["request"].impersonation
+ # If the user is impersonating another user, we want to show all the Okta repos.
+ # This means we do not want to filter out the Okta enforced repos
+ exclude_okta_enforced_repos = not is_impersonation
+
+ queryset = list_repository_for_owner(
+ current_owner, owner, filters, okta_account_auths, exclude_okta_enforced_repos
+ )
+
+ return queryset_to_connection_sync(
+ queryset,
+ ordering=(ordering, RepositoryOrdering.ID),
+ ordering_direction=ordering_direction,
+ **kwargs,
+ )
+
+
+@owner_bindable.field("isCurrentUserPartOfOrg")
+@sync_to_async
+def resolve_is_current_user_part_of_org(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ current_owner = info.context["request"].current_owner
+ return current_user_part_of_org(current_owner, owner)
+
+
+@owner_bindable.field("yaml")
+def resolve_yaml(owner: Owner, info: GraphQLResolveInfo) -> Optional[str]:
+ if owner.yaml is None:
+ return None
+ current_owner = info.context["request"].current_owner
+ if not current_user_part_of_org(current_owner, owner):
+ return None
+ return owner.yaml.get(OWNER_YAML_TO_STRING_KEY, yaml.dump(owner.yaml))
+
+
+@owner_bindable.field("plan")
+@require_part_of_org
+@sync_to_async
+def resolve_plan(owner: Owner, info: GraphQLResolveInfo) -> PlanService:
+ return PlanService(current_org=owner)
+
+
+@owner_bindable.field("pretrialPlan")
+@require_part_of_org
+@sync_to_async
+def resolve_plan_representation(owner: Owner, info: GraphQLResolveInfo) -> Plan:
+ info.context["plan_service"] = PlanService(current_org=owner)
+ free_plan = Plan.objects.select_related("tier").get(name=DEFAULT_FREE_PLAN)
+ return free_plan
+
+
+@owner_bindable.field("availablePlans")
+@require_part_of_org
+@sync_to_async
+def resolve_available_plans(owner: Owner, info: GraphQLResolveInfo) -> List[Plan]:
+ plan_service = PlanService(current_org=owner)
+ info.context["plan_service"] = plan_service
+ owner = info.context["request"].current_owner
+ return plan_service.available_plans(owner=owner)
+
+
+@owner_bindable.field("hasPrivateRepos")
+@sync_to_async
+@require_part_of_org
+def resolve_has_private_repos(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ return owner.has_private_repos
+
+
+@owner_bindable.field("hasPublicRepos")
+@sync_to_async
+@require_part_of_org
+def resolve_has_public_repos(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ return owner.has_public_repos
+
+
+@owner_bindable.field("hasActiveRepos")
+@sync_to_async
+@require_part_of_org
+def resolve_has_active_repos(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ return owner.has_active_repos
+
+
+@owner_bindable.field("ownerid")
+@require_part_of_org
+def resolve_ownerid(owner: Owner, info: GraphQLResolveInfo) -> int:
+ return owner.ownerid
+
+
+COVERAGE_FIELDS = {
+ "coverageAnalytics.percentCovered",
+ "coverageAnalytics.commitSha",
+ "coverageAnalytics.hits",
+ "coverageAnalytics.misses",
+ "coverageAnalytics.lines",
+}
+COMMITS_FIELDS = {"oldestCommitAt"}
+
+
+@owner_bindable.field("repository")
+async def resolve_repository(
+ owner: Owner, info: GraphQLResolveInfo, name: str
+) -> Repository | NotFoundError:
+ command = info.context["executor"].get_command("repository")
+ okta_authenticated_accounts: list[int] = info.context["request"].session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ )
+
+ is_impersonation = info.context["request"].impersonation
+ # If the user is impersonating another user, we want to show all the Okta repos.
+ # This means we do not want to filter out the Okta enforced repos
+ exclude_okta_enforced_repos = not is_impersonation
+
+ requested_fields = selected_fields(info)
+ needs_coverage = not requested_fields.isdisjoint(COVERAGE_FIELDS)
+ needs_commits = not requested_fields.isdisjoint(COMMITS_FIELDS)
+
+ repository: Repository | None = await command.fetch_repository(
+ owner,
+ name,
+ okta_authenticated_accounts,
+ exclude_okta_enforced_repos=exclude_okta_enforced_repos,
+ needs_coverage=needs_coverage,
+ needs_commits=needs_commits,
+ )
+
+ if repository is None:
+ return NotFoundError()
+
+ sentry_sdk.set_tags(
+ {
+ "owner_username": repository.author.username,
+ "owner_service": repository.author.service,
+ "owner_plan": repository.author.plan,
+ "owner_id": repository.author.ownerid,
+ "repo_name": repository.name,
+ "repo_id": repository.repoid,
+ }
+ )
+
+ current_owner = info.context["request"].current_owner
+ has_products_enabled = (
+ repository.bundle_analysis_enabled
+ or repository.coverage_enabled
+ or repository.test_analytics_enabled
+ )
+
+ if repository.private and has_products_enabled:
+ await sync_to_async(activation.try_auto_activate)(owner, current_owner)
+
+ return repository
+
+
+@owner_bindable.field("numberOfUploads")
+@require_part_of_org
+async def resolve_number_of_uploads(
+ owner: Owner, info: GraphQLResolveInfo, **kwargs: Any
+) -> int:
+ command = info.context["executor"].get_command("owner")
+ return await command.get_uploads_number_per_user(owner)
+
+
+@owner_bindable.field("isAdmin")
+@require_part_of_org
+def resolve_is_current_user_an_admin(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ current_owner = info.context["request"].current_owner
+ command = info.context["executor"].get_command("owner")
+ return command.get_is_current_user_an_admin(owner, current_owner)
+
+
+@owner_bindable.field("hashOwnerid")
+@require_part_of_org
+def resolve_hash_ownerid(owner: Owner, info: GraphQLResolveInfo) -> str:
+ hash_ownerid = sha1(str(owner.ownerid).encode())
+ return hash_ownerid.hexdigest()
+
+
+@owner_bindable.field("orgUploadToken")
+@require_part_of_org
+def resolve_org_upload_token(
+ owner: Owner, info: GraphQLResolveInfo, **kwargs: Any
+) -> str:
+ should_hide_tokens = settings.HIDE_ALL_CODECOV_TOKENS
+ current_owner = info.context["request"].current_owner
+ command = info.context["executor"].get_command("owner")
+ is_owner_admin = current_owner.is_admin(owner)
+ if should_hide_tokens and not is_owner_admin:
+ return TOKEN_UNAVAILABLE
+
+ return command.get_org_upload_token(owner)
+
+
+@owner_bindable.field("defaultOrgUsername")
+@sync_to_async
+@require_part_of_org
+def resolve_org_default_org_username(
+ owner: Owner, info: GraphQLResolveInfo, **kwargs: Any
+) -> Optional[str]:
+ return None if owner.default_org is None else owner.default_org.username
+
+
+@owner_bindable.field("measurements")
+@sync_to_async
+def resolve_measurements(
+ owner: Owner,
+ info: GraphQLResolveInfo,
+ interval: Interval,
+ after: Optional[datetime] = None,
+ before: Optional[datetime] = None,
+ repos: Optional[List[str]] = None,
+ is_public: Optional[bool] = None,
+) -> Iterable[dict]:
+ current_owner = info.context["request"].current_owner
+
+ okta_authenticated_accounts: list[int] = info.context["request"].session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY, []
+ )
+
+ queryset = (
+ Repository.objects.filter(author=owner)
+ .viewable_repos(current_owner)
+ .exclude_accounts_enforced_okta(okta_authenticated_accounts)
+ )
+
+ if is_public is not None:
+ queryset = queryset.filter(private=not is_public)
+
+ if repos is None:
+ repo_ids = queryset.values_list("pk", flat=True)
+ else:
+ repo_ids = queryset.filter(name__in=repos).values_list("pk", flat=True)
+
+ return fill_sparse_measurements(
+ timeseries_helpers.owner_coverage_measurements_with_fallback(
+ owner,
+ list(repo_ids),
+ interval,
+ start_date=after,
+ end_date=before,
+ ),
+ interval,
+ start_date=after,
+ end_date=before,
+ )
+
+
+@owner_bindable.field("isCurrentUserActivated")
+@sync_to_async
+def resolve_is_current_user_activated(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ current_user = info.context["request"].user
+ if not current_user.is_authenticated:
+ return False
+
+ current_owner = info.context["request"].current_owner
+ if not current_owner:
+ return False
+
+ if owner.ownerid == current_owner.ownerid:
+ return True
+ if owner.plan_activated_users is None:
+ return False
+
+ return (
+ bool(owner.plan_activated_users)
+ and current_owner.ownerid in owner.plan_activated_users
+ )
+
+
+@owner_bindable.field("invoices")
+@require_part_of_org
+def resolve_owner_invoices(owner: Owner, info: GraphQLResolveInfo) -> list | None:
+ return BillingService(requesting_user=owner).list_filtered_invoices(owner, 100)
+
+
+@owner_bindable.field("isGithubRateLimited")
+@sync_to_async
+def resolve_is_github_rate_limited(
+ owner: Owner, info: GraphQLResolveInfo
+) -> bool | None:
+ if owner.service != SERVICE_GITHUB and owner.service != SERVICE_GITHUB_ENTERPRISE:
+ return False
+ redis_connection = get_redis_connection()
+ rate_limit_redis_key = rate_limits.determine_entity_redis_key(
+ owner=owner, repository=None
+ )
+ return rate_limits.determine_if_entity_is_rate_limited(
+ redis_connection, rate_limit_redis_key
+ )
+
+
+@owner_bindable.field("invoice")
+@require_part_of_org
+def resolve_owner_invoice(
+ owner: Owner,
+ info: GraphQLResolveInfo,
+ invoice_id: str,
+) -> stripe.Invoice | None:
+ return BillingService(requesting_user=owner).get_invoice(owner, invoice_id)
+
+
+@owner_bindable.field("account")
+@require_part_of_org
+@sync_to_async
+def resolve_owner_account(owner: Owner, info: GraphQLResolveInfo) -> dict:
+ account_id = owner.account_id
+ return Account.objects.filter(pk=account_id).first()
+
+
+@owner_bindable.field("isUserOktaAuthenticated")
+@sync_to_async
+@require_part_of_org
+def resolve_is_user_okta_authenticated(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ okta_signed_in_accounts = info.context["request"].session.get(
+ OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY,
+ [],
+ )
+ if owner.account_id:
+ return owner.account_id in okta_signed_in_accounts
+ return False
+
+
+@owner_bindable.field("delinquent")
+@require_part_of_org
+def resolve_delinquent(owner: Owner, info: GraphQLResolveInfo) -> bool | None:
+ return owner.delinquent
+
+
+@owner_bindable.field("aiFeaturesEnabled")
+@sync_to_async
+@require_part_of_org
+def resolve_ai_features_enabled(owner: Owner, info: GraphQLResolveInfo) -> bool:
+ return GithubAppInstallation.objects.filter(
+ app_id=AI_FEATURES_GH_APP_ID, owner=owner
+ ).exists()
+
+
+@owner_bindable.field("aiEnabledRepos")
+@sync_to_async
+@require_part_of_org
+def resolve_ai_enabled_repos(
+ owner: Owner, info: GraphQLResolveInfo
+) -> List[str] | None:
+ ai_features_app_install = GithubAppInstallation.objects.filter(
+ app_id=AI_FEATURES_GH_APP_ID, owner=owner
+ ).first()
+
+ if not ai_features_app_install:
+ return None
+
+ current_owner = info.context["request"].current_owner
+ queryset = Repository.objects.filter(author=owner).viewable_repos(current_owner)
+
+ if ai_features_app_install.repository_service_ids:
+ queryset = queryset.filter(
+ service_id__in=ai_features_app_install.repository_service_ids
+ )
+
+ return list(queryset.values_list("name", flat=True))
+
+
+@owner_bindable.field("uploadTokenRequired")
+@require_part_of_org
+def resolve_upload_token_required(
+ owner: Owner, info: GraphQLResolveInfo
+) -> bool | None:
+ return owner.upload_token_required_for_public_repos
+
+
+@owner_bindable.field("activatedUserCount")
+@sync_to_async
+@require_shared_account_or_part_of_org
+def resolve_activated_user_count(owner: Owner, info: GraphQLResolveInfo) -> int:
+ return owner.activated_user_count
+
+
+@owner_bindable.field("billing")
+@sync_to_async
+@require_part_of_org
+def resolve_billing(owner: Owner, info: GraphQLResolveInfo) -> dict | None:
+ return owner
diff --git a/apps/codecov-api/graphql_api/types/path_contents/__init__.py b/apps/codecov-api/graphql_api/types/path_contents/__init__.py
new file mode 100644
index 0000000000..a20cf9d6a0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/path_contents/__init__.py
@@ -0,0 +1,19 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .path_content import (
+ deprecated_path_contents_result_bindable,
+ path_content_bindable,
+ path_content_file_bindable,
+ path_contents_result_bindable,
+)
+
+path_content = ariadne_load_local_graphql(__file__, "path_content.graphql")
+
+
+__all__ = [
+ "path_content",
+ "path_content_bindable",
+ "path_content_file_bindable",
+ "path_contents_result_bindable",
+ "deprecated_path_contents_result_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/path_contents/path_content.graphql b/apps/codecov-api/graphql_api/types/path_contents/path_content.graphql
new file mode 100644
index 0000000000..88382fa95b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/path_contents/path_content.graphql
@@ -0,0 +1,58 @@
+interface PathContent {
+ name: String!
+ path: String!
+ hits: Int!
+ misses: Int!
+ partials: Int!
+ lines: Int!
+ percentCovered: Float!
+}
+
+type PathContentFile implements PathContent {
+ name: String!
+ path: String!
+ hits: Int!
+ misses: Int!
+ partials: Int!
+ lines: Int!
+ percentCovered: Float!
+}
+
+type PathContentDir implements PathContent {
+ name: String!
+ path: String!
+ hits: Int!
+ misses: Int!
+ partials: Int!
+ lines: Int!
+ percentCovered: Float!
+}
+
+type PathContents {
+ results: [PathContent!]!
+}
+
+type PathContentEdge {
+ cursor: String!
+ node: PathContent!
+}
+
+type PathContentConnection {
+ edges: [PathContentEdge!]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+union PathContentsResult =
+ PathContents
+ | MissingHeadReport
+ | MissingCoverage
+ | UnknownPath
+ | UnknownFlags
+
+union DeprecatedPathContentsResult =
+ PathContentConnection
+ | MissingHeadReport
+ | MissingCoverage
+ | UnknownPath
+ | UnknownFlags
diff --git a/apps/codecov-api/graphql_api/types/path_contents/path_content.py b/apps/codecov-api/graphql_api/types/path_contents/path_content.py
new file mode 100644
index 0000000000..192fcdf838
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/path_contents/path_content.py
@@ -0,0 +1,92 @@
+from typing import List, Union
+
+from ariadne import InterfaceType, ObjectType, UnionType
+
+from graphql_api.helpers.connection import (
+ ArrayConnection,
+ Connection,
+)
+from graphql_api.types.errors import MissingCoverage, MissingHeadReport, UnknownPath
+from graphql_api.types.errors.errors import UnknownFlags
+from services.path import Dir, File
+
+path_content_bindable = InterfaceType("PathContent")
+path_content_file_bindable = ObjectType("PathContentFile")
+
+
+@path_content_bindable.type_resolver
+def resolve_path_content_type(obj, *_):
+ if isinstance(obj, File):
+ return "PathContentFile"
+ if isinstance(obj, Dir):
+ return "PathContentDir"
+ return None
+
+
+@path_content_bindable.field("name")
+def resolve_name(item: Union[File, Dir], info) -> str:
+ return item.name
+
+
+@path_content_bindable.field("path")
+def resolve_file_path(item: Union[File, Dir], info) -> str:
+ return item.full_path
+
+
+@path_content_bindable.field("hits")
+def resolve_hits(item: Union[File, Dir], info) -> int:
+ return item.hits
+
+
+@path_content_bindable.field("misses")
+def resolve_misses(item: Union[File, Dir], info) -> int:
+ return item.misses
+
+
+@path_content_bindable.field("partials")
+def resolve_partials(item: Union[File, Dir], info) -> int:
+ return item.partials
+
+
+@path_content_bindable.field("lines")
+def resolve_lines(item: Union[File, Dir], info) -> int:
+ return item.lines
+
+
+@path_content_bindable.field("percentCovered")
+def resolve_percent_covered(item: Union[File, Dir], info) -> float:
+ return item.coverage
+
+
+path_contents_result_bindable = UnionType("PathContentsResult")
+
+
+@path_contents_result_bindable.type_resolver
+def resolve_path_contents_result_type(res, *_):
+ if isinstance(res, MissingHeadReport):
+ return "MissingHeadReport"
+ elif isinstance(res, MissingCoverage):
+ return "MissingCoverage"
+ elif isinstance(res, UnknownPath):
+ return "UnknownPath"
+ elif isinstance(res, UnknownFlags):
+ return "UnknownFlags"
+ if isinstance(res, type({"results": List[Union[File, Dir]]})):
+ return "PathContents"
+
+
+deprecated_path_contents_result_bindable = UnionType("DeprecatedPathContentsResult")
+
+
+@deprecated_path_contents_result_bindable.type_resolver
+def resolve_deprecated_path_contents_result_type(res, *_):
+ if isinstance(res, MissingHeadReport):
+ return "MissingHeadReport"
+ elif isinstance(res, MissingCoverage):
+ return "MissingCoverage"
+ elif isinstance(res, UnknownPath):
+ return "UnknownPath"
+ elif isinstance(res, UnknownFlags):
+ return "UnknownFlags"
+ elif isinstance(res, (Connection, ArrayConnection)):
+ return "PathContentConnection"
diff --git a/apps/codecov-api/graphql_api/types/plan/__init__.py b/apps/codecov-api/graphql_api/types/plan/__init__.py
new file mode 100644
index 0000000000..b60ae134c6
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan/__init__.py
@@ -0,0 +1,6 @@
+from .plan import plan, plan_bindable
+
+__all__ = [
+ "plan",
+ "plan_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/plan/plan.graphql b/apps/codecov-api/graphql_api/types/plan/plan.graphql
new file mode 100644
index 0000000000..505610c78a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan/plan.graphql
@@ -0,0 +1,22 @@
+type Plan {
+ baseUnitPrice: Int!
+ benefits: [String!]!
+ billingRate: String
+ hasSeatsLeft: Boolean!
+ isEnterprisePlan: Boolean!
+ isFreePlan: Boolean!
+ isProPlan: Boolean!
+ isSentryPlan: Boolean!
+ isTeamPlan: Boolean!
+ isTrialPlan: Boolean!
+ marketingName: String!
+ monthlyUploadLimit: Int
+ planUserCount: Int
+ pretrialUsersCount: Int
+ tierName: String!
+ trialEndDate: DateTime
+ trialStartDate: DateTime
+ trialStatus: TrialStatus!
+ trialTotalDays: Int
+ value: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/plan/plan.py b/apps/codecov-api/graphql_api/types/plan/plan.py
new file mode 100644
index 0000000000..c790170b77
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan/plan.py
@@ -0,0 +1,133 @@
+from datetime import datetime
+from typing import List, Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from shared.plan.constants import PlanBillingRate, TrialStatus
+from shared.plan.service import PlanService
+
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+plan = ariadne_load_local_graphql(__file__, "plan.graphql")
+plan_bindable = ObjectType("Plan")
+
+
+@plan_bindable.field("trialStartDate")
+def resolve_trial_start_date(plan_service: PlanService, info) -> Optional[datetime]:
+ return plan_service.trial_start_date
+
+
+@plan_bindable.field("trialTotalDays")
+@sync_to_async
+def resolve_trial_total_days(plan_service: PlanService, info) -> Optional[int]:
+ return plan_service.trial_total_days
+
+
+@plan_bindable.field("trialEndDate")
+def resolve_trial_end_date(plan_service: PlanService, info) -> Optional[datetime]:
+ return plan_service.trial_end_date
+
+
+@plan_bindable.field("trialStatus")
+def resolve_trial_status(plan_service: PlanService, info) -> TrialStatus:
+ if not plan_service.trial_status:
+ return TrialStatus.NOT_STARTED
+ return TrialStatus(plan_service.trial_status)
+
+
+@plan_bindable.field("marketingName")
+@sync_to_async
+def resolve_marketing_name(plan_service: PlanService, info) -> str:
+ return plan_service.marketing_name
+
+
+@plan_bindable.field("value")
+@sync_to_async
+def resolve_plan_name_as_value(plan_service: PlanService, info) -> str:
+ return plan_service.plan_name
+
+
+@plan_bindable.field("tierName")
+@sync_to_async
+def resolve_tier_name(plan_service: PlanService, info) -> str:
+ return plan_service.tier_name
+
+
+@plan_bindable.field("billingRate")
+@sync_to_async
+def resolve_billing_rate(plan_service: PlanService, info) -> Optional[PlanBillingRate]:
+ return plan_service.billing_rate
+
+
+@plan_bindable.field("baseUnitPrice")
+@sync_to_async
+def resolve_base_unit_price(plan_service: PlanService, info) -> int:
+ return plan_service.base_unit_price
+
+
+@plan_bindable.field("benefits")
+@sync_to_async
+def resolve_benefits(plan_service: PlanService, info) -> List[str]:
+ return plan_service.benefits
+
+
+@plan_bindable.field("pretrialUsersCount")
+@sync_to_async
+def resolve_pretrial_users_count(plan_service: PlanService, info) -> Optional[int]:
+ if plan_service.is_org_trialing:
+ return plan_service.pretrial_users_count
+ return None
+
+
+@plan_bindable.field("monthlyUploadLimit")
+@sync_to_async
+def resolve_monthly_uploads_limit(plan_service: PlanService, info) -> Optional[int]:
+ return plan_service.monthly_uploads_limit
+
+
+@plan_bindable.field("planUserCount")
+@sync_to_async
+def resolve_plan_user_count(plan_service: PlanService, info) -> int:
+ return plan_service.plan_user_count
+
+
+@plan_bindable.field("hasSeatsLeft")
+@sync_to_async
+def resolve_has_seats_left(plan_service: PlanService, info) -> bool:
+ return plan_service.has_seats_left
+
+
+@plan_bindable.field("isEnterprisePlan")
+@sync_to_async
+def resolve_is_enterprise_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_enterprise_plan
+
+
+@plan_bindable.field("isFreePlan")
+@sync_to_async
+def resolve_is_free_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_free_plan
+
+
+@plan_bindable.field("isProPlan")
+@sync_to_async
+def resolve_is_pro_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_pro_plan
+
+
+@plan_bindable.field("isSentryPlan")
+@sync_to_async
+def resolve_is_sentry_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_sentry_plan
+
+
+@plan_bindable.field("isTeamPlan")
+@sync_to_async
+def resolve_is_team_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_team_plan
+
+
+@plan_bindable.field("isTrialPlan")
+@sync_to_async
+def resolve_is_trial_plan(plan_service: PlanService, info) -> bool:
+ return plan_service.is_trial_plan
diff --git a/apps/codecov-api/graphql_api/types/plan_representation/__init__.py b/apps/codecov-api/graphql_api/types/plan_representation/__init__.py
new file mode 100644
index 0000000000..f033ede1a7
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan_representation/__init__.py
@@ -0,0 +1,6 @@
+from .plan_representation import plan_representation, plan_representation_bindable
+
+__all__ = [
+ "plan_representation",
+ "plan_representation_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.graphql b/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.graphql
new file mode 100644
index 0000000000..33a3948487
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.graphql
@@ -0,0 +1,14 @@
+type PlanRepresentation {
+ baseUnitPrice: Int!
+ benefits: [String!]!
+ billingRate: String
+ isEnterprisePlan: Boolean!
+ isFreePlan: Boolean!
+ isProPlan: Boolean!
+ isTeamPlan: Boolean!
+ isSentryPlan: Boolean!
+ isTrialPlan: Boolean!
+ marketingName: String!
+ monthlyUploadLimit: Int
+ value: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.py b/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.py
new file mode 100644
index 0000000000..8ecc2f6434
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/plan_representation/plan_representation.py
@@ -0,0 +1,83 @@
+from typing import List, Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from shared.plan.service import PlanService
+
+from codecov_auth.models import Plan
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+plan_representation = ariadne_load_local_graphql(
+ __file__, "plan_representation.graphql"
+)
+plan_representation_bindable = ObjectType("PlanRepresentation")
+
+
+@plan_representation_bindable.field("marketingName")
+def resolve_marketing_name(plan_data: Plan, info) -> str:
+ return plan_data.marketing_name
+
+
+@plan_representation_bindable.field("value")
+def resolve_plan_value(plan_data: Plan, info) -> str:
+ return plan_data.name
+
+
+@plan_representation_bindable.field("billingRate")
+def resolve_billing_rate(plan_data: Plan, info) -> Optional[str]:
+ return plan_data.billing_rate
+
+
+@plan_representation_bindable.field("baseUnitPrice")
+def resolve_base_unit_price(plan_data: Plan, info) -> int:
+ return plan_data.base_unit_price
+
+
+@plan_representation_bindable.field("benefits")
+@sync_to_async
+def resolve_benefits(plan_data: Plan, info) -> List[str]:
+ plan_service: PlanService = info.context["plan_service"]
+ if plan_service.is_org_trialing:
+ benefits_with_pretrial_users = [
+ benefit.replace(
+ "Up to 1 user", f"Up to {plan_service.pretrial_users_count} users"
+ )
+ for benefit in plan_data.benefits
+ ]
+ return benefits_with_pretrial_users
+ return plan_data.benefits
+
+
+@plan_representation_bindable.field("monthlyUploadLimit")
+def resolve_monthly_uploads_limit(plan_data: Plan, info) -> Optional[int]:
+ return plan_data.monthly_uploads_limit
+
+
+@plan_representation_bindable.field("isEnterprisePlan")
+def resolve_is_enterprise(plan_data: Plan, info) -> bool:
+ return plan_data.is_enterprise_plan
+
+
+@plan_representation_bindable.field("isFreePlan")
+def resolve_is_free(plan_data: Plan, info) -> bool:
+ return plan_data.is_free_plan
+
+
+@plan_representation_bindable.field("isProPlan")
+def resolve_is_pro(plan_data: Plan, info) -> bool:
+ return plan_data.is_pro_plan
+
+
+@plan_representation_bindable.field("isTeamPlan")
+def resolve_is_team(plan_data: Plan, info) -> bool:
+ return plan_data.is_team_plan
+
+
+@plan_representation_bindable.field("isSentryPlan")
+def resolve_is_sentry(plan_data: Plan, info) -> bool:
+ return plan_data.is_sentry_plan
+
+
+@plan_representation_bindable.field("isTrialPlan")
+def resolve_is_trial(plan_data: Plan, info) -> bool:
+ return plan_data.is_trial_plan
diff --git a/apps/codecov-api/graphql_api/types/profile/__init__.py b/apps/codecov-api/graphql_api/types/profile/__init__.py
new file mode 100644
index 0000000000..ebf29f3210
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/profile/__init__.py
@@ -0,0 +1,11 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .profile import profile_bindable
+
+profile = ariadne_load_local_graphql(__file__, "profile.graphql")
+
+
+__all__ = [
+ "profile",
+ "profile_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/profile/profile.graphql b/apps/codecov-api/graphql_api/types/profile/profile.graphql
new file mode 100644
index 0000000000..bf43274b9a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/profile/profile.graphql
@@ -0,0 +1,6 @@
+type Profile {
+ otherGoal: String
+ goals: [GoalOnboarding]!
+ typeProjects: [TypeProjectOnboarding]!
+ createdAt: DateTime!
+}
diff --git a/apps/codecov-api/graphql_api/types/profile/profile.py b/apps/codecov-api/graphql_api/types/profile/profile.py
new file mode 100644
index 0000000000..2f68945ceb
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/profile/profile.py
@@ -0,0 +1,15 @@
+from ariadne import ObjectType
+
+from graphql_api.types.enums.enums import GoalOnboarding, TypeProjectOnboarding
+
+profile_bindable = ObjectType("Profile")
+
+
+@profile_bindable.field("goals")
+def resolve_goals(profile, _):
+ return [GoalOnboarding(goal) for goal in profile.goals]
+
+
+@profile_bindable.field("typeProjects")
+def resolve_type_projects(profile, _):
+ return [TypeProjectOnboarding(project) for project in profile.type_projects]
diff --git a/apps/codecov-api/graphql_api/types/pull/__init__.py b/apps/codecov-api/graphql_api/types/pull/__init__.py
new file mode 100644
index 0000000000..6eb770cb08
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/pull/__init__.py
@@ -0,0 +1,11 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .pull import pull_bindable
+
+pull = ariadne_load_local_graphql(__file__, "pull.graphql")
+
+
+__all__ = [
+ "pull",
+ "pull_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/pull/pull.graphql b/apps/codecov-api/graphql_api/types/pull/pull.graphql
new file mode 100644
index 0000000000..49f9be6ffd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/pull/pull.graphql
@@ -0,0 +1,20 @@
+type Pull {
+ behindBy: Int
+ behindByCommit: String
+ title: String
+ state: PullRequestState!
+ pullId: Int!
+ author: Owner
+ updatestamp: DateTime
+ head: Commit
+ comparedTo: Commit
+ compareWithBase: ComparisonResult
+ bundleAnalysisCompareWithBase: BundleAnalysisComparisonResult
+ commits(
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): CommitConnection @cost(complexity: 10, multipliers: ["first", "last"])
+ firstPull: Boolean!
+}
diff --git a/apps/codecov-api/graphql_api/types/pull/pull.py b/apps/codecov-api/graphql_api/types/pull/pull.py
new file mode 100644
index 0000000000..2845e57148
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/pull/pull.py
@@ -0,0 +1,166 @@
+from typing import Any, Optional, Union
+
+import sentry_sdk
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql import GraphQLResolveInfo
+
+from codecov_auth.models import Owner
+from compare.models import CommitComparison
+from core.models import Commit, Pull
+from graphql_api.actions.commits import pull_commits
+from graphql_api.actions.comparison import validate_commit_comparison
+from graphql_api.dataloader.bundle_analysis import load_bundle_analysis_comparison
+from graphql_api.dataloader.commit import CommitLoader
+from graphql_api.dataloader.comparison import ComparisonLoader
+from graphql_api.dataloader.owner import OwnerLoader
+from graphql_api.helpers.connection import Connection, queryset_to_connection_sync
+from graphql_api.types.comparison.comparison import (
+ FirstPullRequest,
+ MissingBaseCommit,
+ MissingHeadCommit,
+)
+from graphql_api.types.enums import OrderingDirection, PullRequestState
+from services.bundle_analysis import BundleAnalysisComparison
+from services.comparison import ComparisonReport, PullRequestComparison
+
+pull_bindable = ObjectType("Pull")
+
+pull_bindable.set_alias("pullId", "pullid")
+
+
+@pull_bindable.field("state")
+def resolve_state(pull: Pull, info: GraphQLResolveInfo) -> PullRequestState:
+ return PullRequestState(pull.state)
+
+
+@pull_bindable.field("author")
+def resolve_author(pull: Pull, info: GraphQLResolveInfo) -> Optional[Owner]:
+ if pull.author_id:
+ return OwnerLoader.loader(info).load(pull.author_id)
+
+
+@pull_bindable.field("head")
+def resolve_head(pull: Pull, info: GraphQLResolveInfo) -> Optional[Commit]:
+ if pull.head is None:
+ return None
+ return CommitLoader.loader(info, pull.repository_id).load(pull.head)
+
+
+@pull_bindable.field("comparedTo")
+def resolve_base(pull: Pull, info: GraphQLResolveInfo) -> Optional[Commit]:
+ if pull.compared_to is None:
+ return None
+ return CommitLoader.loader(info, pull.repository_id).load(pull.compared_to)
+
+
+@sync_to_async
+def is_first_pull_request(pull: Pull) -> bool:
+ return pull.repository.pull_requests.order_by("id").first() == pull
+
+
+@pull_bindable.field("compareWithBase")
+@sentry_sdk.trace
+async def resolve_compare_with_base(
+ pull: Pull, info: GraphQLResolveInfo, **kwargs: Any
+) -> Union[CommitComparison, Any]:
+ if not pull.compared_to:
+ if await is_first_pull_request(pull):
+ return FirstPullRequest()
+ else:
+ return MissingBaseCommit()
+ if not pull.head:
+ return MissingHeadCommit()
+
+ comparison_loader = ComparisonLoader.loader(info, pull.repository_id)
+ commit_comparison = await comparison_loader.load((pull.compared_to, pull.head))
+
+ comparison_error = validate_commit_comparison(commit_comparison=commit_comparison)
+
+ if comparison_error:
+ return comparison_error
+
+ if commit_comparison and commit_comparison.is_processed:
+ current_owner = info.context["request"].current_owner
+ comparison = PullRequestComparison(current_owner, pull)
+ # store the comparison in the context - to be used in the `Comparison` resolvers
+ info.context["comparison"] = comparison
+
+ if commit_comparison:
+ return ComparisonReport(commit_comparison)
+
+
+@pull_bindable.field("bundleAnalysisCompareWithBase")
+@sync_to_async
+@sentry_sdk.trace
+def resolve_bundle_analysis_compare_with_base(
+ pull: Pull, info: GraphQLResolveInfo, **kwargs: Any
+) -> Union[BundleAnalysisComparison, Any]:
+ if not pull.compared_to:
+ if pull.repository.pull_requests.order_by("id").first() == pull:
+ return FirstPullRequest()
+ else:
+ return MissingBaseCommit()
+
+ # Handles a case where the PR was created without any uploads because all bundles
+ # from the build are cached. Instead of showing a "no commit error" we will instead
+ # show the parent bundle report as it implies everything was cached and carried
+ # over to the head commit
+ head_commit_sha = pull.head if pull.head else pull.compared_to
+
+ bundle_analysis_comparison = load_bundle_analysis_comparison(
+ Commit.objects.filter(
+ commitid=pull.compared_to, repository=pull.repository
+ ).first(),
+ Commit.objects.filter(
+ commitid=head_commit_sha, repository=pull.repository
+ ).first(),
+ )
+
+ # Store the created SQLite DB path in info.context
+ # when the request is fully handled, have the file deleted
+ if isinstance(bundle_analysis_comparison, BundleAnalysisComparison):
+ info.context[
+ "request"
+ ].bundle_analysis_base_report_db_path = (
+ bundle_analysis_comparison.comparison.base_report.db_path
+ )
+ info.context[
+ "request"
+ ].bundle_analysis_head_report_db_path = (
+ bundle_analysis_comparison.comparison.head_report.db_path
+ )
+
+ return bundle_analysis_comparison
+
+
+@pull_bindable.field("commits")
+@sync_to_async
+def resolve_commits(pull: Pull, info: GraphQLResolveInfo, **kwargs: Any) -> Connection:
+ queryset = pull_commits(pull)
+
+ return queryset_to_connection_sync(
+ queryset,
+ ordering=("timestamp",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+
+@pull_bindable.field("behindBy")
+def resolve_behind_by(pull: Pull, info: GraphQLResolveInfo, **kwargs: Any) -> int:
+ return pull.behind_by
+
+
+@pull_bindable.field("behindByCommit")
+def resolve_behind_by_commit(
+ pull: Pull, info: GraphQLResolveInfo, **kwargs: Any
+) -> str:
+ return pull.behind_by_commit
+
+
+@pull_bindable.field("firstPull")
+@sync_to_async
+def resolve_first_pull(pull: Pull, info: GraphQLResolveInfo) -> bool:
+ # returns true if this pull is/was the 1st for a repo
+ return pull.repository.pull_requests.order_by("id").first() == pull
diff --git a/apps/codecov-api/graphql_api/types/query/__init__.py b/apps/codecov-api/graphql_api/types/query/__init__.py
new file mode 100644
index 0000000000..66521fb059
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/query/__init__.py
@@ -0,0 +1,6 @@
+from .query import query, query_bindable
+
+__all__ = [
+ "query",
+ "query_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/query/query.graphql b/apps/codecov-api/graphql_api/types/query/query.graphql
new file mode 100644
index 0000000000..d008003838
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/query/query.graphql
@@ -0,0 +1,14 @@
+scalar DateTime
+
+type Query {
+ me: Me
+ owner(username: String!): Owner
+ config: Config!
+}
+
+type PageInfo {
+ hasNextPage: Boolean!
+ hasPreviousPage: Boolean!
+ startCursor: String
+ endCursor: String
+}
diff --git a/apps/codecov-api/graphql_api/types/query/query.py b/apps/codecov-api/graphql_api/types/query/query.py
new file mode 100644
index 0000000000..9114aa8ef4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/query/query.py
@@ -0,0 +1,79 @@
+from typing import Any, Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from graphql import GraphQLResolveInfo
+from sentry_sdk import Scope
+
+from codecov.commands.exceptions import UnauthorizedGuestAccess
+from codecov_auth.models import Owner
+from graphql_api.actions.owner import get_owner
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+query = ariadne_load_local_graphql(__file__, "query.graphql")
+query_bindable = ObjectType("Query")
+
+
+def query_name(info: GraphQLResolveInfo) -> Optional[str]:
+ if info.operation and info.operation.name:
+ return info.operation.name.value
+
+
+def configure_sentry_scope(query_name: Optional[str]) -> None:
+ # this sets the Sentry transaction name to the GraphQL query name which
+ # should make it easier to search/filter transactions
+ # we're configuring this here since it's the main entrypoint into GraphQL resolvers
+
+ # https://docs.sentry.io/platforms/python/enriching-events/transaction-name/
+ scope = Scope.get_current_scope()
+ if scope.transaction:
+ scope.transaction.name = f"GraphQL [{query_name}]"
+
+
+@query_bindable.field("me")
+@sync_to_async
+def resolve_me(_: Any, info: GraphQLResolveInfo) -> Optional[Owner]:
+ configure_sentry_scope(query_name(info))
+ # will be `None` for anonymous users or users w/ no linked owners
+ return info.context["request"].current_owner
+
+
+@query_bindable.field("owner")
+async def resolve_owner(
+ _: Any, info: GraphQLResolveInfo, username: str
+) -> Optional[Owner]:
+ configure_sentry_scope(query_name(info))
+
+ service = info.context["service"]
+ if not service:
+ return None
+
+ user = info.context["request"].current_owner or info.context["request"].user
+
+ if settings.IS_ENTERPRISE and settings.GUEST_ACCESS is False:
+ if not user or not user.is_authenticated:
+ raise UnauthorizedGuestAccess()
+
+ # if the owner tracks plan activated users, check if the user is in the list
+ target_owner = await get_owner(service, username)
+ has_plan_activated_users = (
+ target_owner
+ and target_owner.plan_activated_users is not None
+ and len(target_owner.plan_activated_users) > 0
+ )
+ if (
+ has_plan_activated_users
+ and user.ownerid not in target_owner.plan_activated_users
+ ):
+ raise UnauthorizedGuestAccess()
+
+ return await get_owner(service, username)
+
+
+@query_bindable.field("config")
+def resolve_config(_: Any, info: GraphQLResolveInfo) -> object:
+ configure_sentry_scope(query_name(info))
+
+ # we have to return something here just to allow access to the child resolvers
+ return object()
diff --git a/apps/codecov-api/graphql_api/types/repository/__init__.py b/apps/codecov-api/graphql_api/types/repository/__init__.py
new file mode 100644
index 0000000000..0f42fc5137
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .repository import repository_bindable, repository_result_bindable
+
+repository = ariadne_load_local_graphql(__file__, "repository.graphql")
+
+
+__all__ = [
+ "repository",
+ "repository_bindable",
+ "repository_result_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/repository/repository.graphql b/apps/codecov-api/graphql_api/types/repository/repository.graphql
new file mode 100644
index 0000000000..69590e47db
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository/repository.graphql
@@ -0,0 +1,95 @@
+"""
+Repository is a named collection of files uploaded
+"""
+type Repository {
+ isFirstPullRequest: Boolean!
+ repoid: Int!
+ name: String!
+ active: Boolean!
+ activated: Boolean!
+ private: Boolean!
+ oldestCommitAt: DateTime
+ latestCommitAt: DateTime
+ updatedAt: DateTime
+ author: Owner!
+ uploadToken: String
+ branch(name: String!): Branch
+ commit(id: String!): Commit
+ pull(id: Int!): Pull
+ pulls(
+ filters: PullsSetFilters
+ orderingDirection: OrderingDirection
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): PullConnection @cost(complexity: 10, multipliers: ["first", "last"])
+ commits(
+ filters: CommitsSetFilters
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): CommitConnection @cost(complexity: 10, multipliers: ["first", "last"])
+ branches(
+ filters: BranchesSetFilters
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): BranchConnection @cost(complexity: 3, multipliers: ["first", "last"])
+ defaultBranch: String
+ graphToken: String
+ yaml: String
+ bot: Owner
+ repositoryConfig: RepositoryConfig
+ staticAnalysisToken: String
+ isATSConfigured: Boolean
+ primaryLanguage: String
+ languages: [String!]
+ bundleAnalysisEnabled: Boolean
+ coverageEnabled: Boolean
+ testAnalyticsEnabled: Boolean
+ isGithubRateLimited: Boolean
+
+ "CoverageAnalytics are fields related to Codecov's Coverage product offering"
+ coverageAnalytics: CoverageAnalytics
+
+ "TestAnalytics are fields related to Codecov's Test Analytics product offering"
+ testAnalytics: TestAnalytics
+}
+
+type PullConnection {
+ edges: [PullEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type PullEdge {
+ cursor: String!
+ node: Pull!
+}
+
+type CommitConnection {
+ edges: [CommitEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type CommitEdge {
+ cursor: String!
+ node: Commit!
+}
+
+type BranchConnection {
+ edges: [BranchEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type BranchEdge {
+ cursor: String!
+ node: Branch!
+}
+
+union RepositoryResult = Repository | NotFoundError | OwnerNotActivatedError
diff --git a/apps/codecov-api/graphql_api/types/repository/repository.py b/apps/codecov-api/graphql_api/types/repository/repository.py
new file mode 100644
index 0000000000..145579f0b9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository/repository.py
@@ -0,0 +1,339 @@
+import logging
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+import sentry_sdk
+import shared.rate_limits as rate_limits
+import yaml
+from ariadne import ObjectType, UnionType
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from graphql.type.definition import GraphQLResolveInfo
+from shared.helpers.redis import get_redis_connection
+
+from codecov_auth.models import SERVICE_GITHUB, SERVICE_GITHUB_ENTERPRISE, Owner
+from core.models import Branch, Commit, Pull, Repository
+from graphql_api.actions.commits import load_commit_statuses, repo_commits
+from graphql_api.dataloader.commit import CommitLoader
+from graphql_api.dataloader.owner import OwnerLoader
+from graphql_api.helpers.connection import queryset_to_connection
+from graphql_api.helpers.requested_fields import selected_fields
+from graphql_api.types.coverage_analytics.coverage_analytics import (
+ CoverageAnalyticsProps,
+)
+from graphql_api.types.enums import OrderingDirection
+from graphql_api.types.enums.enum_types import PullRequestState
+from graphql_api.types.errors.errors import NotFoundError, OwnerNotActivatedError
+
+TOKEN_UNAVAILABLE = "Token Unavailable. Please contact your admin."
+
+log = logging.getLogger(__name__)
+
+repository_bindable = ObjectType("Repository")
+
+repository_bindable.set_alias("updatedAt", "updatestamp")
+
+# latest_commit_at and coverage have their NULL value defaulted to -1/an old date
+# so the NULL would end up last in the queryset as we do not have control over
+# the order_by call. The true value of is under true_*; which would actually contain NULL
+# see with_cache_latest_commit_at() from core/managers.py
+repository_bindable.set_alias("latestCommitAt", "true_latest_commit_at")
+
+
+@repository_bindable.field("repoid")
+def resolve_repoid(repository: Repository, info: GraphQLResolveInfo) -> int:
+ return repository.repoid
+
+
+@repository_bindable.field("name")
+def resolve_name(repository: Repository, info: GraphQLResolveInfo) -> str:
+ return repository.name
+
+
+@repository_bindable.field("oldestCommitAt")
+def resolve_oldest_commit_at(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[datetime]:
+ if hasattr(repository, "oldest_commit_at"):
+ return repository.oldest_commit_at
+ else:
+ return None
+
+
+@repository_bindable.field("branch")
+def resolve_branch(
+ repository: Repository, info: GraphQLResolveInfo, name: str
+) -> Branch:
+ command = info.context["executor"].get_command("branch")
+ return command.fetch_branch(repository, name)
+
+
+@repository_bindable.field("author")
+def resolve_author(repository: Repository, info: GraphQLResolveInfo) -> Owner:
+ return OwnerLoader.loader(info).load(repository.author_id)
+
+
+@repository_bindable.field("commit")
+def resolve_commit(repository: Repository, info: GraphQLResolveInfo, id: str) -> Commit:
+ loader = CommitLoader.loader(info, repository.pk)
+ commit = loader.load(id)
+
+ if commit:
+ sentry_sdk.set_tag("commit_sha", id)
+
+ return commit
+
+
+@repository_bindable.field("uploadToken")
+def resolve_upload_token(repository: Repository, info: GraphQLResolveInfo) -> str:
+ should_hide_tokens = settings.HIDE_ALL_CODECOV_TOKENS
+
+ current_owner = info.context["request"].current_owner
+ if not current_owner:
+ is_current_user_admin = False
+ else:
+ is_current_user_admin = current_owner.is_admin(repository.author)
+
+ if should_hide_tokens and not is_current_user_admin:
+ return TOKEN_UNAVAILABLE
+ command = info.context["executor"].get_command("repository")
+ return command.get_upload_token(repository)
+
+
+@repository_bindable.field("pull")
+def resolve_pull(repository: Repository, info: GraphQLResolveInfo, id: int) -> Pull:
+ command = info.context["executor"].get_command("pull")
+ return command.fetch_pull_request(repository, id)
+
+
+@repository_bindable.field("pulls")
+async def resolve_pulls(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ filters: Optional[Dict[str, List[PullRequestState]]] = None,
+ ordering_direction: Optional[OrderingDirection] = OrderingDirection.DESC,
+ **kwargs: Any,
+) -> List[Pull]:
+ command = info.context["executor"].get_command("pull")
+ queryset = await command.fetch_pull_requests(repository, filters)
+ return await queryset_to_connection(
+ queryset,
+ ordering=("pullid",),
+ ordering_direction=ordering_direction,
+ **kwargs,
+ )
+
+
+# the `requested_fields` here are prefixed with `edges.node`, as this is a `Connection`
+# and using `commits { edges { node { ... } } }` is the way this is queried.
+STATUS_FIELDS = {"edges.node.coverageStatus", "edges.node.bundleStatus"}
+
+
+@repository_bindable.field("commits")
+async def resolve_commits(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ filters: Optional[Dict[str, Any]] = None,
+ **kwargs: Any,
+) -> List[Commit]:
+ queryset = await sync_to_async(repo_commits)(repository, filters)
+ connection = await queryset_to_connection(
+ queryset,
+ ordering=("timestamp",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+ for edge in connection.edges:
+ commit = edge["node"]
+ # cache all resulting commits in dataloader
+ loader = CommitLoader.loader(info, repository.repoid)
+ loader.cache(commit)
+
+ requested_fields = selected_fields(info)
+ should_load_statuses = not requested_fields.isdisjoint(STATUS_FIELDS)
+
+ if should_load_statuses:
+ commit_ids = [edge["node"].id for edge in connection.edges]
+ info.context["commit_statuses"] = await sync_to_async(load_commit_statuses)(
+ commit_ids
+ )
+
+ return connection
+
+
+@repository_bindable.field("branches")
+async def resolve_branches(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ filters: Optional[Dict[str, str | bool]] = None,
+ **kwargs: Any,
+) -> List[Branch]:
+ command = info.context["executor"].get_command("branch")
+ queryset = await command.fetch_branches(repository, filters)
+ return await queryset_to_connection(
+ queryset,
+ ordering=("updatestamp",),
+ ordering_direction=OrderingDirection.DESC,
+ **kwargs,
+ )
+
+
+@repository_bindable.field("defaultBranch")
+def resolve_default_branch(repository: Repository, info: GraphQLResolveInfo) -> str:
+ return repository.branch
+
+
+@repository_bindable.field("staticAnalysisToken")
+def resolve_static_analysis_token(
+ repository: Repository, info: GraphQLResolveInfo
+) -> str:
+ command = info.context["executor"].get_command("repository")
+ return command.get_repository_token(repository, token_type="static_analysis")
+
+
+@repository_bindable.field("graphToken")
+def resolve_graph_token(repository: Repository, info: GraphQLResolveInfo) -> str:
+ return repository.image_token
+
+
+@repository_bindable.field("yaml")
+def resolve_repo_yaml(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[str]:
+ if repository.yaml is None:
+ return None
+ return yaml.dump(repository.yaml)
+
+
+@repository_bindable.field("bot")
+@sync_to_async
+def resolve_repo_bot(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[Owner]:
+ return repository.bot
+
+
+@repository_bindable.field("active")
+def resolve_active(repository: Repository, info: GraphQLResolveInfo) -> bool:
+ return repository.active or False
+
+
+@repository_bindable.field("isATSConfigured")
+def resolve_is_ats_configured(repository: Repository, info: GraphQLResolveInfo) -> bool:
+ if not repository.yaml or "flag_management" not in repository.yaml:
+ return False
+
+ # See https://docs.codecov.com/docs/getting-started-with-ats-github-actions on configuring
+ # flags. To use Automated Test Selection, a flag is required with Carryforward mode "labels".
+ individual_flags = repository.yaml["flag_management"].get("individual_flags", {})
+ return individual_flags.get("carryforward_mode") == "labels"
+
+
+@repository_bindable.field("repositoryConfig")
+def resolve_repository_config(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Repository:
+ return repository
+
+
+@repository_bindable.field("primaryLanguage")
+def resolve_language(repository: Repository, info: GraphQLResolveInfo) -> str:
+ return repository.language
+
+
+@repository_bindable.field("languages")
+def resolve_languages(repository: Repository, info: GraphQLResolveInfo) -> List[str]:
+ return repository.languages
+
+
+@repository_bindable.field("bundleAnalysisEnabled")
+def resolve_bundle_analysis_enabled(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[bool]:
+ return repository.bundle_analysis_enabled
+
+
+@repository_bindable.field("testAnalyticsEnabled")
+def resolve_test_analytics_enabled(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[bool]:
+ return repository.test_analytics_enabled
+
+
+@repository_bindable.field("coverageEnabled")
+def resolve_coverage_enabled(
+ repository: Repository, info: GraphQLResolveInfo
+) -> Optional[bool]:
+ return repository.coverage_enabled
+
+
+repository_result_bindable = UnionType("RepositoryResult")
+
+
+@repository_result_bindable.type_resolver
+def resolve_repository_result_type(obj: Any, *_: Any) -> Optional[str]:
+ if isinstance(obj, Repository):
+ return "Repository"
+ elif isinstance(obj, OwnerNotActivatedError):
+ return "OwnerNotActivatedError"
+ elif isinstance(obj, NotFoundError):
+ return "NotFoundError"
+
+
+@repository_bindable.field("isFirstPullRequest")
+@sync_to_async
+def resolve_is_first_pull_request(
+ repository: Repository, info: GraphQLResolveInfo
+) -> bool:
+ # Get at most 2 PRs to determine if there's only one
+ pull_requests = repository.pull_requests.values("id", "compared_to")[:2]
+ if len(pull_requests) != 1:
+ return False
+ # For single PR, check if it's a valid first PR by verifying no compared_to
+ return pull_requests[0]["compared_to"] is None
+
+
+@repository_bindable.field("isGithubRateLimited")
+@sync_to_async
+def resolve_is_github_rate_limited(
+ repository: Repository, info: GraphQLResolveInfo
+) -> bool | None:
+ if (
+ repository.service != SERVICE_GITHUB
+ and repository.service != SERVICE_GITHUB_ENTERPRISE
+ ):
+ return False
+ repo_owner = repository.author
+ try:
+ redis_connection = get_redis_connection()
+ rate_limit_redis_key = rate_limits.determine_entity_redis_key(
+ owner=repo_owner, repository=repository
+ )
+ return rate_limits.determine_if_entity_is_rate_limited(
+ redis_connection, rate_limit_redis_key
+ )
+ except Exception:
+ log.warning(
+ "Error when checking rate limit",
+ extra=dict(repo_id=repository.repoid, has_owner=bool(repo_owner)),
+ )
+ return None
+
+
+@repository_bindable.field("coverageAnalytics")
+def resolve_coverage_analytics(
+ repository: Repository, info: GraphQLResolveInfo
+) -> CoverageAnalyticsProps:
+ return CoverageAnalyticsProps(repository=repository)
+
+
+@repository_bindable.field("testAnalytics")
+def resolve_test_analytics(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+) -> Repository:
+ """
+ resolve_test_analytics defines the data that will get passed to the testAnalytics resolvers
+ """
+ return repository
diff --git a/apps/codecov-api/graphql_api/types/repository_config/__init__.py b/apps/codecov-api/graphql_api/types/repository_config/__init__.py
new file mode 100644
index 0000000000..0520f63a3b
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository_config/__init__.py
@@ -0,0 +1,11 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .repository_config import indication_range_bindable, repository_config_bindable
+
+repository_config = ariadne_load_local_graphql(__file__, "repository_config.graphql")
+
+__all__ = [
+ "repository_config",
+ "repository_config_bindable",
+ "indication_range_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/repository_config/repository_config.graphql b/apps/codecov-api/graphql_api/types/repository_config/repository_config.graphql
new file mode 100644
index 0000000000..6a32ad076c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository_config/repository_config.graphql
@@ -0,0 +1,8 @@
+type RepositoryConfig {
+ indicationRange: IndicationRange
+}
+
+type IndicationRange {
+ upperRange: Float!
+ lowerRange: Float!
+}
diff --git a/apps/codecov-api/graphql_api/types/repository_config/repository_config.py b/apps/codecov-api/graphql_api/types/repository_config/repository_config.py
new file mode 100644
index 0000000000..289ff315ca
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/repository_config/repository_config.py
@@ -0,0 +1,41 @@
+from typing import TypedDict
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from shared.yaml.user_yaml import UserYaml
+
+from core.models import Repository
+from graphql_api.dataloader.owner import OwnerLoader
+
+repository_config_bindable = ObjectType("RepositoryConfig")
+indication_range_bindable = ObjectType("IndicationRange")
+
+
+class IndicationRange(TypedDict):
+ lowerRange: float
+ upperRange: float
+
+
+@repository_config_bindable.field("indicationRange")
+async def resolve_indication_range(repository: Repository, info) -> dict[str, float]:
+ owner = await OwnerLoader.loader(info).load(repository.author_id)
+
+ yaml = await sync_to_async(UserYaml.get_final_yaml)(
+ owner_yaml=owner.yaml, repo_yaml=repository.yaml
+ )
+ range: list[float] = yaml.get("coverage", {"range": [60, 80]}).get(
+ "range", [60, 80]
+ )
+ return {"lowerRange": range[0], "upperRange": range[1]}
+
+
+@indication_range_bindable.field("upperRange")
+def resolve_upper_range(indicationRange: IndicationRange, info) -> float:
+ upperRange = indicationRange.get("upperRange")
+ return upperRange
+
+
+@indication_range_bindable.field("lowerRange")
+def resolve_lower_range(indicationRange: IndicationRange, info) -> float:
+ lowerRange = indicationRange.get("lowerRange")
+ return lowerRange
diff --git a/apps/codecov-api/graphql_api/types/segment_comparison/__init__.py b/apps/codecov-api/graphql_api/types/segment_comparison/__init__.py
new file mode 100644
index 0000000000..20f87a3b48
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/segment_comparison/__init__.py
@@ -0,0 +1,12 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .segment_comparison import segment_comparison_bindable, segments_result_bindable
+
+segment_comparison = ariadne_load_local_graphql(__file__, "segment_comparison.graphql")
+
+
+__all__ = [
+ "segment_comparison",
+ "segment_comparison_bindable",
+ "segments_result_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.graphql b/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.graphql
new file mode 100644
index 0000000000..9dac085247
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.graphql
@@ -0,0 +1,11 @@
+type SegmentComparison {
+ header: String!
+ hasUnintendedChanges: Boolean!
+ lines: [LineComparison!]!
+}
+
+type SegmentComparisons {
+ results: [SegmentComparison!]!
+}
+
+union SegmentsResult = SegmentComparisons | UnknownPath | ProviderError
diff --git a/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.py b/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.py
new file mode 100644
index 0000000000..12356678e2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/segment_comparison/segment_comparison.py
@@ -0,0 +1,55 @@
+from dataclasses import dataclass
+from typing import List
+
+from ariadne import ObjectType, UnionType
+
+from graphql_api.types.errors.errors import ProviderError, UnknownPath
+from services.comparison import LineComparison, Segment
+
+
+@dataclass
+class SegmentComparisons:
+ results: List[Segment]
+
+
+segment_comparison_bindable = ObjectType("SegmentComparison")
+
+
+@segment_comparison_bindable.field("header")
+def resolve_header(segment: Segment, info) -> str:
+ (
+ base_starting,
+ base_extracted,
+ head_starting,
+ head_extracted,
+ ) = segment.header
+ base = f"{base_starting}"
+ if base_extracted is not None:
+ base = f"{base},{base_extracted}"
+ head = f"{head_starting}"
+ if head_extracted is not None:
+ head = f"{head},{head_extracted}"
+ return f"-{base} +{head}"
+
+
+@segment_comparison_bindable.field("lines")
+def resolve_lines(segment: Segment, info) -> List[LineComparison]:
+ return segment.lines
+
+
+@segment_comparison_bindable.field("hasUnintendedChanges")
+def resolve_has_unintended_changes(segment: Segment, info) -> bool:
+ return segment.has_unintended_changes
+
+
+segments_result_bindable = UnionType("SegmentsResult")
+
+
+@segments_result_bindable.type_resolver
+def resolve_segments_result_type(res, *_):
+ if isinstance(res, UnknownPath):
+ return "UnknownPath"
+ elif isinstance(res, ProviderError):
+ return "ProviderError"
+ elif isinstance(res, SegmentComparisons):
+ return "SegmentComparisons"
diff --git a/apps/codecov-api/graphql_api/types/self_hosted_license/__init__.py b/apps/codecov-api/graphql_api/types/self_hosted_license/__init__.py
new file mode 100644
index 0000000000..777259ea74
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/self_hosted_license/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .self_hosted_license import self_hosted_license_bindable
+
+self_hosted_license = ariadne_load_local_graphql(
+ __file__, "self_hosted_license.graphql"
+)
+
+__all__ = ["self_hosted_license", "self_hosted_license_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.graphql b/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.graphql
new file mode 100644
index 0000000000..e7e38af3f2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.graphql
@@ -0,0 +1,3 @@
+type SelfHostedLicense {
+ expirationDate: DateTime
+}
diff --git a/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.py b/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.py
new file mode 100644
index 0000000000..a737ce54db
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/self_hosted_license/self_hosted_license.py
@@ -0,0 +1,18 @@
+import datetime
+from typing import Optional
+
+from ariadne import ObjectType
+from django.conf import settings
+from shared.license import LicenseInformation
+
+self_hosted_license_bindable = ObjectType("SelfHostedLicense")
+
+
+@self_hosted_license_bindable.field("expirationDate")
+def resolve_expiration_date(
+ license: LicenseInformation, info
+) -> Optional[datetime.date]:
+ if not settings.IS_ENTERPRISE:
+ return None
+
+ return license.expires
diff --git a/apps/codecov-api/graphql_api/types/session/__init__.py b/apps/codecov-api/graphql_api/types/session/__init__.py
new file mode 100644
index 0000000000..49f9ff9cac
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/session/__init__.py
@@ -0,0 +1,7 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .session import session_bindable
+
+session = ariadne_load_local_graphql(__file__, "session.graphql")
+
+__all__ = ["session", "session_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/session/session.graphql b/apps/codecov-api/graphql_api/types/session/session.graphql
new file mode 100644
index 0000000000..30e626f0fa
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/session/session.graphql
@@ -0,0 +1,9 @@
+type Session {
+ sessionid: Int!
+ ip: String
+ lastseen: DateTime
+ useragent: String
+ type: String!
+ name: String
+ lastFour: String!
+}
diff --git a/apps/codecov-api/graphql_api/types/session/session.py b/apps/codecov-api/graphql_api/types/session/session.py
new file mode 100644
index 0000000000..859f314c06
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/session/session.py
@@ -0,0 +1,8 @@
+from ariadne import ObjectType
+
+session_bindable = ObjectType("Session")
+
+
+@session_bindable.field("lastFour")
+def resolve_last_four(session, _) -> str:
+ return str(session.token)[-4:]
diff --git a/apps/codecov-api/graphql_api/types/test_analytics/__init__.py b/apps/codecov-api/graphql_api/types/test_analytics/__init__.py
new file mode 100644
index 0000000000..81a4c7cfba
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_analytics/__init__.py
@@ -0,0 +1,11 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .test_analytics import (
+ test_analytics_bindable,
+)
+
+test_analytics = ariadne_load_local_graphql(__file__, "test_analytics.graphql")
+
+__all__ = [
+ "test_analytics_bindable",
+]
diff --git a/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.graphql b/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.graphql
new file mode 100644
index 0000000000..efa71bfde0
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.graphql
@@ -0,0 +1,36 @@
+"""
+TestAnalytics are fields related to Codecov's Test Analytics product offering
+"""
+type TestAnalytics {
+ "Test results are analytics data per test"
+ testResults(
+ filters: TestResultsFilters
+ ordering: TestResultsOrdering
+ first: Int
+ after: String
+ last: Int
+ before: String
+ ): TestResultConnection! @cost(complexity: 10, multipliers: ["first", "last"])
+
+ "Test results aggregates are analytics data totals across all tests"
+ testResultsAggregates(interval: MeasurementInterval): TestResultsAggregates
+
+ "Flake aggregates are flake totals across all tests"
+ flakeAggregates(interval: MeasurementInterval): FlakeAggregates
+
+ testSuites(term: String): [String!]!
+
+ "Only flag names relevant to Test Analytics"
+ flags(term: String): [String!]!
+}
+
+type TestResultConnection {
+ edges: [TestResultEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type TestResultEdge {
+ cursor: String!
+ node: TestResult!
+}
diff --git a/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.py b/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.py
new file mode 100644
index 0000000000..28f26fd69e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_analytics/test_analytics.py
@@ -0,0 +1,416 @@
+import datetime as dt
+import logging
+from base64 import b64decode, b64encode
+from dataclasses import dataclass
+from typing import Any, TypedDict
+
+import polars as pl
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from graphql.type.definition import GraphQLResolveInfo
+from shared.django_apps.core.models import Repository
+
+from codecov.commands.exceptions import ValidationError
+from graphql_api.types.enums import (
+ OrderingDirection,
+ TestResultsFilterParameter,
+ TestResultsOrderingParameter,
+)
+from graphql_api.types.enums.enum_types import MeasurementInterval
+from graphql_api.types.flake_aggregates.flake_aggregates import (
+ FlakeAggregates,
+ generate_flake_aggregates,
+)
+from graphql_api.types.test_results_aggregates.test_results_aggregates import (
+ TestResultsAggregates,
+ generate_test_results_aggregates,
+)
+from utils.test_results import get_results
+
+log = logging.getLogger(__name__)
+
+INTERVAL_30_DAY = 30
+INTERVAL_7_DAY = 7
+INTERVAL_1_DAY = 1
+
+
+@dataclass
+class TestResultsRow:
+ # the order here must match the order of the fields in the query
+ name: str
+ testsuite: str | None
+ flags: list[str]
+ failure_rate: float
+ flake_rate: float
+ updated_at: dt.datetime
+ avg_duration: float
+ total_fail_count: int
+ total_flaky_fail_count: int
+ total_pass_count: int
+ total_skip_count: int
+ commits_where_fail: int
+ last_duration: float
+
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "testsuite": self.testsuite,
+ "flags": self.flags,
+ "failure_rate": self.failure_rate,
+ "flake_rate": self.flake_rate,
+ "updated_at": self.updated_at.isoformat(),
+ "avg_duration": self.avg_duration,
+ "total_fail_count": self.total_fail_count,
+ "total_flaky_fail_count": self.total_flaky_fail_count,
+ "total_pass_count": self.total_pass_count,
+ "total_skip_count": self.total_skip_count,
+ "commits_where_fail": self.commits_where_fail,
+ "last_duration": self.last_duration,
+ }
+
+
+@dataclass
+class TestResultConnection:
+ edges: list[dict[str, str | TestResultsRow]]
+ page_info: dict
+ total_count: int
+
+
+DELIMITER = "|"
+
+
+@dataclass
+class CursorValue:
+ ordered_value: float | int | dt.datetime | str
+ name: str
+
+
+def decode_cursor(
+ value: str | None, ordering: TestResultsOrderingParameter
+) -> CursorValue | None:
+ if value is None:
+ return None
+
+ split_cursor = b64decode(value.encode("ascii")).decode("utf-8").split(DELIMITER)
+ ordered_value: str = split_cursor[0]
+ name: str = split_cursor[1]
+ match ordering:
+ case (
+ TestResultsOrderingParameter.AVG_DURATION
+ | TestResultsOrderingParameter.FLAKE_RATE
+ | TestResultsOrderingParameter.FAILURE_RATE
+ | TestResultsOrderingParameter.LAST_DURATION
+ ):
+ return CursorValue(ordered_value=float(ordered_value), name=name)
+ case TestResultsOrderingParameter.COMMITS_WHERE_FAIL:
+ return CursorValue(ordered_value=int(ordered_value), name=name)
+ case TestResultsOrderingParameter.UPDATED_AT:
+ return CursorValue(
+ ordered_value=dt.datetime.fromisoformat(ordered_value), name=name
+ )
+
+ raise ValueError(f"Invalid ordering field: {ordering}")
+
+
+def encode_cursor(row: TestResultsRow, ordering: TestResultsOrderingParameter) -> str:
+ return b64encode(
+ DELIMITER.join([str(getattr(row, ordering.value)), str(row.name)]).encode(
+ "utf-8"
+ )
+ ).decode("ascii")
+
+
+def validate(
+ interval: int,
+ ordering: TestResultsOrderingParameter,
+ ordering_direction: OrderingDirection,
+ after: str | None,
+ before: str | None,
+ first: int | None,
+ last: int | None,
+) -> None:
+ if interval not in {INTERVAL_1_DAY, INTERVAL_7_DAY, INTERVAL_30_DAY}:
+ raise ValidationError(f"Invalid interval: {interval}")
+
+ if not isinstance(ordering_direction, OrderingDirection):
+ raise ValidationError(f"Invalid ordering direction: {ordering_direction}")
+
+ if not isinstance(ordering, TestResultsOrderingParameter):
+ raise ValidationError(f"Invalid ordering field: {ordering}")
+
+ if first is not None and last is not None:
+ raise ValidationError("First and last can not be used at the same time")
+
+ if after is not None and before is not None:
+ raise ValidationError("After and before can not be used at the same time")
+
+
+def ordering_expression(
+ ordering: TestResultsOrderingParameter, cursor_value: CursorValue, is_forward: bool
+) -> pl.Expr:
+ if is_forward:
+ ordering_expression = (pl.col(ordering.value) > cursor_value.ordered_value) | (
+ (pl.col(ordering.value) == cursor_value.ordered_value)
+ & (pl.col("name") > cursor_value.name)
+ )
+ else:
+ ordering_expression = (pl.col(ordering.value) < cursor_value.ordered_value) | (
+ (pl.col(ordering.value) == cursor_value.ordered_value)
+ & (pl.col("name") > cursor_value.name)
+ )
+ return ordering_expression
+
+
+def generate_test_results(
+ ordering: TestResultsOrderingParameter,
+ ordering_direction: OrderingDirection,
+ repoid: int,
+ measurement_interval: MeasurementInterval,
+ *,
+ first: int | None = None,
+ after: str | None = None,
+ last: int | None = None,
+ before: str | None = None,
+ branch: str | None = None,
+ parameter: TestResultsFilterParameter | None = None,
+ testsuites: list[str] | None = None,
+ flags: list[str] | None = None,
+ term: str | None = None,
+) -> TestResultConnection:
+ """
+ Function that retrieves aggregated information about all tests in a given repository, for a given time range, optionally filtered by branch name.
+ The fields it calculates are: the test failure rate, commits where this test failed, last duration and average duration of the test.
+
+ :param repoid: repoid of the repository we want to calculate aggregates for
+ :param branch: optional name of the branch we want to filter on, if this is provided the aggregates calculated will only take into account
+ test instances generated on that branch. By default branches will not be filtered and test instances on all branches wil be taken into
+ account.
+ :param interval: timedelta for filtering test instances used to calculate the aggregates by time, the test instances used will be
+ those with a created at larger than now - interval.
+ :param testsuites: optional list of testsuite names to filter by, this is done via a union
+ :param flags: optional list of flag names to filter by, this is done via a union so if a user specifies multiple flags, we get all tests with any
+ of the flags, not tests that have all of the flags
+ :returns: queryset object containing list of dictionaries of results
+
+ """
+ repo = Repository.objects.get(repoid=repoid)
+ if branch is None:
+ branch = repo.branch
+ interval = measurement_interval.value
+ validate(interval, ordering, ordering_direction, after, before, first, last)
+
+ table = get_results(repoid, branch, interval)
+
+ if table is None:
+ return TestResultConnection(
+ edges=[],
+ total_count=0,
+ page_info={
+ "has_next_page": False,
+ "has_previous_page": False,
+ "start_cursor": None,
+ "end_cursor": None,
+ },
+ )
+
+ if term:
+ table = table.filter(pl.col("name").str.contains(term))
+
+ if testsuites:
+ table = table.filter(
+ pl.col("testsuite").is_not_null()
+ & pl.col("testsuite").list.eval(pl.element().is_in(testsuites)).list.any()
+ )
+
+ if flags:
+ table = table.filter(
+ pl.col("flags").is_not_null()
+ & pl.col("flags").list.eval(pl.element().is_in(flags)).list.any()
+ )
+
+ match parameter:
+ case TestResultsFilterParameter.FAILED_TESTS:
+ table = table.filter(pl.col("total_fail_count") > 0)
+ case TestResultsFilterParameter.FLAKY_TESTS:
+ table = table.filter(pl.col("total_flaky_fail_count") > 0)
+ case TestResultsFilterParameter.SKIPPED_TESTS:
+ table = table.filter(
+ (pl.col("total_skip_count") > 0) & (pl.col("total_pass_count") == 0)
+ )
+ case TestResultsFilterParameter.SLOWEST_TESTS:
+ table = table.filter(
+ pl.col("avg_duration") >= pl.col("avg_duration").quantile(0.95)
+ ).top_k(
+ min(100, max(table.height // 20, 1)), by=pl.col("avg_duration")
+ ) # the top k operation here is to make sure we don't show too many slowest tests in the case of a low sample size
+
+ total_count = table.height
+
+ if after or before:
+ comparison_direction = (ordering_direction == OrderingDirection.ASC) == (
+ bool(after)
+ )
+ cursor_value = (
+ decode_cursor(after, ordering) if after else decode_cursor(before, ordering)
+ )
+ if cursor_value:
+ table = table.filter(
+ ordering_expression(ordering, cursor_value, comparison_direction)
+ )
+
+ table = table.sort(
+ [ordering.value, "name"],
+ descending=[ordering_direction == OrderingDirection.DESC, False],
+ )
+
+ if first:
+ page_elements = table.slice(0, first)
+ elif last:
+ page_elements = table.slice(-last, last)
+ else:
+ page_elements = table
+
+ rows = [TestResultsRow(**row) for row in page_elements.rows(named=True)]
+
+ page: list[dict[str, str | TestResultsRow]] = [
+ {"cursor": encode_cursor(row, ordering), "node": row} for row in rows
+ ]
+
+ return TestResultConnection(
+ edges=page,
+ total_count=total_count,
+ page_info={
+ "has_next_page": True if first and len(table) > first else False,
+ "has_previous_page": True if last and len(table) > last else False,
+ "start_cursor": page[0]["cursor"] if page else None,
+ "end_cursor": page[-1]["cursor"] if page else None,
+ },
+ )
+
+
+def get_test_suites(
+ repoid: int, term: str | None = None, interval: int = 30
+) -> list[str]:
+ repo = Repository.objects.get(repoid=repoid)
+
+ table = get_results(repoid, repo.branch, interval)
+ if table is None:
+ return []
+
+ testsuites = table.select(pl.col("testsuite")).unique()
+
+ if term:
+ testsuites = testsuites.filter(pl.col("testsuite").str.starts_with(term))
+
+ return testsuites.to_series().drop_nulls().to_list() or []
+
+
+def get_flags(repoid: int, term: str | None = None, interval: int = 30) -> list[str]:
+ repo = Repository.objects.get(repoid=repoid)
+
+ table = get_results(repoid, repo.branch, interval)
+ if table is None:
+ return []
+
+ flags = table.select(pl.col("flags").explode()).unique()
+
+ if term:
+ flags = flags.filter(pl.col("flags").str.starts_with(term))
+
+ return flags.to_series().drop_nulls().to_list() or []
+
+
+class TestResultsOrdering(TypedDict):
+ parameter: TestResultsOrderingParameter
+ direction: OrderingDirection
+
+
+class TestResultsFilters(TypedDict):
+ parameter: TestResultsFilterParameter | None
+ interval: MeasurementInterval
+ branch: str | None
+ test_suites: list[str] | None
+ flags: list[str] | None
+ term: str | None
+
+
+# Bindings for GraphQL types
+test_analytics_bindable: ObjectType = ObjectType("TestAnalytics")
+
+
+@test_analytics_bindable.field("testResults")
+async def resolve_test_results(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ ordering: TestResultsOrdering | None = None,
+ filters: TestResultsFilters | None = None,
+ first: int | None = None,
+ after: str | None = None,
+ last: int | None = None,
+ before: str | None = None,
+) -> TestResultConnection:
+ queryset = await sync_to_async(generate_test_results)(
+ ordering=ordering.get("parameter", TestResultsOrderingParameter.AVG_DURATION)
+ if ordering
+ else TestResultsOrderingParameter.AVG_DURATION,
+ ordering_direction=ordering.get("direction", OrderingDirection.DESC)
+ if ordering
+ else OrderingDirection.DESC,
+ repoid=repository.repoid,
+ measurement_interval=filters.get(
+ "interval", MeasurementInterval.INTERVAL_30_DAY
+ )
+ if filters
+ else MeasurementInterval.INTERVAL_30_DAY,
+ first=first,
+ after=after,
+ last=last,
+ before=before,
+ branch=filters.get("branch") if filters else None,
+ parameter=filters.get("parameter") if filters else None,
+ testsuites=filters.get("test_suites") if filters else None,
+ flags=filters.get("flags") if filters else None,
+ term=filters.get("term") if filters else None,
+ )
+
+ return queryset
+
+
+@test_analytics_bindable.field("testResultsAggregates")
+async def resolve_test_results_aggregates(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ interval: MeasurementInterval | None = None,
+ **_: Any,
+) -> TestResultsAggregates | None:
+ return await sync_to_async(generate_test_results_aggregates)(
+ repoid=repository.repoid,
+ interval=interval if interval else MeasurementInterval.INTERVAL_30_DAY,
+ )
+
+
+@test_analytics_bindable.field("flakeAggregates")
+async def resolve_flake_aggregates(
+ repository: Repository,
+ info: GraphQLResolveInfo,
+ interval: MeasurementInterval | None = None,
+ **_: Any,
+) -> FlakeAggregates | None:
+ return await sync_to_async(generate_flake_aggregates)(
+ repoid=repository.repoid,
+ interval=interval if interval else MeasurementInterval.INTERVAL_30_DAY,
+ )
+
+
+@test_analytics_bindable.field("testSuites")
+async def resolve_test_suites(
+ repository: Repository, info: GraphQLResolveInfo, term: str | None = None, **_: Any
+) -> list[str]:
+ return await sync_to_async(get_test_suites)(repository.repoid, term)
+
+
+@test_analytics_bindable.field("flags")
+async def resolve_flags(
+ repository: Repository, info: GraphQLResolveInfo, term: str | None = None, **_: Any
+) -> list[str]:
+ return await sync_to_async(get_flags)(repository.repoid, term)
diff --git a/apps/codecov-api/graphql_api/types/test_results/__init__.py b/apps/codecov-api/graphql_api/types/test_results/__init__.py
new file mode 100644
index 0000000000..4f9f9394f4
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results/__init__.py
@@ -0,0 +1,7 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .test_results import test_result_bindable
+
+test_results = ariadne_load_local_graphql(__file__, "test_results.graphql")
+
+__all__ = ["test_result_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/test_results/test_results.graphql b/apps/codecov-api/graphql_api/types/test_results/test_results.graphql
new file mode 100644
index 0000000000..7d7ac178d9
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results/test_results.graphql
@@ -0,0 +1,13 @@
+type TestResult {
+ updatedAt: DateTime!
+ name: String!
+ commitsFailed: Int!
+ failureRate: Float!
+ flakeRate: Float!
+ avgDuration: Float!
+ lastDuration: Float!
+ totalFailCount: Int!
+ totalFlakyFailCount: Int!
+ totalSkipCount: Int!
+ totalPassCount: Int!
+}
diff --git a/apps/codecov-api/graphql_api/types/test_results/test_results.py b/apps/codecov-api/graphql_api/types/test_results/test_results.py
new file mode 100644
index 0000000000..28d839f10a
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results/test_results.py
@@ -0,0 +1,63 @@
+from datetime import datetime
+
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+
+from graphql_api.types.test_analytics.test_analytics import TestResultsRow
+
+test_result_bindable = ObjectType("TestResult")
+
+
+@test_result_bindable.field("name")
+def resolve_name(test: TestResultsRow, _: GraphQLResolveInfo) -> str:
+ return test.name.replace("\x1f", " ")
+
+
+@test_result_bindable.field("updatedAt")
+def resolve_updated_at(test: TestResultsRow, _: GraphQLResolveInfo) -> datetime:
+ return test.updated_at
+
+
+@test_result_bindable.field("commitsFailed")
+def resolve_commits_failed(test: TestResultsRow, _: GraphQLResolveInfo) -> int:
+ return test.commits_where_fail
+
+
+@test_result_bindable.field("failureRate")
+def resolve_failure_rate(test: TestResultsRow, _: GraphQLResolveInfo) -> float:
+ return test.failure_rate
+
+
+@test_result_bindable.field("flakeRate")
+def resolve_flake_rate(test: TestResultsRow, _: GraphQLResolveInfo) -> float:
+ return test.flake_rate
+
+
+@test_result_bindable.field("avgDuration")
+def resolve_avg_duration(test: TestResultsRow, _: GraphQLResolveInfo) -> float:
+ return test.avg_duration
+
+
+@test_result_bindable.field("lastDuration")
+def resolve_last_duration(test: TestResultsRow, _: GraphQLResolveInfo) -> float:
+ return test.last_duration
+
+
+@test_result_bindable.field("totalFailCount")
+def resolve_total_fail_count(test: TestResultsRow, _: GraphQLResolveInfo) -> int:
+ return test.total_fail_count
+
+
+@test_result_bindable.field("totalFlakyFailCount")
+def resolve_total_flaky_fail_count(test: TestResultsRow, _: GraphQLResolveInfo) -> int:
+ return test.total_flaky_fail_count
+
+
+@test_result_bindable.field("totalSkipCount")
+def resolve_total_skip_count(test: TestResultsRow, _: GraphQLResolveInfo) -> int:
+ return test.total_skip_count
+
+
+@test_result_bindable.field("totalPassCount")
+def resolve_total_pass_count(test: TestResultsRow, _: GraphQLResolveInfo) -> int:
+ return test.total_pass_count
diff --git a/apps/codecov-api/graphql_api/types/test_results_aggregates/__init__.py b/apps/codecov-api/graphql_api/types/test_results_aggregates/__init__.py
new file mode 100644
index 0000000000..33eafe87dd
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results_aggregates/__init__.py
@@ -0,0 +1,9 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .test_results_aggregates import test_results_aggregates_bindable
+
+test_results_aggregates = ariadne_load_local_graphql(
+ __file__, "test_results_aggregates.graphql"
+)
+
+__all__ = ["test_results_aggregates_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.graphql b/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.graphql
new file mode 100644
index 0000000000..e225bc1ff3
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.graphql
@@ -0,0 +1,12 @@
+type TestResultsAggregates {
+ totalDuration: Float!
+ totalDurationPercentChange: Float
+ slowestTestsDuration: Float!
+ slowestTestsDurationPercentChange: Float
+ totalFails: Int!
+ totalFailsPercentChange: Float
+ totalSkips: Int!
+ totalSkipsPercentChange: Float
+ totalSlowTests: Int!
+ totalSlowTestsPercentChange: Float
+}
diff --git a/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.py b/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.py
new file mode 100644
index 0000000000..da99787e3c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/test_results_aggregates/test_results_aggregates.py
@@ -0,0 +1,164 @@
+from dataclasses import dataclass
+
+import polars as pl
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+from shared.django_apps.core.models import Repository
+
+from graphql_api.types.enums.enum_types import MeasurementInterval
+from utils.test_results import get_results
+
+
+@dataclass
+class TestResultsAggregates:
+ total_duration: float
+ slowest_tests_duration: float
+ total_slow_tests: int
+ fails: int
+ skips: int
+ total_duration_percent_change: float | None = None
+ slowest_tests_duration_percent_change: float | None = None
+ total_slow_tests_percent_change: float | None = None
+ fails_percent_change: float | None = None
+ skips_percent_change: float | None = None
+
+
+def calculate_aggregates(table: pl.DataFrame) -> pl.DataFrame:
+ return table.select(
+ (
+ pl.col("avg_duration")
+ * (pl.col("total_pass_count") + pl.col("total_fail_count"))
+ )
+ .sum()
+ .alias("total_duration"),
+ (
+ pl.when(pl.col("avg_duration") >= pl.col("avg_duration").quantile(0.95))
+ .then(
+ pl.col("avg_duration")
+ * (pl.col("total_pass_count") + pl.col("total_fail_count"))
+ )
+ .otherwise(0)
+ .top_k(min(100, max(table.height // 20, 1)))
+ .sum()
+ .alias("slowest_tests_duration")
+ ),
+ (pl.col("total_skip_count").sum()).alias("skips"),
+ (pl.col("total_fail_count").sum()).alias("fails"),
+ (
+ (pl.col("avg_duration") >= pl.col("avg_duration").quantile(0.95))
+ .top_k(min(100, max(table.height // 20, 1)))
+ .sum()
+ ).alias("total_slow_tests"),
+ )
+
+
+def test_results_aggregates_from_table(
+ table: pl.DataFrame,
+) -> TestResultsAggregates:
+ aggregates = calculate_aggregates(table).row(0, named=True)
+ return TestResultsAggregates(**aggregates)
+
+
+def test_results_aggregates_with_percentage(
+ curr_results: pl.DataFrame,
+ past_results: pl.DataFrame,
+) -> TestResultsAggregates:
+ curr_aggregates = calculate_aggregates(curr_results)
+ past_aggregates = calculate_aggregates(past_results)
+
+ merged_results: pl.DataFrame = pl.concat([past_aggregates, curr_aggregates])
+
+ # with_columns upserts the new columns, so if the name already exists it get overwritten
+ # otherwise it's just added
+ merged_results = merged_results.with_columns(
+ pl.all()
+ .pct_change()
+ .replace([float("inf"), float("-inf")], None)
+ .fill_nan(0)
+ .name.suffix("_percent_change")
+ )
+ aggregates = merged_results.row(1, named=True)
+
+ return TestResultsAggregates(**aggregates)
+
+
+def generate_test_results_aggregates(
+ repoid: int, interval: MeasurementInterval
+) -> TestResultsAggregates | None:
+ repo = Repository.objects.get(repoid=repoid)
+
+ curr_results = get_results(repo.repoid, repo.branch, interval.value)
+ if curr_results is None:
+ return None
+ past_results = get_results(
+ repo.repoid, repo.branch, interval.value * 2, interval.value
+ )
+ if past_results is None:
+ return test_results_aggregates_from_table(curr_results)
+ else:
+ return test_results_aggregates_with_percentage(curr_results, past_results)
+
+
+test_results_aggregates_bindable = ObjectType("TestResultsAggregates")
+
+
+@test_results_aggregates_bindable.field("totalDuration")
+def resolve_total_duration(obj: TestResultsAggregates, _: GraphQLResolveInfo) -> float:
+ return obj.total_duration
+
+
+@test_results_aggregates_bindable.field("totalDurationPercentChange")
+def resolve_total_duration_percent_change(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.total_duration_percent_change
+
+
+@test_results_aggregates_bindable.field("slowestTestsDuration")
+def resolve_slowest_tests_duration(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float:
+ return obj.slowest_tests_duration
+
+
+@test_results_aggregates_bindable.field("slowestTestsDurationPercentChange")
+def resolve_slowest_tests_duration_percent_change(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.slowest_tests_duration_percent_change
+
+
+@test_results_aggregates_bindable.field("totalSlowTests")
+def resolve_total_slow_tests(obj: TestResultsAggregates, _: GraphQLResolveInfo) -> int:
+ return obj.total_slow_tests
+
+
+@test_results_aggregates_bindable.field("totalSlowTestsPercentChange")
+def resolve_total_slow_tests_percent_change(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.total_slow_tests_percent_change
+
+
+@test_results_aggregates_bindable.field("totalFails")
+def resolve_total_fails(obj: TestResultsAggregates, _: GraphQLResolveInfo) -> int:
+ return obj.fails
+
+
+@test_results_aggregates_bindable.field("totalFailsPercentChange")
+def resolve_total_fails_percent_change(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.fails_percent_change
+
+
+@test_results_aggregates_bindable.field("totalSkips")
+def resolve_total_skips(obj: TestResultsAggregates, _: GraphQLResolveInfo) -> int:
+ return obj.skips
+
+
+@test_results_aggregates_bindable.field("totalSkipsPercentChange")
+def resolve_total_skips_percent_change(
+ obj: TestResultsAggregates, _: GraphQLResolveInfo
+) -> float | None:
+ return obj.skips_percent_change
diff --git a/apps/codecov-api/graphql_api/types/upload/__init__.py b/apps/codecov-api/graphql_api/types/upload/__init__.py
new file mode 100644
index 0000000000..59f00b7129
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/upload/__init__.py
@@ -0,0 +1,7 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .upload import upload_bindable, upload_error_bindable
+
+upload = ariadne_load_local_graphql(__file__, "upload.graphql")
+
+__all__ = ["upload", "upload_bindable", "upload_error_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/upload/upload.graphql b/apps/codecov-api/graphql_api/types/upload/upload.graphql
new file mode 100644
index 0000000000..910d84281e
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/upload/upload.graphql
@@ -0,0 +1,30 @@
+type Upload {
+ state: UploadState!
+ provider: String
+ createdAt: DateTime!
+ updatedAt: DateTime!
+ flags: [String]
+ downloadUrl: String!
+ ciUrl: String
+ uploadType: UploadType!
+ jobCode: String
+ buildCode: String
+ errors: UploadErrorsConnection
+ name: String
+ id: Int
+}
+
+type UploadErrorsConnection {
+ edges: [UploadErrorsEdge]!
+ totalCount: Int!
+ pageInfo: PageInfo!
+}
+
+type UploadErrorsEdge {
+ cursor: String!
+ node: UploadError!
+}
+
+type UploadError {
+ errorCode: UploadErrorEnum
+}
diff --git a/apps/codecov-api/graphql_api/types/upload/upload.py b/apps/codecov-api/graphql_api/types/upload/upload.py
new file mode 100644
index 0000000000..393f228765
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/upload/upload.py
@@ -0,0 +1,84 @@
+from typing import Optional
+
+from ariadne import ObjectType
+from asgiref.sync import sync_to_async
+from django.urls import reverse
+from graphql import GraphQLResolveInfo
+from shared.django_apps.utils.services import get_short_service_name
+
+from graphql_api.helpers.connection import queryset_to_connection_sync
+from graphql_api.types.enums import (
+ UploadErrorEnum,
+ UploadState,
+ UploadType,
+)
+from reports.models import ReportSession
+
+upload_bindable = ObjectType("Upload")
+upload_bindable.set_alias("flags", "flag_names")
+
+upload_error_bindable = ObjectType("UploadError")
+
+"""
+ Note Uploads are called ReportSession in the model, so I'm keeping the argument
+ in line with the code vs product name.
+"""
+
+
+@upload_bindable.field("state")
+def resolve_state(upload: ReportSession, info: GraphQLResolveInfo) -> UploadState:
+ if not upload.state:
+ return UploadState.ERROR
+ return UploadState(upload.state)
+
+
+@upload_bindable.field("id")
+def resolve_id(upload: ReportSession, info: GraphQLResolveInfo) -> Optional[int]:
+ return upload.order_number
+
+
+@upload_bindable.field("uploadType")
+def resolve_upload_type(upload: ReportSession, info: GraphQLResolveInfo) -> UploadType:
+ return UploadType(upload.upload_type)
+
+
+@upload_bindable.field("errors")
+@sync_to_async
+def resolve_errors(report_session: ReportSession, info: GraphQLResolveInfo, **kwargs):
+ return queryset_to_connection_sync(list(report_session.errors.all()))
+
+
+@upload_error_bindable.field("errorCode")
+def resolve_error_code(error, info: GraphQLResolveInfo) -> UploadErrorEnum:
+ return UploadErrorEnum(error.error_code)
+
+
+@upload_bindable.field("ciUrl")
+@sync_to_async
+def resolve_ci_url(upload: ReportSession, info: GraphQLResolveInfo):
+ return upload.ci_url
+
+
+@upload_bindable.field("downloadUrl")
+@sync_to_async
+def resolve_download_url(upload: ReportSession, info) -> str:
+ request = info.context["request"]
+ repository = upload.report.commit.repository
+ download_url = (
+ reverse(
+ "upload-download",
+ kwargs={
+ "service": get_short_service_name(repository.author.service),
+ "owner_username": repository.author.username,
+ "repo_name": repository.name,
+ },
+ )
+ + f"?path={upload.storage_path}"
+ )
+ download_absolute_uri = request.build_absolute_uri(download_url)
+ return download_absolute_uri.replace("http", "https", 1)
+
+
+@upload_bindable.field("name")
+def resolve_name(upload, info):
+ return upload.name
diff --git a/apps/codecov-api/graphql_api/types/user/__init__.py b/apps/codecov-api/graphql_api/types/user/__init__.py
new file mode 100644
index 0000000000..94580c9b04
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user/__init__.py
@@ -0,0 +1,3 @@
+from .user import user, user_bindable
+
+__all__ = ["user", "user_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/user/user.graphql b/apps/codecov-api/graphql_api/types/user/user.graphql
new file mode 100644
index 0000000000..acfbab5157
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user/user.graphql
@@ -0,0 +1,10 @@
+type User {
+ username: String!
+ name: String
+ avatarUrl: String!
+ student: Boolean!
+ studentCreatedAt: DateTime
+ studentUpdatedAt: DateTime
+ # this will no longer be updated from the UI with Appless
+ customerIntent: String
+}
diff --git a/apps/codecov-api/graphql_api/types/user/user.py b/apps/codecov-api/graphql_api/types/user/user.py
new file mode 100644
index 0000000000..aee121b032
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user/user.py
@@ -0,0 +1,55 @@
+from datetime import datetime
+from typing import Optional
+
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+
+from codecov_auth.models import Owner
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+user = ariadne_load_local_graphql(__file__, "user.graphql")
+
+user_bindable = ObjectType("User")
+
+
+@user_bindable.field("username")
+def resolve_username(user: Owner, info: GraphQLResolveInfo) -> str:
+ return user.username
+
+
+@user_bindable.field("name")
+def resolve_name(user: Owner, info: GraphQLResolveInfo) -> Optional[str]:
+ return user.name
+
+
+@user_bindable.field("avatarUrl")
+def resolve_avatar_url(user: Owner, info: GraphQLResolveInfo) -> str:
+ return user.avatar_url
+
+
+@user_bindable.field("student")
+def resolve_student(user: Owner, info: GraphQLResolveInfo) -> bool:
+ return user.student
+
+
+@user_bindable.field("studentCreatedAt")
+def resolve_student_created_at(
+ user: Owner, info: GraphQLResolveInfo
+) -> Optional[datetime]:
+ return user.student_created_at
+
+
+@user_bindable.field("studentUpdatedAt")
+def resolve_student_updated_at(
+ user: Owner, info: GraphQLResolveInfo
+) -> Optional[datetime]:
+ return user.student_updated_at
+
+
+# this will no longer be updated from the UI
+@user_bindable.field("customerIntent")
+def resolve_customer_intent(user: Owner, info: GraphQLResolveInfo) -> Optional[str]:
+ owner = user
+ if not owner.user:
+ return None
+ return owner.user.customer_intent
diff --git a/apps/codecov-api/graphql_api/types/user_token/__init__.py b/apps/codecov-api/graphql_api/types/user_token/__init__.py
new file mode 100644
index 0000000000..445e55d62f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user_token/__init__.py
@@ -0,0 +1,8 @@
+from graphql_api.helpers.ariadne import ariadne_load_local_graphql
+
+from .user_token import user_token_bindable
+
+user_token = ariadne_load_local_graphql(__file__, "user_token.graphql")
+
+
+__all__ = ["user_token", "user_token_bindable"]
diff --git a/apps/codecov-api/graphql_api/types/user_token/user_token.graphql b/apps/codecov-api/graphql_api/types/user_token/user_token.graphql
new file mode 100644
index 0000000000..7e8ff1942f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user_token/user_token.graphql
@@ -0,0 +1,7 @@
+type UserToken {
+ id: String!
+ name: String!
+ type: String!
+ lastFour: String!
+ expiration: DateTime
+}
\ No newline at end of file
diff --git a/apps/codecov-api/graphql_api/types/user_token/user_token.py b/apps/codecov-api/graphql_api/types/user_token/user_token.py
new file mode 100644
index 0000000000..c7dd034672
--- /dev/null
+++ b/apps/codecov-api/graphql_api/types/user_token/user_token.py
@@ -0,0 +1,25 @@
+from ariadne import ObjectType
+
+from codecov_auth.models import UserToken
+
+user_token_bindable = ObjectType("UserToken")
+
+
+@user_token_bindable.field("id")
+def resolve_id(user_token: UserToken, info):
+ return user_token.external_id
+
+
+@user_token_bindable.field("type")
+def resolve_type(user_token: UserToken, info):
+ return user_token.token_type
+
+
+@user_token_bindable.field("lastFour")
+def resolve_last_four(user_token: UserToken, info):
+ return str(user_token.token)[-4:]
+
+
+@user_token_bindable.field("expiration")
+def resolve_expiration(user_token: UserToken, info):
+ return user_token.valid_until
diff --git a/apps/codecov-api/graphql_api/urls.py b/apps/codecov-api/graphql_api/urls.py
new file mode 100644
index 0000000000..a71cce0b4f
--- /dev/null
+++ b/apps/codecov-api/graphql_api/urls.py
@@ -0,0 +1,25 @@
+from django.urls import re_path
+
+from .views import ariadne_view
+
+ALLOWED_SERVICES = [
+ "gh",
+ "github",
+ "gl",
+ "gitlab",
+ "bb",
+ "bitbucket",
+ "ghe",
+ "github_enterprise",
+ "gle",
+ "gitlab_enterprise",
+ "bbs",
+ "bitbucket_server",
+ "",
+]
+
+service_regex = "|".join(ALLOWED_SERVICES)
+
+urlpatterns = [
+ re_path(r"^(?P({}))$".format(service_regex), ariadne_view, name="graphql"),
+]
diff --git a/apps/codecov-api/graphql_api/validation.py b/apps/codecov-api/graphql_api/validation.py
new file mode 100644
index 0000000000..b9886b0cd2
--- /dev/null
+++ b/apps/codecov-api/graphql_api/validation.py
@@ -0,0 +1,114 @@
+from typing import Any, Dict, Type
+
+from graphql import GraphQLError, ValidationRule
+from graphql.language.ast import (
+ DocumentNode,
+ FieldNode,
+ OperationDefinitionNode,
+ VariableDefinitionNode,
+)
+from graphql.validation import ValidationContext
+
+
+class MissingVariablesError(Exception):
+ """
+ Custom error class to represent errors where required variables defined in the query does
+ not have a matching definition in the variables part of the request. Normally when this
+ scenario occurs it would raise a GraphQLError type but that would cause a uncaught
+ exception for some reason. The aim of this is to surface the error in the response clearly
+ and to prevent internal server errors when it occurs.
+ """
+
+ pass
+
+
+def create_required_variables_rule(variables: Dict) -> Type[ValidationRule]:
+ class RequiredVariablesValidationRule(ValidationRule):
+ def __init__(self, context: ValidationContext) -> None:
+ super().__init__(context)
+ self.variables = variables
+
+ def enter_operation_definition(
+ self, node: OperationDefinitionNode, *_args: Any
+ ) -> None:
+ # Get variable definitions
+ variable_definitions = node.variable_definitions or []
+
+ # Extract variables marked as Non Null
+ required_variables = [
+ var_def.variable.name.value
+ for var_def in variable_definitions
+ if isinstance(var_def, VariableDefinitionNode)
+ and var_def.type.kind == "non_null_type"
+ ]
+
+ # Check if these required variables are provided
+ missing_variables = [
+ var for var in required_variables if var not in self.variables
+ ]
+ if missing_variables:
+ raise MissingVariablesError(
+ f"Missing required variables: {', '.join(missing_variables)}",
+ )
+
+ return RequiredVariablesValidationRule
+
+
+def create_max_depth_rule(max_depth: int) -> Type[ValidationRule]:
+ class MaxDepthRule(ValidationRule):
+ def __init__(self, context: ValidationContext) -> None:
+ super().__init__(context)
+ self.operation_depth: int = 1
+ self.max_depth_reached: bool = False
+ self.max_depth: int = max_depth
+
+ def enter_operation_definition(
+ self, node: OperationDefinitionNode, *_args: Any
+ ) -> None:
+ self.operation_depth = 1
+ self.max_depth_reached = False
+
+ def enter_field(self, node: FieldNode, *_args: Any) -> None:
+ self.operation_depth += 1
+
+ if self.operation_depth > self.max_depth and not self.max_depth_reached:
+ self.max_depth_reached = True
+ self.report_error(
+ GraphQLError(
+ "Query depth exceeds the maximum allowed depth",
+ node,
+ )
+ )
+
+ def leave_field(self, node: FieldNode, *_args: Any) -> None:
+ self.operation_depth -= 1
+
+ return MaxDepthRule
+
+
+def create_max_aliases_rule(max_aliases: int) -> Type[ValidationRule]:
+ class MaxAliasesRule(ValidationRule):
+ def __init__(self, context: ValidationContext) -> None:
+ super().__init__(context)
+ self.alias_count: int = 0
+ self.has_reported_error: bool = False
+ self.max_aliases: int = max_aliases
+
+ def enter_document(self, node: DocumentNode, *_args: Any) -> None:
+ self.alias_count = 0
+ self.has_reported_error = False
+
+ def enter_field(self, node: FieldNode, *_args: Any) -> None:
+ if node.alias:
+ self.alias_count += 1
+
+ if self.alias_count > self.max_aliases and not self.has_reported_error:
+ self.has_reported_error = True
+ self.report_error(
+ GraphQLError(
+ "Query uses too many aliases",
+ node,
+ )
+ )
+
+ return MaxAliasesRule
diff --git a/apps/codecov-api/graphql_api/views.py b/apps/codecov-api/graphql_api/views.py
new file mode 100644
index 0000000000..3be0a9e01c
--- /dev/null
+++ b/apps/codecov-api/graphql_api/views.py
@@ -0,0 +1,431 @@
+import json
+import logging
+import os
+import socket
+import time
+from asyncio import iscoroutine
+from typing import Any, Collection, Optional
+
+import regex
+from ariadne import format_error
+from ariadne.types import Extension
+from ariadne.validation import cost_validator
+from ariadne_django.views import GraphQLAsyncView
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from django.core.handlers.wsgi import WSGIRequest
+from django.http import (
+ HttpResponse,
+ HttpResponseBadRequest,
+ HttpResponseNotAllowed,
+ JsonResponse,
+)
+from graphql import DocumentNode
+from sentry_sdk import capture_exception
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import Counter, Histogram, inc_counter
+
+from codecov.commands.exceptions import BaseException
+from codecov.commands.executor import get_executor_from_request
+from services import ServiceException
+
+from .schema import schema
+from .validation import (
+ MissingVariablesError,
+ create_max_aliases_rule,
+ create_max_depth_rule,
+ create_required_variables_rule,
+)
+
+log = logging.getLogger(__name__)
+
+GQL_HIT_COUNTER = Counter(
+ "api_gql_counts_hits",
+ "Number of times API GQL endpoint request starts",
+ ["operation_type", "operation_name"],
+)
+
+GQL_ERROR_COUNTER = Counter(
+ "api_gql_counts_errors",
+ "Number of times API GQL endpoint failed with an exception",
+ ["operation_type", "operation_name"],
+)
+
+GQL_REQUEST_LATENCIES = Histogram(
+ "api_gql_timers_full_runtime_seconds",
+ "Total runtime in seconds of this query",
+ ["operation_type", "operation_name"],
+ buckets=[0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10, 30],
+)
+
+GQL_REQUEST_MADE_COUNTER = Counter(
+ "api_gql_requests_made",
+ "Total API GQL requests made",
+ ["path"],
+)
+
+GQL_ERROR_TYPE_COUNTER = Counter(
+ "api_gql_errors",
+ "Number of times API GQL endpoint failed with an exception by type",
+ ["error_type", "path"],
+)
+
+# covers named and 3 unnamed operations (see graphql_api/types/query/query.py)
+GQL_TYPE_AND_NAME_PATTERN = r"^(query|mutation|subscription)(?:\(\$input:|) (\w+)(?:\(| \(|{| {|!)|^(?:{) (me|owner|config)(?:\(| |{)"
+
+
+class QueryMetricsExtension(Extension):
+ """
+ We have named and unnamed operations, we want to collect metrics on both.
+ named operations have an operation_type and operation_name,
+ ex: "query MySession { operation body }"
+ would be tracked as operation_type = query, operation_name = MySession
+ we have `me`, `owner`, and `config` as unnamed operations,
+ ex: "{ owner(username: "%s") { continued operation body } }"
+ this operation would be tracked as operation_type = unknown_type, operation_name = owner
+
+ """
+
+ def __init__(self) -> None:
+ self.start_timestamp: float = 0
+ self.end_timestamp: float = 0
+ self.operation_type: str | None = None
+ self.operation_name: str | None = None
+
+ def set_type_and_name(self, query: str) -> None:
+ operation_type = "unknown_type" # default value
+ operation_name = "unknown_name" # default value
+ try:
+ match_obj = regex.match(GQL_TYPE_AND_NAME_PATTERN, query, timeout=2)
+ except TimeoutError:
+ # does not block the rest of the gql request, logs and falls back to default values
+ query_slice = query[:30] if len(query) > 30 else query
+ log.error("Regex Timeout Error", extra=dict(query_slice=query_slice))
+ match_obj = None
+
+ if match_obj:
+ if match_obj.group(1) is not None:
+ operation_type = match_obj.group(1)
+
+ if match_obj.group(2) is not None:
+ operation_name = match_obj.group(2)
+ elif match_obj.group(3) is not None:
+ operation_name = match_obj.group(3)
+
+ self.operation_type = operation_type
+ self.operation_name = operation_name
+ if operation_type == "unknown_type" and operation_name == "unknown_name":
+ query_slice = query[:30] if len(query) > 30 else query
+ log.info(
+ "Could not match gql query format for logging",
+ extra=dict(query_slice=query_slice),
+ )
+
+ def request_started(self, context: dict[str, Any]) -> None:
+ """
+ Extension hook executed at request's start.
+ """
+ self.set_type_and_name(query=context["clean_query"])
+ self.start_timestamp = time.perf_counter()
+ inc_counter(
+ GQL_HIT_COUNTER,
+ labels=dict(
+ operation_type=self.operation_type,
+ operation_name=self.operation_name,
+ ),
+ )
+
+ def request_finished(self, context: dict[str, Any]) -> None:
+ """
+ Extension hook executed at request's end.
+ """
+ self.end_timestamp = time.perf_counter()
+ latency = self.end_timestamp - self.start_timestamp
+ GQL_REQUEST_LATENCIES.labels(
+ operation_type=self.operation_type, operation_name=self.operation_name
+ ).observe(latency)
+
+ def has_errors(self, errors: list[dict[str, Any]], context: dict[str, Any]) -> None:
+ """
+ Extension hook executed when GraphQL encountered errors.
+ """
+ GQL_ERROR_COUNTER.labels(
+ operation_type=self.operation_type, operation_name=self.operation_name
+ ).inc(len(errors))
+
+
+class RequestFinalizer:
+ """
+ A context manager class used as a teardown step after the GraphQL request is fully handled.
+ """
+
+ # List of keys representing files to be deleted during cleanup
+ TO_BE_DELETED_FILES = [
+ "bundle_analysis_head_report_db_path",
+ "bundle_analysis_base_report_db_path",
+ ]
+
+ def __init__(self, request: WSGIRequest) -> None:
+ self.request = request
+
+ def _remove_temp_files(self) -> None:
+ """
+ Some requests cause temporary files to be created in /tmp (eg BundleAnalysis)
+ This cleanup step clears all contents of the /tmp directory after each request
+ """
+ for key in RequestFinalizer.TO_BE_DELETED_FILES:
+ if hasattr(self.request, key):
+ file_path = getattr(self.request, key)
+ if file_path:
+ try:
+ if os.path.isfile(file_path) or os.path.islink(file_path):
+ os.unlink(file_path)
+ except Exception as e:
+ log.info(
+ "Failed to delete temp file",
+ extra={"file_path": file_path, "exc": e},
+ )
+
+ def __enter__(self) -> None:
+ pass
+
+ def __exit__(self, exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
+ self._remove_temp_files()
+
+
+class AsyncGraphqlView(GraphQLAsyncView):
+ schema = schema
+ extensions = [QueryMetricsExtension]
+ introspection = settings.GRAPHQL_INTROSPECTION_ENABLED
+
+ def get_validation_rules(
+ self,
+ context_value: Optional[Any],
+ document: DocumentNode,
+ data: dict,
+ ) -> Optional[Collection]:
+ return [
+ create_required_variables_rule(variables=data.get("variables", {})),
+ create_max_aliases_rule(max_aliases=settings.GRAPHQL_MAX_ALIASES),
+ create_max_depth_rule(max_depth=settings.GRAPHQL_MAX_DEPTH),
+ cost_validator(
+ maximum_cost=settings.GRAPHQL_QUERY_COST_THRESHOLD,
+ default_cost=1,
+ variables=data.get("variables"),
+ ),
+ ]
+
+ validation_rules = get_validation_rules # type: ignore
+
+ def get_clean_query(self, request_body: dict[str, Any]) -> str | None:
+ # clean up graphql query to remove new lines and extra spaces
+ if "query" in request_body and isinstance(request_body["query"], str):
+ clean_query = request_body["query"].replace("\n", " ")
+ clean_query = clean_query.replace(" ", "").strip()
+ return clean_query
+
+ async def get(self, *args: Any, **kwargs: Any) -> HttpResponse:
+ if settings.GRAPHQL_PLAYGROUND:
+ return await super().get(*args, **kwargs)
+ # No GraphqlPlayground if no settings.DEBUG
+ return HttpResponseNotAllowed(["POST"])
+
+ async def post(
+ self, request: WSGIRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse:
+ await self._get_user(request)
+ # get request body information for logging
+ req_body = json.loads(request.body.decode("utf-8")) if request.body else {}
+
+ # get request path information for logging
+ req_path = request.get_full_path()
+
+ # clean up graphql query for logging, remove new lines and extra spaces
+ cleaned_query = self.get_clean_query(req_body)
+ if cleaned_query:
+ req_body["query"] = cleaned_query
+
+ # put everything together for log
+ log_data = {
+ "server_hostname": socket.gethostname(),
+ "request_method": request.method,
+ "request_path": req_path,
+ "request_body": req_body,
+ "user": request.user,
+ }
+ log.info("GraphQL Request", extra=log_data)
+ inc_counter(GQL_REQUEST_MADE_COUNTER, labels=dict(path=req_path))
+ if self._check_ratelimit(request=request):
+ inc_counter(
+ GQL_ERROR_TYPE_COUNTER,
+ labels=dict(error_type="rate_limit", path=req_path),
+ )
+ return JsonResponse(
+ data={
+ "status": 429,
+ "detail": f"It looks like you've hit the rate limit of {settings.GRAPHQL_RATE_LIMIT_RPM} req/min. Try again later.",
+ },
+ status=429,
+ )
+
+ with RequestFinalizer(request):
+ try:
+ response = await super().post(request, *args, **kwargs)
+ except MissingVariablesError as e:
+ return JsonResponse(
+ data={
+ "status": 400,
+ "detail": str(e),
+ },
+ status=400,
+ )
+
+ content = response.content.decode("utf-8")
+ try:
+ data = json.loads(content)
+ except json.JSONDecodeError:
+ log.error(
+ "Failed to decode JSON response",
+ extra={"content": content, "request_body": req_body},
+ )
+ return JsonResponse(
+ data={
+ "status": 400,
+ "detail": "Invalid JSON response received.",
+ },
+ status=400,
+ )
+
+ if "errors" in data:
+ inc_counter(
+ GQL_ERROR_TYPE_COUNTER,
+ labels=dict(error_type="all", path=req_path),
+ )
+ try:
+ if data["errors"][0]["extensions"]["cost"]:
+ costs = data["errors"][0]["extensions"]["cost"]
+ log.error(
+ "Query Cost Exceeded",
+ extra=dict(
+ requested_cost=costs.get("requestedQueryCost"),
+ maximum_cost=costs.get("maximumAvailable"),
+ request_body=req_body,
+ ),
+ )
+ inc_counter(
+ GQL_ERROR_TYPE_COUNTER,
+ labels=dict(
+ error_type="query_cost_exceeded",
+ path=req_path,
+ ),
+ )
+ return HttpResponseBadRequest(
+ JsonResponse("Your query is too costly.")
+ )
+ except Exception:
+ pass
+ return response
+
+ def context_value(self, request: WSGIRequest, *_args: Any) -> dict[str, Any]:
+ request_body = json.loads(request.body.decode("utf-8")) if request.body else {}
+ self.request = request
+
+ return {
+ "request": request,
+ "service": request.resolver_match.kwargs["service"],
+ "executor": get_executor_from_request(request),
+ "clean_query": self.get_clean_query(request_body) if request_body else "",
+ }
+
+ def error_formatter(self, error: Any, debug: bool = False) -> dict[str, Any]:
+ user = self.request.user
+ is_anonymous = user.is_anonymous if user else True
+ # the only way to check for a malformed query
+ is_bad_query = "Cannot query field" in error.formatted["message"]
+ if debug or (not is_anonymous and is_bad_query):
+ return format_error(error, debug)
+ formatted = error.formatted
+ formatted["message"] = "INTERNAL SERVER ERROR"
+ formatted["type"] = "ServerError"
+ # if this is one of our own command exception, we can tell a bit more
+ original_error = error.original_error
+ if isinstance(original_error, BaseException) or isinstance(
+ original_error, ServiceException
+ ):
+ formatted["message"] = original_error.message # type: ignore
+ formatted["type"] = type(original_error).__name__
+ else:
+ # otherwise it's not supposed to happen, so we log it
+ log.error("GraphQL internal server error", exc_info=original_error)
+ capture_exception(original_error)
+ return formatted
+
+ @sync_to_async
+ def _get_user(self, request: WSGIRequest) -> None:
+ # force eager evaluation of `request.user` (a lazy object)
+ # while we're in a sync context
+ if request.user:
+ request.user.pk
+
+ def _check_ratelimit(self, request: WSGIRequest) -> bool:
+ if not settings.GRAPHQL_RATE_LIMIT_ENABLED:
+ return False
+
+ redis = get_redis_connection()
+
+ try:
+ # eagerly try to get user_id from request object
+ user_id = request.user.pk
+ except AttributeError:
+ user_id = None
+
+ if user_id:
+ key = f"rl-user:{user_id}"
+ else:
+ user_ip = self.get_client_ip(request)
+ key = f"rl-ip:{user_ip}"
+
+ limit = settings.GRAPHQL_RATE_LIMIT_RPM
+ window = 60 # in seconds
+
+ current_count = redis.get(key)
+ if current_count is None:
+ log.info(
+ "[GQL Rate Limit] - Setting new key",
+ extra=dict(key=key, user_id=user_id),
+ )
+ redis.set(name=key, ex=window, value=1)
+ elif int(current_count) >= limit:
+ log.warning(
+ "[GQL Rate Limit] - Rate limit reached for key",
+ extra=dict(key=key, limit=limit, count=current_count, user_id=user_id),
+ )
+ return True
+ else:
+ log.warning(
+ "[GQL Rate Limit] - Incrementing rate limit for key",
+ extra=dict(key=key, limit=limit, count=current_count, user_id=user_id),
+ )
+ redis.incr(key)
+ return False
+
+ def get_client_ip(self, request: WSGIRequest) -> str:
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
+ if x_forwarded_for:
+ ip = x_forwarded_for.split(",")[0]
+ else:
+ ip = request.META.get("REMOTE_ADDR")
+ return ip
+
+
+BaseAriadneView = AsyncGraphqlView.as_view()
+
+
+async def ariadne_view(request: WSGIRequest, service: str) -> HttpResponse:
+ response = BaseAriadneView(request, service)
+ if iscoroutine(response):
+ response = await response
+ return response
+
+
+ariadne_view.csrf_exempt = True
diff --git a/apps/codecov-api/graphs/__init__.py b/apps/codecov-api/graphs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphs/badges/badges.py b/apps/codecov-api/graphs/badges/badges.py
new file mode 100644
index 0000000000..663bb1f3a7
--- /dev/null
+++ b/apps/codecov-api/graphs/badges/badges.py
@@ -0,0 +1,99 @@
+large_badge = """
+"""
+
+medium_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ {1}%
+ {1}%
+
+
+
+
+
+"""
+
+small_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ {1}%
+ {1}%
+
+
+
+
+
+"""
+
+unknown_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+"""
diff --git a/apps/codecov-api/graphs/helpers/__init__.py b/apps/codecov-api/graphs/helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/graphs/helpers/badge.py b/apps/codecov-api/graphs/helpers/badge.py
new file mode 100644
index 0000000000..2e24a9e9c4
--- /dev/null
+++ b/apps/codecov-api/graphs/helpers/badge.py
@@ -0,0 +1,126 @@
+from shared.helpers.color import coverage_to_color
+
+from graphs.badges.badges import large_badge, medium_badge, small_badge, unknown_badge
+
+
+def get_badge(coverage: str | None, coverage_range: list[int], precision: str):
+ """
+ Returns and SVG string containing coverage badge
+
+ Parameters:
+ coverage (str): coverage to be displayed in badge
+ coverage_range (array): array containing two values:
+ coverage_range[0] (int): coverage low threshold to use red as badge color
+ coverage_range[1] (int): coverage high threshold to use green as badge color
+ precision: (str): amount of decimals to be displayed in badge
+ """
+ precision = int(precision)
+ if coverage is not None:
+ # Get color for badge
+ color = coverage_to_color(*coverage_range)(coverage)
+ # Use medium badge to fit coverage of 100%
+ if float(coverage) == 100:
+ badge = medium_badge
+ # Use badge size based on precision (0 = small, 1 = medium, 2 = large)
+ elif precision == 0:
+ badge = small_badge
+ elif precision == 1:
+ badge = medium_badge
+ else:
+ badge = large_badge
+ else:
+ badge = unknown_badge
+
+ return (
+ badge.format(color.hex, coverage).strip() if badge != unknown_badge else badge
+ )
+
+
+def format_coverage_precision(coverage: float | None, precision: int):
+ """
+ Returns coverage as a string formatted with appropriate precision
+
+ Parameters:
+ coverage (string): coverage value
+ precision (string): amount of decimals to be displayed in coverage
+ """
+ if coverage is None:
+ return None
+
+ precision = int(precision)
+ coverage = float(coverage)
+ return ("%%.%sf" % precision) % coverage
+
+
+def get_bundle_badge(bundle_size_bytes: int | None, precision: int):
+ if bundle_size_bytes is None:
+ # Returns text 'unknown' instead of bundle size
+ return """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+
+ bundle_size_string = format_bundle_bytes(bundle_size_bytes, precision)
+ char_width = 7 # approximate, looks good on all reasonable inputs
+ width_in_pixels = len(bundle_size_string) * char_width
+ static_width = 57 # width of static elements in the svg (text + margins)
+
+ width = static_width + width_in_pixels
+
+ return f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ {bundle_size_string}
+ {bundle_size_string}
+
+
+"""
+
+
+def format_bundle_bytes(bytes: int, precision: int):
+ precision = min(abs(precision), 2) # allow at most 2 decimal places
+ kilobyte = 10**3
+ megabyte = 10**6
+ gigabyte = 10**9
+
+ def remove_trailing_zeros(n: str):
+ return (n.rstrip("0") if "." in n else n).rstrip(".")
+
+ if bytes < kilobyte:
+ return f"{bytes}B"
+ elif bytes < megabyte:
+ return f"{remove_trailing_zeros(str(round(bytes / kilobyte, precision)))}KB"
+ elif bytes < gigabyte:
+ return f"{remove_trailing_zeros(str(round(bytes / megabyte, precision)))}MB"
+ else:
+ return f"{remove_trailing_zeros(str(round(bytes / gigabyte, precision)))}GB"
diff --git a/apps/codecov-api/graphs/helpers/graph_utils.py b/apps/codecov-api/graphs/helpers/graph_utils.py
new file mode 100644
index 0000000000..ee548eaf87
--- /dev/null
+++ b/apps/codecov-api/graphs/helpers/graph_utils.py
@@ -0,0 +1,206 @@
+from math import cos, pi, sin
+
+style_n_defs = """
+
+
+
+
+
+
+
+
+
+"""
+
+
+def _squarify(values, left, top, width, height, **kwargs):
+ # values should add up to width * height
+ if len(values) == 0:
+ return []
+
+ if len(values) == 1:
+ return _layout(values, left, top, width, height)[0]
+
+ i = 1
+ while i < len(values) and _worst_ratio(
+ values[:i], left, top, width, height
+ ) >= _worst_ratio(values[: (i + 1)], left, top, width, height):
+ i += 1
+
+ current = values[:i]
+ remaining = values[i:]
+
+ rectangles, leftover_space = _layout(current, left, top, width, height)
+ return rectangles + _squarify(remaining, *leftover_space)
+
+
+def _layout(areas, left, top, width, height, **kwargs):
+ layout_area = sum(areas)
+ vertical = width >= height
+ rectangles = []
+ if vertical:
+ layout_width = layout_area / height
+ rect_top = top
+ for area in areas:
+ rect_height = area / layout_width
+ rectangles.append((left, rect_top, layout_width, rect_height))
+ rect_top += rect_height
+ leftover_space = (left + layout_width, top, width - layout_width, height)
+ else:
+ layout_height = layout_area / width
+ rect_left = left
+ for area in areas:
+ rect_width = area / layout_height
+ rectangles.append((rect_left, top, rect_width, layout_height))
+ rect_left += rect_width
+ leftover_space = (left, top + layout_height, width, height - layout_height)
+ return rectangles, leftover_space
+
+
+def _worst_ratio(areas, left, top, width, height, **kwargs):
+ rectangles, leftover = _layout(areas, left, top, width, height)
+ return max(map(_max_aspect_ratio, rectangles))
+
+
+def _max_aspect_ratio(rect):
+ return max(
+ (rect[2] / rect[3]) if rect[3] > 0 else 0,
+ (rect[3] / rect[2]) if rect[2] > 0 else 0,
+ )
+
+
+def _svg_rect(x, y, width, height, fill, stroke, stroke_width, _class=None, title=None):
+ """http://www.w3schools.com/svg/svg_rect.asp"""
+ if title is None:
+ return (
+ ''.format(
+ x,
+ y,
+ width,
+ height,
+ fill,
+ stroke,
+ stroke_width,
+ ('class="%s"' % _class if _class else ""),
+ )
+ )
+
+ return (
+ ''
+ "{7}"
+ "".format(
+ x, y, width, height, fill, stroke, stroke_width, title, _class or ""
+ )
+ )
+
+
+def _make_svg(width, height, elements, viewPortWidth=None, viewPortHeight=None):
+ return (
+ '\n'
+ "{2}\n"
+ "{3}\n"
+ "".format(
+ width,
+ height,
+ style_n_defs,
+ "\n".join(elements),
+ viewPortWidth or width,
+ viewPortHeight or height,
+ )
+ )
+
+
+def _tree_height(tree):
+ if not tree:
+ return 0
+
+ subtrees = filter(None, (item.get("children", None) for item in tree))
+
+ if not subtrees:
+ return 1
+ children_map = list(map(_tree_height, subtrees))
+ if len(children_map) < 1:
+ return 1
+
+ return 1 + max(children_map)
+
+
+def _svg_polar_rect(
+ cx, cy, inner_radius, outer_radius, start, end, fill, stroke, stroke_width
+):
+ """
+ http://www.w3schools.com/svg/svg_circle.asp
+ http://www.w3schools.com/svg/svg_path.asp
+ """
+
+ # special case: circle
+ if inner_radius == 0 and end - start == 1:
+ return ''.format(
+ cx, cy, fill, outer_radius, stroke, stroke_width
+ )
+
+ in_angle = 2.0 * pi * start
+ out_angle = 2.0 * pi * end
+
+ # special case: ring
+ if end - start == 1:
+ # make a ring using two circles
+ # each circle consists of 2 180-degree arcs
+ # from (cx - r, cy) to (cx + r, cy) and back
+
+ # outer contour
+ d = "M {x1} {y} A {r} {r} 0 0 0 {x2} {y} A {r} {r} 0 0 0 {x1} {y} z ".format(
+ x1=cx - outer_radius, x2=cx + outer_radius, r=outer_radius, y=cy
+ )
+ # inner contour
+ d += "M {x1} {y} A {r} {r} 0 0 0 {x2} {y} A {r} {r} 0 0 0 {x1} {y} z ".format(
+ x1=cx - inner_radius, x2=cx + inner_radius, r=inner_radius, y=cy
+ )
+
+ return ''.format(
+ d, fill, stroke, stroke_width
+ )
+
+ # start points
+ spx_outer = outer_radius * sin(in_angle)
+ spy_outer = outer_radius * cos(in_angle)
+ spx_inner = inner_radius * sin(out_angle)
+ spy_inner = inner_radius * cos(out_angle)
+
+ # target points
+ tpx_outer = outer_radius * sin(out_angle)
+ tpy_outer = outer_radius * cos(out_angle)
+ tpx_inner = inner_radius * sin(in_angle)
+ tpy_inner = inner_radius * cos(in_angle)
+
+ large_arc_flag = 1 if end - start > 0.5 else 0
+
+ path_args = (
+ "M {} {} L {} {} A {} {} 0 {} 0 {} {} L {} {} A {} {} 0 {} 1 {} {} z".format(
+ cx + tpx_inner,
+ cy + tpy_inner,
+ cx + spx_outer,
+ cy + spy_outer,
+ outer_radius,
+ outer_radius,
+ large_arc_flag,
+ cx + tpx_outer,
+ cy + tpy_outer,
+ cx + spx_inner,
+ cy + spy_inner,
+ inner_radius,
+ inner_radius,
+ large_arc_flag,
+ cx + tpx_inner,
+ cy + tpy_inner,
+ )
+ )
+
+ return ''.format(
+ path_args, fill, stroke, stroke_width
+ )
diff --git a/apps/codecov-api/graphs/helpers/graphs.py b/apps/codecov-api/graphs/helpers/graphs.py
new file mode 100644
index 0000000000..2eabd3a2d2
--- /dev/null
+++ b/apps/codecov-api/graphs/helpers/graphs.py
@@ -0,0 +1,181 @@
+from operator import itemgetter
+
+from graphs.settings import settings
+
+from .graph_utils import (
+ _make_svg,
+ _squarify,
+ _svg_polar_rect,
+ _svg_rect,
+ _tree_height,
+)
+
+
+def tree(parsed_data, href=None, classes=None, **kwargs):
+ """
+ [
+ {
+ "lines": 10,
+ "color": "#aaaeee",
+ "children": [],
+ "name": "path"
+ }
+ ]
+ """
+ options = settings["sunburst"]["options"].copy()
+ options.update(kwargs)
+
+ svg_elements = []
+
+ def recursively_draw(items, step, left, top, width, height):
+ values = [item["lines"] for item in items]
+ _sum_values = sum(values)
+ if _sum_values > 0:
+ correction = width * height / _sum_values
+ sorted_values = sorted(
+ enumerate(value * correction for value in values),
+ key=itemgetter(1),
+ reverse=True,
+ )
+ indices = [x[0] for x in sorted_values]
+ values = [x[1] for x in sorted_values]
+ rectangles = _squarify(values, left, top, width, height)
+ for rect, color, _class, children, name in zip(
+ rectangles,
+ (items[index]["color"] for index in indices),
+ (items[index]["_class"] for index in indices),
+ (items[index].get("children", None) for index in indices),
+ (items[index]["name"] for index in indices),
+ ):
+ step.append(name)
+ if children:
+ recursively_draw(children, step, *rect)
+ step.pop(-1)
+ else:
+ path = "/".join(step[1:])
+ rect = _svg_rect(
+ rect[0],
+ rect[1],
+ rect[2],
+ rect[3],
+ fill=color,
+ stroke=options["border_color"],
+ stroke_width=options["border_size"],
+ _class=_class,
+ title=path,
+ )
+ svg_elements.append(rect)
+ step.pop(-1)
+
+ recursively_draw(
+ parsed_data,
+ [],
+ 0,
+ 0,
+ options.get("viewPortWidth") or options["width"],
+ options.get("viewPortHeight") or options["height"],
+ )
+
+ return _make_svg(
+ options["width"],
+ options["height"],
+ svg_elements,
+ options.get("viewPortWidth"),
+ options.get("viewPortHeight"),
+ )
+
+
+def icicle(parsed_data, **kwargs):
+ options = settings["icicle"]["options"].copy()
+ options.update(kwargs)
+
+ drawing_width = options["width"]
+ drawing_height = options["height"]
+
+ # ensure 5% frame
+ plot_width = drawing_width * 0.9
+ plot_height = drawing_height * 0.9
+
+ # starting point
+ sx, sy = drawing_width * 0.05, drawing_height * 0.05
+ strip_height = plot_height / _tree_height(parsed_data)
+
+ svg_elements = []
+
+ def recursively_draw(items, x, y, max_width, prefix_name):
+ total = sum((item["lines"] for item in items))
+ if total > 0:
+ for item in items:
+ item_width = item["lines"] / total * max_width
+ title = prefix_name + "/" + item["name"]
+ svg_elements.append(
+ _svg_rect(
+ x,
+ y,
+ item_width,
+ strip_height,
+ fill=item["color"],
+ stroke=options["border_color"],
+ title=title,
+ stroke_width=options["border_size"],
+ )
+ )
+ if "children" in item.keys():
+ recursively_draw(
+ item["children"], x, y + strip_height, item_width, title
+ )
+ x += item_width
+
+ recursively_draw(parsed_data, sx, sy, plot_width, "")
+
+ return _make_svg(drawing_width, drawing_height, svg_elements)
+
+
+def sunburst(parsed_data, **kwargs):
+ options = settings["sunburst"]["options"].copy()
+ options.update(kwargs)
+
+ drawing_width = options["width"]
+ drawing_height = options["height"]
+ cx = drawing_width / 2.0
+ cy = drawing_height / 2.0
+
+ # ensure 5% frame
+ max_diameter = min(drawing_width, drawing_height) * 0.95
+ max_radius = max_diameter / 2.0
+
+ offset_increment = max_radius / _tree_height(parsed_data)
+
+ svg_elements = []
+
+ def recursively_draw(items, inner_radius, start, end):
+ total = sum((item["lines"] for item in items))
+ if total > 0:
+ s = start
+ for item in items:
+ arc_size = item["lines"] / total * (end - start)
+ svg_elements.append(
+ _svg_polar_rect(
+ cx,
+ cy,
+ inner_radius,
+ inner_radius + offset_increment,
+ s,
+ s + arc_size,
+ item["color"],
+ options["border_color"],
+ options["border_size"],
+ )
+ )
+ if "children" in item.keys():
+ recursively_draw(
+ item["children"],
+ inner_radius + offset_increment,
+ s,
+ s + arc_size,
+ )
+ s += arc_size
+
+ recursively_draw(parsed_data, 0, 0, 1)
+
+ return _make_svg(drawing_width, drawing_height, svg_elements)
diff --git a/apps/codecov-api/graphs/mixins.py b/apps/codecov-api/graphs/mixins.py
new file mode 100644
index 0000000000..e1b74563dc
--- /dev/null
+++ b/apps/codecov-api/graphs/mixins.py
@@ -0,0 +1,37 @@
+from typing import Any
+
+from django.http import HttpResponse
+from rest_framework import status
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+
+class GraphBadgeAPIMixin(object):
+ def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ ext = self.kwargs.get("ext")
+ if ext not in self.extensions:
+ return Response(
+ {
+ "detail": f"File extension should be one of [ {' || '.join(self.extensions)} ]"
+ },
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ graph = self.get_object(
+ request, *args, **kwargs
+ ) # for badge handler this will get the badge, for graph it will get the graph
+ # do all the header stuff and return the response
+
+ response = HttpResponse(graph)
+ if self.kwargs.get("ext") == "svg":
+ response["Content-Disposition"] = ' inline; filename="{}.svg"'.format(
+ self.filename
+ )
+ response["Content-Type"] = "image/svg+xml"
+ response["Pragma"] = "no-cache"
+ response["Expires"] = "0"
+ response["Access-Control-Expose-Headers"] = (
+ "Content-Type, Cache-Control, Expires, Etag, Last-Modified"
+ )
+ response["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
+ return response
diff --git a/apps/codecov-api/graphs/settings.py b/apps/codecov-api/graphs/settings.py
new file mode 100644
index 0000000000..2a8e37baaa
--- /dev/null
+++ b/apps/codecov-api/graphs/settings.py
@@ -0,0 +1,50 @@
+settings = {
+ "icicle": {
+ "method": "flare",
+ "options": {
+ "width": 750,
+ "height": 150,
+ "border_size": 1,
+ "border_color": "white",
+ },
+ "exports": ["svg"],
+ "types": ["commit", "pull", "branch"],
+ },
+ "tree": {
+ "method": "flare",
+ "options": {
+ "width": 500,
+ "height": 500,
+ "border_size": 1,
+ "border_color": "white",
+ },
+ "exports": ["svg", "json"],
+ "types": ["commit", "pull", "branch"],
+ },
+ "sunburst": {
+ "method": "flare",
+ "options": {
+ "width": 300,
+ "height": 300,
+ "border_size": 1,
+ "border_color": "white",
+ },
+ "exports": ["svg", "html"],
+ "types": ["commit", "pull", "branch"],
+ },
+ "commits": {
+ "method": "commits",
+ "options": {
+ "width": 700,
+ "height": 100,
+ "color": "yes",
+ "legend": "yes",
+ "yaxis": [0, 100],
+ "hg": "yes",
+ "vg": "yes",
+ "limit": 20,
+ },
+ "exports": ["svg", "json"],
+ "types": ["pull", "branch"],
+ },
+}
diff --git a/apps/codecov-api/graphs/tests/test_badge_handler.py b/apps/codecov-api/graphs/tests/test_badge_handler.py
new file mode 100644
index 0000000000..c8b47ccf28
--- /dev/null
+++ b/apps/codecov-api/graphs/tests/test_badge_handler.py
@@ -0,0 +1,1691 @@
+from unittest.mock import PropertyMock, patch
+
+from rest_framework import status
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile, Session, SessionType
+from shared.reports.types import ReportLine, ReportTotals
+from shared.yaml import UserYaml
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1], [1, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1], [1, 0]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(
+ Session(
+ flags=["unittests"],
+ provider="circleci",
+ session_type=SessionType.uploaded,
+ build="aycaramba",
+ totals=ReportTotals(files=2, lines=10, coverage=95.00000),
+ )
+ )
+ report.add_session(
+ Session(
+ flags=["integration"],
+ provider="travis",
+ session_type=SessionType.uploaded,
+ build="poli",
+ totals=ReportTotals(files=2, lines=10, coverage=None),
+ )
+ )
+ report.add_session(
+ Session(
+ flags=["aaa"],
+ provider="travis",
+ session_type=SessionType.uploaded,
+ build="poli",
+ totals=ReportTotals(files=2, lines=10, coverage=None),
+ )
+ )
+ report.add_session(
+ Session(
+ flags=["no_coverage"],
+ provider="travis",
+ session_type=SessionType.uploaded,
+ build="poli",
+ totals=ReportTotals(files=0, lines=0, coverage=None),
+ )
+ )
+ return report
+
+
+def sample_report_no_flags():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1], [1, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1], [1, 0]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(
+ Session(
+ flags=None,
+ provider="circleci",
+ session_type=SessionType.uploaded,
+ build="aycaramba",
+ totals=ReportTotals(files=2, lines=10, coverage=95.00000),
+ )
+ )
+ return report
+
+
+class TestBadgeHandler(APITestCase):
+ def _get(self, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/graphs/badge.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def _get_branch(self, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/branch/{kwargs.get('branch')}/graphs/badge.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def test_invalid_precision(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "svg",
+ },
+ data={"precision": "3"},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Coverage precision should be one of [ 0 || 1 || 2 ]"
+ )
+
+ def test_invalid_extension(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "png",
+ }
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"] == "File extension should be one of [ svg || txt ]"
+ )
+
+ def test_unknown_badge_incorrect_service(self):
+ response = self._get(
+ kwargs={
+ "service": "gih",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_incorrect_owner(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "user1233",
+ "repo_name": "repo",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_incorrect_repo(self):
+ gh_owner = OwnerFactory(service="github")
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_no_branch(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(author=gh_owner, active=True, private=False, name="repo1")
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_no_commit(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ BranchFactory(repository=repo, name="master")
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_no_totals(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner, totals=None)
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_text_badge(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ }
+ )
+
+ badge = response.content.decode("utf-8")
+ assert badge == "85"
+ assert response.status_code == status.HTTP_200_OK
+
+ # test precision = 1
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ },
+ data={"precision": "1"},
+ )
+
+ badge = response.content.decode("utf-8")
+ assert badge == "85.0"
+ assert response.status_code == status.HTTP_200_OK
+
+ # test precision = 1
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ },
+ data={"precision": "2"},
+ )
+
+ badge = response.content.decode("utf-8")
+ assert badge == "85.00"
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_svg_badge(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85%
+ 85%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ # test precision = 1
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"precision": "1"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85.0%
+ 85.0%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert response.status_code == status.HTTP_200_OK
+
+ # test precision = 1
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"precision": "2"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85.00%
+ 85.00%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_private_badge_no_token(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_private_badge(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"token": "12345678"},
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85%
+ 85%
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_branch_badge(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_2_totals = {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "95.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ }
+ commit_2 = CommitFactory(
+ commitid="b1c2b4fa3ae9ef615c8f740c5cba95d9851f9ae8",
+ repository=repo,
+ author=gh_owner,
+ totals=commit_2_totals,
+ )
+ branch_2 = BranchFactory(
+ repository=repo, name="branch1", head=commit_2.commitid
+ )
+
+ # test default precision
+ response = self._get_branch(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "branch": "branch1",
+ },
+ data={"token": "12345678"},
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 95%
+ 95%
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+ branch_2.refresh_from_db()
+ assert branch_2.head == "b1c2b4fa3ae9ef615c8f740c5cba95d9851f9ae8"
+
+ def test_badge_with_100_coverage(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_2_totals = {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "100.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ }
+ commit_2 = CommitFactory(
+ repository=repo, author=gh_owner, totals=commit_2_totals
+ )
+ BranchFactory(repository=repo, name="branch1", head=commit_2.commitid)
+ # test default precision
+ response = self._get_branch(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "branch": "branch1",
+ },
+ data={"token": "12345678"},
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 100%
+ 100%
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_branch_badge_with_slash(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_2_totals = {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "95.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ }
+ commit_2 = CommitFactory(
+ repository=repo, author=gh_owner, totals=commit_2_totals
+ )
+ BranchFactory(repository=repo, name="test/branch1", head=commit_2.commitid)
+ # test default precision
+ response = self._get_branch(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "branch": "test%2Fbranch1",
+ },
+ data={"token": "12345678"},
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 95%
+ 95%
+
+
+
+
+ """
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_flag_badge(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unittests"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 100%
+ 100%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_none_branch_flag_badge(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ branch="not-master",
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unittests"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_unknown_flag_badge(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "aaa"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_unknown_sessions_flag_badge(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = sample_report()
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unit"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_unknown_report_flag_badge(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unit"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("graphs.views.commit_components")
+ def test_component_badge(self, commit_components_mock, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={
+ "component_management": {
+ "individual_components": [
+ {
+ "component_id": "file_1",
+ "name": "File1",
+ "paths": ["file_1.go"],
+ }
+ ]
+ }
+ },
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_components_mock.return_value = UserYaml.get_final_yaml(
+ repo_yaml=repo.yaml
+ ).get_components()
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"component": "File1"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 62%
+ 62%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("graphs.views.commit_components")
+ def test_flag_component_badge(self, commit_components_mock, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={
+ "component_management": {
+ "individual_components": [
+ {
+ "component_id": "unittests",
+ "name": "Unit tests",
+ "flag_regexes": ["unittests"],
+ }
+ ]
+ }
+ },
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_components_mock.return_value = UserYaml.get_final_yaml(
+ repo_yaml=repo.yaml
+ ).get_components()
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"component": "unittests"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 100%
+ 100%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("graphs.views.commit_components")
+ def test_unknown_component_badge(self, commit_components_mock, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={
+ "component_management": {
+ "individual_components": [
+ {
+ "component_id": "unittests",
+ "name": "Unit tests",
+ "flag_regexes": ["unittests"],
+ }
+ ]
+ }
+ },
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_components_mock.return_value = UserYaml.get_final_yaml(
+ repo_yaml=repo.yaml
+ ).get_components()
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"component": "idk"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("graphs.views.commit_components")
+ def test_component_badge_no_report(self, commit_components_mock, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={
+ "component_management": {
+ "individual_components": [
+ {
+ "component_id": "unittests",
+ "name": "Unit tests",
+ "flag_regexes": ["unittests"],
+ }
+ ]
+ }
+ },
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_components_mock.return_value = UserYaml.get_final_yaml(
+ repo_yaml=repo.yaml
+ ).get_components()
+ full_report_mock.return_value = None
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"component": "unittests"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ @patch("graphs.views.commit_components")
+ def test_component_badge_report_doesnt_have_matching_coverage(
+ self, commit_components_mock, full_report_mock
+ ):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={
+ "component_management": {
+ "individual_components": [
+ {
+ "component_id": "no_coverage",
+ "name": "No Coverage",
+ "flag_regexes": ["no_coverage"],
+ }
+ ]
+ }
+ },
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ commit_components_mock.return_value = UserYaml.get_final_yaml(
+ repo_yaml=repo.yaml
+ ).get_components()
+ full_report_mock.return_value = sample_report()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"component": "no_coverage"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_yaml_range(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={"coverage": {"range": [0.0, 0.8]}},
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85%
+ 85%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_yaml_empty_range(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=False,
+ name="repo1",
+ yaml={"coverage": {}},
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ }
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 85%
+ 85%
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_commit_report_null(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = None
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unit"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("core.models.Commit.full_report", new_callable=PropertyMock)
+ def test_commit_report_no_flags(self, full_report_mock):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitFactory(repository=repo, author=gh_owner)
+ full_report_mock.return_value = sample_report_no_flags()
+
+ # test default precision
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"flag": "unit"},
+ )
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
diff --git a/apps/codecov-api/graphs/tests/test_bundle_badge_handler.py b/apps/codecov-api/graphs/tests/test_bundle_badge_handler.py
new file mode 100644
index 0000000000..0317ab05b2
--- /dev/null
+++ b/apps/codecov-api/graphs/tests/test_bundle_badge_handler.py
@@ -0,0 +1,540 @@
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.test import APITestCase
+from shared.bundle_analysis import BundleAnalysisReport, BundleReport
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+
+class MockBundleReport(BundleReport):
+ def __init__(self):
+ return
+
+ def total_size(self):
+ return 1234567
+
+
+class MockBundleAnalysisReport(BundleAnalysisReport):
+ def bundle_report(self, bundle_name: str):
+ if bundle_name == "idk":
+ return None
+ return MockBundleReport()
+
+
+class TestBundleBadgeHandler(APITestCase):
+ def _get(self, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/graphs/bundle/{kwargs.get('bundle')}/badge.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def _get_branch(self, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/branch/{kwargs.get('branch')}/graphs/{kwargs.get('bundle')}/badge.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def test_invalid_extension(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "png",
+ "bundle": "asdf",
+ }
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"] == "File extension should be one of [ svg || txt ]"
+ )
+
+ def test_unknown_badge_incorrect_service(self):
+ response = self._get(
+ kwargs={
+ "service": "gih",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_incorrect_owner(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "user1233",
+ "repo_name": "repo",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_incorrect_repo(self):
+ gh_owner = OwnerFactory(service="github")
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_private_repo_wrong_token(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(
+ author=gh_owner, active=True, private=True, name="repo1", image_token="asdf"
+ )
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_no_branch(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(author=gh_owner, active=True, private=False, name="repo1")
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unknown_badge_no_commit(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("graphs.views.load_report")
+ def test_unknown_badge_no_report(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = None
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("graphs.views.load_report")
+ def test_unknown_badge_no_bundle(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = MockBundleAnalysisReport()
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "idk",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+"""
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("graphs.views.load_report")
+ def test_bundle_badge(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = MockBundleAnalysisReport()
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ 1.23MB
+ 1.23MB
+
+
+"""
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("graphs.views.load_report")
+ def test_bundle_badge_text(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = MockBundleAnalysisReport()
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ "bundle": "asdf",
+ }
+ )
+ expected_badge = "1.23MB"
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("graphs.views.load_report")
+ def test_bundle_badge_unsupported_precision_defaults_to_2(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = MockBundleAnalysisReport()
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ "bundle": "asdf",
+ },
+ data={"precision": "asdf"},
+ )
+ expected_badge = "1.23MB"
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+
+ @patch("graphs.views.load_report")
+ def test_bundle_badge_private_repo_correct_token(self, mock_load_report):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=True, name="repo1", image_token="asdf"
+ )
+ branch = BranchFactory(name="main", repository=repo)
+ repo.branch = branch
+ commit = CommitFactory(
+ repository=repo, commitid=repo.branch.head, branch="main"
+ )
+ branch.head = commit.commitid
+
+ mock_load_report.return_value = MockBundleAnalysisReport()
+
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "txt",
+ "bundle": "asdf",
+ },
+ data={"token": "asdf"},
+ )
+ expected_badge = "1.23MB"
+
+ badge = response.content.decode("utf-8")
+ badge = [line.strip() for line in badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == badge
+ assert response.status_code == status.HTTP_200_OK
diff --git a/apps/codecov-api/graphs/tests/test_graph_handler.py b/apps/codecov-api/graphs/tests/test_graph_handler.py
new file mode 100644
index 0000000000..5d5882d2d2
--- /dev/null
+++ b/apps/codecov-api/graphs/tests/test_graph_handler.py
@@ -0,0 +1,687 @@
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ CommitWithReportFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+
+@patch("shared.api_archive.archive.ArchiveService.read_chunks", lambda obj, _: "")
+class TestGraphHandler(APITestCase):
+ def _get(self, graph_type, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/graphs/{graph_type}.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def _get_branch(self, graph_type, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/branch/{kwargs.get('branch')}/graphs/{graph_type}.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def _get_commit(self, graph_type, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/commit/{kwargs.get('commit')}/graphs/{graph_type}.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def _get_pull(self, graph_type, kwargs={}, data={}):
+ path = f"/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/pull/{kwargs.get('pull')}/graphs/{graph_type}.{kwargs.get('ext')}"
+ return self.client.get(path, data=data)
+
+ def test_invalid_extension(self):
+ response = self._get(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "json",
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data["detail"] == "File extension should be one of [ svg ]"
+
+ response = self._get(
+ "icicle",
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "json",
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data["detail"] == "File extension should be one of [ svg ]"
+
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "json",
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data["detail"] == "File extension should be one of [ svg ]"
+
+ response = self._get(
+ "commits",
+ kwargs={
+ "service": "gh",
+ "owner_username": "user",
+ "repo_name": "repo",
+ "ext": "txt",
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data["detail"] == "File extension should be one of [ svg ]"
+
+ def test_tree_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ tests/test_sample.py
+ tests/__init__.py
+ awesome/__init__.py
+ """
+
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_icicle_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ "icicle",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ /
+ //tests
+ //tests/__init__.py
+ //tests/test_sample.py
+ //awesome
+ //awesome/__init__.py
+ """
+
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_sunburst_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner, active=True, private=False, name="repo1"
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+
+ # test default precision
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unkown_owner(self):
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": "gh_owner",
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ def test_unkown_repo(self):
+ gh_owner = OwnerFactory(service="github")
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ def test_private_repo_no_token(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ )
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"token": "123456"},
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ def test_private_repo(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ data={"token": "12345678"},
+ )
+
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_unkown_branch(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(author=gh_owner, active=True, private=False, name="repo1")
+
+ response = self._get(
+ "sunburst",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ },
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ def test_branch_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+ commit_2_totals = {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "95.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ }
+ commit_2 = CommitWithReportFactory(
+ repository=repo, author=gh_owner, totals=commit_2_totals
+ )
+ BranchFactory(repository=repo, name="branch1", head=commit_2.commitid)
+ # test default precision
+ response = self._get_branch(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "branch": "branch1",
+ },
+ data={"token": "12345678"},
+ )
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ tests/test_sample.py
+ tests/__init__.py
+ awesome/__init__.py
+ """
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_commit_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ )
+ commit_1 = CommitWithReportFactory(repository=repo, author=gh_owner)
+
+ # make sure commit 2 report is different than commit 1 and
+ # assert that the expected graph below still pertains to commit_1
+ CommitFactory(
+ repository=repo,
+ author=gh_owner,
+ parent_commit_id=commit_1.commitid,
+ _report={
+ "files": {
+ "different/test_file.py": [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ],
+ },
+ "sessions": {
+ "0": {
+ "N": None,
+ "a": "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ "c": None,
+ "d": 1547084427,
+ "e": None,
+ "f": ["unittests"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "t": [3, 20, 17, 3, 0, "85.00000", 0, 0, 0, 0, 0, 0, 0],
+ "": None,
+ }
+ },
+ },
+ )
+
+ response = self._get_commit(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "commit": commit_1.commitid,
+ },
+ data={"token": "12345678"},
+ )
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ tests/test_sample.py
+ tests/__init__.py
+ awesome/__init__.py
+ """
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_pull_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ PullFactory(
+ pullid=10,
+ repository_id=repo.repoid,
+ _flare=[
+ {
+ "name": "",
+ "color": "#e05d44",
+ "lines": 14,
+ "_class": None,
+ "children": [
+ {
+ "name": "tests.py",
+ "color": "#baaf1b",
+ "lines": 7,
+ "_class": None,
+ "coverage": "85.71429",
+ }
+ ],
+ }
+ ],
+ )
+ # test default precision
+ response = self._get_pull(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "pull": 10,
+ },
+ data={"token": "12345678"},
+ )
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ tests.py
+ """
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert expected_graph == graph
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_no_pull_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="branch1",
+ )
+ # test default precision
+ response = self._get_pull(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "pull": 10,
+ },
+ data={"token": "12345678"},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ def test_pull_no_flare_graph(self):
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="main",
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+ PullFactory(pullid=10, repository_id=repo.repoid, _flare=None)
+
+ # test default precision
+ response = self._get_pull(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "pull": 10,
+ },
+ data={"token": "12345678"},
+ )
+ expected_graph = """
+
+
+
+
+
+
+
+
+
+
+
+ tests/test_sample.py
+ tests/__init__.py
+ awesome/__init__.py
+ """
+ graph = response.content.decode("utf-8")
+ graph = [line.strip() for line in graph.split("\n")]
+ expected_graph = [line.strip() for line in expected_graph.split("\n")]
+ assert response.status_code == status.HTTP_200_OK
+ assert expected_graph == graph
+
+ def test_pull_no_repo_graph(self):
+ gh_owner = OwnerFactory(service="github")
+
+ # test default precision
+ response = self._get_pull(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "pull": 10,
+ },
+ data={"token": "12345678"},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: private repositories require ?token arguments"
+ )
+
+ @patch("shared.reports.api_report_service.build_report_from_commit")
+ def test_pull_file_not_found_in_storage(self, mocked_build_report):
+ mocked_build_report.return_value = None
+ gh_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(
+ author=gh_owner,
+ active=True,
+ private=True,
+ name="repo1",
+ image_token="12345678",
+ branch="main",
+ )
+ CommitWithReportFactory(repository=repo, author=gh_owner)
+ PullFactory(pullid=10, repository_id=repo.repoid, _flare=None)
+
+ response = self._get_pull(
+ "tree",
+ kwargs={
+ "service": "gh",
+ "owner_username": gh_owner.username,
+ "repo_name": "repo1",
+ "ext": "svg",
+ "pull": 10,
+ },
+ data={"token": "12345678"},
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert (
+ response.data["detail"]
+ == "Not found. Note: file for chunks not found in storage"
+ )
diff --git a/apps/codecov-api/graphs/tests/test_graph_utils.py b/apps/codecov-api/graphs/tests/test_graph_utils.py
new file mode 100644
index 0000000000..1655930bde
--- /dev/null
+++ b/apps/codecov-api/graphs/tests/test_graph_utils.py
@@ -0,0 +1,43 @@
+from graphs.helpers.graph_utils import _tree_height
+
+
+class TestGraphsUtils(object):
+ def test_tree_height(self):
+ tree = [{"name": "name_0"}]
+
+ height = _tree_height(tree)
+ assert height == 1
+
+ tree = [{"name": "name_0", "children": [{"name": "name_2"}]}]
+
+ height = _tree_height(tree)
+ assert height == 2
+
+ tree = [
+ {
+ "name": "name_0",
+ "children": [
+ {"name": "name_1", "children": [{"name": "name_2"}]},
+ {"name": "name_3"},
+ ],
+ }
+ ]
+ height = _tree_height(tree)
+ assert height == 3
+
+ tree = [
+ {
+ "name": "name_0",
+ "children": [
+ {
+ "name": "name_1",
+ "children": [
+ {"name": "name_2", "children": [{"name": "name_4"}]}
+ ],
+ },
+ {"name": "name_3"},
+ ],
+ }
+ ]
+ height = _tree_height(tree)
+ assert height == 4
diff --git a/apps/codecov-api/graphs/tests/test_helpers.py b/apps/codecov-api/graphs/tests/test_helpers.py
new file mode 100644
index 0000000000..c7955af40a
--- /dev/null
+++ b/apps/codecov-api/graphs/tests/test_helpers.py
@@ -0,0 +1,438 @@
+from graphs.helpers.badge import (
+ format_bundle_bytes,
+ format_coverage_precision,
+ get_badge,
+ get_bundle_badge,
+)
+
+
+class TestGraphsHelpers(object):
+ def test_format_coverage_precision(self):
+ coverage = "91.1111"
+ precision = "1"
+
+ expected_coverage = "91.1"
+ _coverage = format_coverage_precision(coverage, precision)
+ assert expected_coverage == _coverage
+
+ precision = "0"
+ expected_coverage = "91"
+ _coverage = format_coverage_precision(coverage, precision)
+ assert expected_coverage == _coverage
+
+ precision = "2"
+ expected_coverage = "91.11"
+ _coverage = format_coverage_precision(coverage, precision)
+ assert expected_coverage == _coverage
+
+ def test_badge(self):
+ # Test medium badge
+ coverage = 91.1
+ precision = 1
+ coverage_range = [70, 100]
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 91.1%
+ 91.1%
+
+
+
+
+ """
+
+ _badge = get_badge(coverage, coverage_range, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ # Test small badge
+ coverage = 80
+ precision = 0
+ coverage_range = [70, 100]
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 80%
+ 80%
+
+
+
+
+ """
+ _badge = get_badge(coverage, coverage_range, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ # Test large badge
+
+ coverage = 60.52
+ precision = 2
+ coverage_range = [70, 100]
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ 60.52%
+ 60.52%
+
+
+
+
+ """
+
+ _badge = get_badge(coverage, coverage_range, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_unknown_badge(self):
+ coverage = None
+ precision = 2
+ coverage_range = [70, 100]
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codecov
+ codecov
+ unknown
+ unknown
+
+
+
+
+
+ """
+
+ _badge = get_badge(coverage, coverage_range, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_bundle_badge_small(self):
+ bundle_size_bytes = 7
+ precision = 2
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ 7B
+ 7B
+
+
+ """
+
+ _badge = get_bundle_badge(bundle_size_bytes, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_bundle_badge_medium(self):
+ bundle_size_bytes = 7777777
+ precision = 2
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ 7.78MB
+ 7.78MB
+
+
+ """
+
+ _badge = get_bundle_badge(bundle_size_bytes, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_bundle_badge_large(self):
+ bundle_size_bytes = 7777777777777
+ precision = 2
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ 7777.78GB
+ 7777.78GB
+
+
+ """
+
+ _badge = get_bundle_badge(bundle_size_bytes, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_bundle_badge_unknown(self):
+ bundle_size_bytes = None
+ precision = 2
+
+ expected_badge = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ bundle
+ bundle
+ unknown
+ unknown
+
+
+ """
+
+ _badge = get_bundle_badge(bundle_size_bytes, precision)
+ _badge = [line.strip() for line in _badge.split("\n")]
+ expected_badge = [line.strip() for line in expected_badge.split("\n")]
+ assert expected_badge == _badge
+
+ def test_format_bundle_bytes_0_precision(self):
+ bundle_sizes = [
+ 7,
+ 77,
+ 777,
+ 7777,
+ 77777,
+ 777777,
+ 7777777,
+ 77777777,
+ 777777777,
+ 7777777777,
+ 77777777777,
+ 777777777777,
+ 7777777777777,
+ ]
+
+ expected = [
+ "7B",
+ "77B",
+ "777B",
+ "8KB",
+ "78KB",
+ "778KB",
+ "8MB",
+ "78MB",
+ "778MB",
+ "8GB",
+ "78GB",
+ "778GB",
+ "7778GB",
+ ]
+
+ for i in range(len(bundle_sizes)):
+ assert format_bundle_bytes(bundle_sizes[i], 0) == expected[i]
+
+ def test_format_bundle_bytes_1_precision(self):
+ bundle_sizes = [
+ 7,
+ 77,
+ 777,
+ 7777,
+ 77777,
+ 777777,
+ 7777777,
+ 77777777,
+ 777777777,
+ 7777777777,
+ 77777777777,
+ 777777777777,
+ 7777777777777,
+ ]
+
+ expected = [
+ "7B",
+ "77B",
+ "777B",
+ "7.8KB",
+ "77.8KB",
+ "777.8KB",
+ "7.8MB",
+ "77.8MB",
+ "777.8MB",
+ "7.8GB",
+ "77.8GB",
+ "777.8GB",
+ "7777.8GB",
+ ]
+
+ for i in range(len(bundle_sizes)):
+ assert format_bundle_bytes(bundle_sizes[i], 1) == expected[i]
+
+ def test_format_bundle_bytes_2_precision(self):
+ bundle_sizes = [
+ 7,
+ 77,
+ 777,
+ 7777,
+ 77777,
+ 777777,
+ 7777777,
+ 77777777,
+ 777777777,
+ 7777777777,
+ 77777777777,
+ 777777777777,
+ 7777777777777,
+ ]
+
+ expected = [
+ "7B",
+ "77B",
+ "777B",
+ "7.78KB",
+ "77.78KB",
+ "777.78KB",
+ "7.78MB",
+ "77.78MB",
+ "777.78MB",
+ "7.78GB",
+ "77.78GB",
+ "777.78GB",
+ "7777.78GB",
+ ]
+
+ for i in range(len(bundle_sizes)):
+ assert format_bundle_bytes(bundle_sizes[i], 2) == expected[i]
+
+ def test_format_bundle_strips_zeros(self):
+ bundle_sizes = [
+ 0,
+ 10,
+ 100,
+ 1000,
+ 10000,
+ 100000,
+ 1000000,
+ 10000000,
+ 100000000,
+ 1000000000,
+ 10000000000,
+ 100000000000,
+ 1000000000000,
+ 1100,
+ 11100,
+ 111100,
+ ]
+
+ expected = [
+ "0B",
+ "10B",
+ "100B",
+ "1KB",
+ "10KB",
+ "100KB",
+ "1MB",
+ "10MB",
+ "100MB",
+ "1GB",
+ "10GB",
+ "100GB",
+ "1000GB",
+ "1.1KB",
+ "11.1KB",
+ "111.1KB",
+ ]
+
+ for i in range(len(bundle_sizes)):
+ assert format_bundle_bytes(bundle_sizes[i], 2) == expected[i]
diff --git a/apps/codecov-api/graphs/urls.py b/apps/codecov-api/graphs/urls.py
new file mode 100644
index 0000000000..cf069e9a42
--- /dev/null
+++ b/apps/codecov-api/graphs/urls.py
@@ -0,0 +1,46 @@
+from django.urls import re_path
+
+from .views import BadgeHandler, BundleBadgeHandler, GraphHandler
+
+urlpatterns = [
+ re_path(
+ "branch/(?P.+)/(graph|graphs)/badge.(?P[^/]+)",
+ BadgeHandler.as_view(),
+ name="branch-badge",
+ ),
+ re_path(
+ "(graph|graphs)/badge.(?P[^/]+)",
+ BadgeHandler.as_view(),
+ name="default-badge",
+ ),
+ re_path(
+ "branch/(?P.+)/(graph|graphs)/bundle/(?P.+)/badge.(?P[^/]+)",
+ BundleBadgeHandler.as_view(),
+ name="branch-bundle-badge",
+ ),
+ re_path(
+ "(graph|graphs)/bundle/(?P.+)/badge.(?P[^/]+)",
+ BundleBadgeHandler.as_view(),
+ name="default-bundle-badge",
+ ),
+ re_path(
+ "pull/(?P[^/]+)/(graph|graphs)/(?Ptree|icicle|sunburst|commits).(?P[^/]+)",
+ GraphHandler.as_view(),
+ name="pull-graph",
+ ),
+ re_path(
+ "branch/(?P[^/].+)/(graph|graphs)/(?Ptree|icicle|sunburst|commits).(?P[^/]+)",
+ GraphHandler.as_view(),
+ name="branch-graph",
+ ),
+ re_path(
+ "commit/(?P[^/].+)/(graph|graphs)/(?Ptree|icicle|sunburst|commits).(?P[^/]+)",
+ GraphHandler.as_view(),
+ name="commit-graph",
+ ),
+ re_path(
+ "(graph|graphs)/(?Ptree|icicle|sunburst|commits).(?P[^/]+)",
+ GraphHandler.as_view(),
+ name="default-graph",
+ ),
+]
diff --git a/apps/codecov-api/graphs/views.py b/apps/codecov-api/graphs/views.py
new file mode 100644
index 0000000000..68f0779aeb
--- /dev/null
+++ b/apps/codecov-api/graphs/views.py
@@ -0,0 +1,444 @@
+import logging
+
+import shared.reports.api_report_service as report_service
+from django.core.exceptions import ObjectDoesNotExist
+from django.http import Http404
+from rest_framework import exceptions
+from rest_framework.exceptions import NotFound
+from rest_framework.negotiation import DefaultContentNegotiation
+from rest_framework.permissions import AllowAny
+from rest_framework.views import APIView
+from shared.django_apps.core.models import Commit
+from shared.metrics import Counter, inc_counter
+
+from api.shared.mixins import RepoPropertyMixin
+from core.models import Branch, Pull
+from graphs.settings import settings
+from services.bundle_analysis import load_report
+from services.components import commit_components
+
+from .helpers.badge import (
+ format_bundle_bytes,
+ format_coverage_precision,
+ get_badge,
+ get_bundle_badge,
+)
+from .helpers.graphs import icicle, sunburst, tree
+from .mixins import GraphBadgeAPIMixin
+
+log = logging.getLogger(__name__)
+
+FLARE_USE_COUNTER = Counter(
+ "graph_activity",
+ "How are graphs and flare being used?",
+ [
+ "position",
+ ],
+)
+FLARE_SUCCESS_COUNTER = Counter(
+ "graph_success",
+ "How often are graphs successfully generated?",
+ [
+ "graph_type",
+ ],
+)
+
+
+class IgnoreClientContentNegotiation(DefaultContentNegotiation):
+ def select_parser(self, request, parsers):
+ """
+ Select the first parser in the `.parser_classes` list.
+ """
+ return parsers[0]
+
+ def select_renderer(self, request, renderers, format_suffix):
+ """
+ Select the first renderer in the `.renderer_classes` list.
+ """
+ try:
+ return super().select_renderer(request, renderers, format_suffix)
+ except exceptions.NotAcceptable:
+ log.info(
+ f"Recieved unsupported HTTP_ACCEPT header: {request.META.get('HTTP_ACCEPT')}"
+ )
+ return (renderers[0], renderers[0].media_type)
+
+
+class BadgeHandler(APIView, RepoPropertyMixin, GraphBadgeAPIMixin):
+ content_negotiation_class = IgnoreClientContentNegotiation
+
+ permission_classes = [AllowAny]
+
+ extensions = ["svg", "txt"]
+ precisions = ["0", "1", "2"]
+ filename = "badge"
+
+ def get_object(self, request, *args, **kwargs):
+ # Validate coverage precision
+ precision = self.request.query_params.get("precision", "0")
+ if precision not in self.precisions:
+ raise NotFound("Coverage precision should be one of [ 0 || 1 || 2 ]")
+
+ coverage, coverage_range = self.get_coverage()
+
+ # Format coverage according to precision
+ coverage = format_coverage_precision(coverage, precision)
+
+ if self.kwargs.get("ext") == "txt":
+ return coverage
+
+ return get_badge(coverage, coverage_range, precision)
+
+ def get_coverage(self):
+ """
+ Note: This endpoint has the behavior of returning a gray badge with the word 'unknown' instead of returning a 404
+ when the user enters an invalid service, owner, repo or when coverage is not found for a branch.
+
+ We also need to support service abbreviations for users already using them
+ """
+ coverage_range = [70, 100]
+
+ try:
+ repo = self.repo
+ except Http404:
+ log.warning("Repo not found", extra=dict(repo=self.kwargs.get("repo_name")))
+ return None, coverage_range
+
+ if repo.private and repo.image_token != self.request.query_params.get("token"):
+ log.warning(
+ "Token provided does not match repo's image token",
+ extra=dict(repo=repo),
+ )
+ return None, coverage_range
+
+ branch_name = self.kwargs.get("branch") or repo.branch
+ branch = Branch.objects.filter(
+ name=branch_name, repository_id=repo.repoid
+ ).first()
+
+ if branch is None:
+ log.warning(
+ "Branch not found", extra=dict(branch_name=branch_name, repo=repo)
+ )
+ return None, coverage_range
+ try:
+ commit = repo.commits.filter(commitid=branch.head).first()
+ except ObjectDoesNotExist:
+ # if commit does not exist return None coverage
+ log.warning("Commit not found", extra=dict(commit=branch.head))
+ return None, coverage_range
+
+ if repo.yaml and repo.yaml.get("coverage", {}).get("range") is not None:
+ coverage_range = repo.yaml.get("coverage", {}).get("range")
+
+ flag = self.request.query_params.get("flag")
+ if flag:
+ return self.flag_coverage(flag, commit), coverage_range
+
+ component = self.request.query_params.get("component")
+ if component:
+ return self.component_coverage(component, commit), coverage_range
+
+ coverage = (
+ commit.totals.get("c")
+ if commit is not None and commit.totals is not None
+ else None
+ )
+
+ return coverage, coverage_range
+
+ def flag_coverage(self, flag_name: str, commit: Commit):
+ """
+ Looks into a commit's report sessions and returns the coverage for a particular flag name.
+ """
+ if commit.full_report is None:
+ log.warning(
+ "Commit's report not found",
+ extra=dict(commit=commit.commitid, flag=flag_name),
+ )
+ return None
+ flags = commit.full_report.flags
+ if flags is None:
+ return None
+ flag = flags.get(flag_name)
+ if flag:
+ return flag.totals.coverage
+ return None
+
+ def component_coverage(self, component_identifier: str, commit: Commit):
+ """
+ Looks into a commit's report sessions and returns the coverage for a particular component.
+ """
+ report = commit.full_report
+ if report is None:
+ log.warning(
+ "Commit's report not found",
+ extra=dict(commit=commit.commitid, component=component_identifier),
+ )
+ return None
+ components = commit_components(commit, None)
+
+ try:
+ component = next(
+ c
+ for c in components
+ if c.component_id == component_identifier
+ or c.name == component_identifier
+ )
+ except StopIteration:
+ # Component not found
+ return None
+
+ # Gets the flags present in commit's report and reduces to only those
+ # that match at least one of the component's flag regexes.
+ component_flags = component.get_matching_flags(report.get_flag_names())
+
+ # Filters the commit report on the component's flags and paths.
+ filtered_report = report.filter(flags=component_flags, paths=component.paths)
+
+ return filtered_report.totals.coverage
+
+
+class BundleBadgeHandler(APIView, RepoPropertyMixin, GraphBadgeAPIMixin):
+ content_negotiation_class = IgnoreClientContentNegotiation
+
+ permission_classes = [AllowAny]
+
+ extensions = ["svg", "txt"]
+ precisions = ["0", "1", "2"]
+ filename = "bundle-badge"
+
+ def get_object(self, request, *args, **kwargs):
+ # Validate precision query param
+ precision = self.request.query_params.get("precision", "2")
+ precision = int(precision) if precision in self.precisions else 2
+
+ bundle_size_bytes = self.get_bundle_size()
+
+ if self.kwargs.get("ext") == "txt":
+ return (
+ "unknown"
+ if bundle_size_bytes is None
+ else format_bundle_bytes(bundle_size_bytes, precision)
+ )
+
+ return get_bundle_badge(bundle_size_bytes, precision)
+
+ def get_bundle_size(self) -> int | None:
+ try:
+ repo = self.repo
+ except Http404:
+ log.warning("Repo not found", extra=dict(repo=self.kwargs.get("repo_name")))
+ return None
+
+ if repo.private and repo.image_token != self.request.query_params.get("token"):
+ log.warning(
+ "Token provided does not match repo's image token",
+ extra=dict(repo=repo),
+ )
+ return None
+
+ branch_name = self.kwargs.get("branch") or repo.branch
+ branch = Branch.objects.filter(
+ name=branch_name, repository_id=repo.repoid
+ ).first()
+
+ if branch is None:
+ log.warning(
+ "Branch not found", extra=dict(branch_name=branch_name, repo=repo)
+ )
+ return None
+
+ commit: Commit = repo.commits.filter(commitid=branch.head).first()
+ if commit is None:
+ log.warning("Commit not found", extra=dict(commit=branch.head))
+ return None
+
+ commit_bundles = load_report(commit)
+
+ if commit_bundles is None:
+ log.warning(
+ "Bundle analysis report not found for commit",
+ extra=dict(commit=branch.head),
+ )
+ return None
+
+ bundle_name = str(self.kwargs.get("bundle"))
+ bundle = commit_bundles.bundle_report(bundle_name)
+
+ if bundle is None:
+ log.warning(
+ "Bundle with provided name not found for commit",
+ extra=dict(commit=branch.head),
+ )
+ return None
+
+ return bundle.total_size()
+
+
+class GraphHandler(APIView, RepoPropertyMixin, GraphBadgeAPIMixin):
+ permission_classes = [AllowAny]
+
+ extensions = ["svg"]
+ filename = "graph"
+
+ def get_object(self, request, *args, **kwargs):
+ options = dict()
+ graph = self.kwargs.get("graph")
+
+ # a flare graph has been requested
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=0))
+ log.info(
+ msg="flare graph activity",
+ extra=dict(position="start", graph_type=graph, kwargs=self.kwargs),
+ )
+
+ flare = self.get_flare()
+ # flare success, will generate and return graph
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=20))
+
+ if graph == "tree":
+ options["width"] = int(
+ self.request.query_params.get(
+ "width", settings["sunburst"]["options"]["width"] or 100
+ )
+ )
+ options["height"] = int(
+ self.request.query_params.get(
+ "height", settings["sunburst"]["options"]["height"] or 100
+ )
+ )
+ inc_counter(FLARE_SUCCESS_COUNTER, labels=dict(graph_type=graph))
+ log.info(
+ msg="flare graph activity",
+ extra=dict(position="success", graph_type=graph, kwargs=self.kwargs),
+ )
+ return tree(flare, None, None, **options)
+ elif graph == "icicle":
+ options["width"] = int(
+ self.request.query_params.get(
+ "width", settings["icicle"]["options"]["width"] or 100
+ )
+ )
+ options["height"] = int(
+ self.request.query_params.get(
+ "height", settings["icicle"]["options"]["height"] or 100
+ )
+ )
+ inc_counter(FLARE_SUCCESS_COUNTER, labels=dict(graph_type=graph))
+ log.info(
+ msg="flare graph activity",
+ extra=dict(position="success", graph_type=graph, kwargs=self.kwargs),
+ )
+ return icicle(flare, **options)
+ elif graph == "sunburst":
+ options["width"] = int(
+ self.request.query_params.get(
+ "width", settings["sunburst"]["options"]["width"] or 100
+ )
+ )
+ options["height"] = int(
+ self.request.query_params.get(
+ "height", settings["sunburst"]["options"]["height"] or 100
+ )
+ )
+ inc_counter(FLARE_SUCCESS_COUNTER, labels=dict(graph_type=graph))
+ log.info(
+ msg="flare graph activity",
+ extra=dict(position="success", graph_type=graph, kwargs=self.kwargs),
+ )
+ return sunburst(flare, **options)
+
+ def get_flare(self):
+ pullid = self.kwargs.get("pullid")
+
+ if not pullid:
+ # pullid not in kwargs, try to generate flare from commit
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=12))
+ return self.get_commit_flare()
+ else:
+ # pullid was included in the request
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=1))
+ pull_flare = self.get_pull_flare(pullid)
+ if pull_flare is None:
+ # failed to get flare from pull OR commit - graph request failed
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=15))
+ raise NotFound(
+ "Not found. Note: private repositories require ?token arguments"
+ )
+ return pull_flare
+
+ def get_commit_flare(self):
+ commit = self.get_commit()
+
+ if commit is None:
+ # could not find a commit - graph request failed
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=13))
+ raise NotFound(
+ "Not found. Note: private repositories require ?token arguments"
+ )
+
+ # will attempt to build a report from a commit
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=10))
+ report = report_service.build_report_from_commit(commit)
+
+ if report is None:
+ # report generation failed
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=14))
+ raise NotFound("Not found. Note: file for chunks not found in storage")
+
+ # report successfully generated
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=11))
+ return report.flare(None, [70, 100])
+
+ def get_pull_flare(self, pullid):
+ try:
+ repo = self.repo
+ # repo was included
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=2))
+ except Http404:
+ return None
+ pull = Pull.objects.filter(pullid=pullid, repository_id=repo.repoid).first()
+ if pull is not None:
+ # pull found
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=3))
+ if pull._flare is not None or pull._flare_storage_path is not None:
+ # pull has flare
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=4))
+ return pull.flare
+ # pull not found or pull does not have flare, try to generate flare
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=5))
+ return self.get_commit_flare()
+
+ def get_commit(self):
+ try:
+ repo = self.repo
+ # repo included in request
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=6))
+ except Http404:
+ return None
+ if repo.private and repo.image_token != self.request.query_params.get("token"):
+ # failed auth
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=7))
+ return None
+
+ commitid = self.kwargs.get("commit")
+ if commitid:
+ # commitid included on request
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=8))
+ commit = repo.commits.filter(commitid=commitid).first()
+ else:
+ branch_name = self.kwargs.get("branch") or repo.branch
+ branch = Branch.objects.filter(
+ name=branch_name, repository_id=repo.repoid
+ ).first()
+ if branch is None:
+ # failed to get a commit
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=16))
+ return None
+
+ # found a commit by finding a branch
+ inc_counter(FLARE_USE_COUNTER, labels=dict(position=9))
+ commit = repo.commits.filter(commitid=branch.head).first()
+
+ return commit
diff --git a/apps/codecov-api/gunicorn.conf.py b/apps/codecov-api/gunicorn.conf.py
new file mode 100644
index 0000000000..fc81474cd9
--- /dev/null
+++ b/apps/codecov-api/gunicorn.conf.py
@@ -0,0 +1,48 @@
+import logging
+import os
+
+from gunicorn.glogging import Logger
+from prometheus_client import multiprocess
+
+
+def child_exit(server, worker):
+ if worker and worker.pid and "PROMETHEUS_MULTIPROC_DIR" in os.environ:
+ multiprocess.mark_process_dead(worker.pid)
+
+
+class CustomGunicornLogger(Logger):
+ """
+ A custom class for logging gunicorn startup logs, these are for the logging that takes
+ place before the Django app starts and takes over with its own defined logging formats.
+ This class ensures the gunicorn minimum log level to be INFO instead of the default ERROR.
+ """
+
+ def setup(self, cfg):
+ super().setup(cfg)
+ custom_format = "[%(levelname)s] [%(process)d] [%(asctime)s] %(message)s "
+ date_format = "%Y-%m-%d %H:%M:%S %z"
+ formatter = logging.Formatter(fmt=custom_format, datefmt=date_format)
+
+ # Update handlers with the custom formatter
+ for handler in self.error_log.handlers:
+ handler.setFormatter(formatter)
+ for handler in self.access_log.handlers:
+ handler.setFormatter(formatter)
+
+
+logconfig_dict = {
+ "loggers": {
+ "gunicorn.error": {
+ "level": "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
+ "gunicorn.access": {
+ "level": "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
+ }
+}
+
+logger_class = CustomGunicornLogger
diff --git a/apps/codecov-api/labelanalysis/__init__.py b/apps/codecov-api/labelanalysis/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/labelanalysis/admin.py b/apps/codecov-api/labelanalysis/admin.py
new file mode 100644
index 0000000000..846f6b4061
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/admin.py
@@ -0,0 +1 @@
+# Register your models here.
diff --git a/apps/codecov-api/labelanalysis/apps.py b/apps/codecov-api/labelanalysis/apps.py
new file mode 100644
index 0000000000..4620208fd1
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class LabelanalysisConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "labelanalysis"
diff --git a/apps/codecov-api/labelanalysis/models.py b/apps/codecov-api/labelanalysis/models.py
new file mode 100644
index 0000000000..8a0a37e452
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/models.py
@@ -0,0 +1 @@
+from shared.django_apps.labelanalysis.models import *
diff --git a/apps/codecov-api/labelanalysis/serializers.py b/apps/codecov-api/labelanalysis/serializers.py
new file mode 100644
index 0000000000..a75237a1c5
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/serializers.py
@@ -0,0 +1,89 @@
+from rest_framework import exceptions, serializers
+
+from core.models import Commit
+from labelanalysis.models import (
+ LabelAnalysisProcessingError,
+ LabelAnalysisRequest,
+ LabelAnalysisRequestState,
+)
+
+
+class CommitFromShaSerializerField(serializers.Field):
+ def __init__(self, *args, **kwargs):
+ self.accepts_fallback = kwargs.pop("accepts_fallback", False)
+ super().__init__(*args, **kwargs)
+
+ def to_representation(self, commit):
+ return commit.commitid
+
+ def to_internal_value(self, commit_sha):
+ commit = Commit.objects.filter(
+ repository__in=self.context["request"].auth.get_repositories(),
+ commitid=commit_sha,
+ ).first()
+ if commit is None:
+ raise exceptions.NotFound(f"Commit {commit_sha[:7]} not found.")
+ if commit.staticanalysissuite_set.exists():
+ return commit
+ if not self.accepts_fallback:
+ raise serializers.ValidationError("No static analysis found")
+ attempted_commits = []
+ for _ in range(10):
+ attempted_commits.append(commit.commitid)
+ commit = commit.parent_commit
+ if commit is None:
+ raise serializers.ValidationError(
+ f"No possible commits have static analysis sent. Attempted commits: {','.join(attempted_commits)}"
+ )
+ if commit.staticanalysissuite_set.exists():
+ return commit
+ raise serializers.ValidationError(
+ f"No possible commits have static analysis sent. Attempted too many commits: {','.join(attempted_commits)}"
+ )
+
+
+class LabelAnalysisProcessingErrorSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = LabelAnalysisProcessingError
+ fields = ("error_code", "error_params")
+ read_only_fields = ("error_code", "error_params")
+
+
+class ProcessingErrorList(serializers.ListField):
+ child = LabelAnalysisProcessingErrorSerializer()
+
+ def to_representation(self, data):
+ data = data.select_related(
+ "label_analysis_request",
+ ).all()
+ return super().to_representation(data)
+
+
+class LabelAnalysisRequestSerializer(serializers.ModelSerializer):
+ base_commit = CommitFromShaSerializerField(required=True, accepts_fallback=True)
+ head_commit = CommitFromShaSerializerField(required=True, accepts_fallback=False)
+ state = serializers.SerializerMethodField()
+ errors = ProcessingErrorList(required=False)
+
+ def validate(self, data):
+ if data["base_commit"] == data["head_commit"]:
+ raise serializers.ValidationError(
+ {"base_commit": "Base and head must be different commits"}
+ )
+ return data
+
+ class Meta:
+ model = LabelAnalysisRequest
+ fields = (
+ "base_commit",
+ "head_commit",
+ "requested_labels",
+ "result",
+ "state",
+ "external_id",
+ "errors",
+ )
+ read_only_fields = ("result", "external_id", "errors")
+
+ def get_state(self, obj):
+ return LabelAnalysisRequestState.enum_from_int(obj.state_id).name.lower()
diff --git a/apps/codecov-api/labelanalysis/tests/__init__.py b/apps/codecov-api/labelanalysis/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/labelanalysis/tests/factories.py b/apps/codecov-api/labelanalysis/tests/factories.py
new file mode 100644
index 0000000000..2a08cd0b4a
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/tests/factories.py
@@ -0,0 +1 @@
+from shared.django_apps.labelanalysis.tests.factories import *
diff --git a/apps/codecov-api/labelanalysis/tests/integration/__init__.py b/apps/codecov-api/labelanalysis/tests/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/labelanalysis/tests/integration/test_views.py b/apps/codecov-api/labelanalysis/tests/integration/test_views.py
new file mode 100644
index 0000000000..6f153abc79
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/tests/integration/test_views.py
@@ -0,0 +1,457 @@
+from uuid import uuid4
+
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.celery_config import label_analysis_task_name
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ RepositoryFactory,
+ RepositoryTokenFactory,
+)
+
+from labelanalysis.models import (
+ LabelAnalysisProcessingError,
+ LabelAnalysisRequest,
+ LabelAnalysisRequestState,
+)
+from labelanalysis.tests.factories import LabelAnalysisRequestFactory
+from services.task import TaskService
+from staticanalysis.tests.factories import StaticAnalysisSuiteFactory
+
+
+def test_simple_label_analysis_call_flow(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit = CommitFactory.create(repository=commit.repository)
+ StaticAnalysisSuiteFactory.create(commit=base_commit)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 201
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 1
+ produced_object = LabelAnalysisRequest.objects.get(head_commit=commit)
+ assert produced_object
+ assert produced_object.base_commit == base_commit
+ assert produced_object.head_commit == commit
+ assert produced_object.requested_labels is None
+ assert produced_object.state_id == LabelAnalysisRequestState.CREATED.db_id
+ assert produced_object.result is None
+ response_json = response.json()
+ expected_response_json = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ "result": None,
+ "state": "created",
+ "external_id": str(produced_object.external_id),
+ "errors": [],
+ }
+ assert response_json == expected_response_json
+ mocked_task_service.assert_called_with(
+ label_analysis_task_name,
+ kwargs={"request_id": produced_object.id},
+ apply_async_kwargs={},
+ )
+ get_url = reverse(
+ "view_label_analysis", kwargs=dict(external_id=produced_object.external_id)
+ )
+ response = client.get(
+ get_url,
+ format="json",
+ )
+ assert response.status_code == 200
+ assert response.json() == expected_response_json
+
+
+def test_simple_label_analysis_call_flow_same_commit_error(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 400
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 0
+ response_json = response.json()
+ expected_response_json = {
+ "base_commit": ["Base and head must be different commits"]
+ }
+ assert response_json == expected_response_json
+ assert not mocked_task_service.called
+
+
+def test_simple_label_analysis_call_flow_with_fallback_on_base(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit_parent_parent = CommitFactory.create(repository=commit.repository)
+ base_commit_parent = CommitFactory.create(
+ parent_commit_id=base_commit_parent_parent.commitid,
+ repository=commit.repository,
+ )
+ base_commit = CommitFactory.create(
+ parent_commit_id=base_commit_parent.commitid, repository=commit.repository
+ )
+ StaticAnalysisSuiteFactory.create(commit=base_commit_parent_parent)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 201
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 1
+ produced_object = LabelAnalysisRequest.objects.get(head_commit=commit)
+ assert produced_object
+ assert produced_object.base_commit == base_commit_parent_parent
+ assert produced_object.head_commit == commit
+ assert produced_object.requested_labels is None
+ assert produced_object.state_id == LabelAnalysisRequestState.CREATED.db_id
+ assert produced_object.result is None
+ response_json = response.json()
+ expected_response_json = {
+ "base_commit": base_commit_parent_parent.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ "result": None,
+ "state": "created",
+ "external_id": str(produced_object.external_id),
+ "errors": [],
+ }
+ assert response_json == expected_response_json
+ mocked_task_service.assert_called_with(
+ label_analysis_task_name,
+ kwargs={"request_id": produced_object.id},
+ apply_async_kwargs={},
+ )
+ get_url = reverse(
+ "view_label_analysis", kwargs=dict(external_id=produced_object.external_id)
+ )
+ response = client.get(
+ get_url,
+ format="json",
+ )
+ assert response.status_code == 200
+ assert response.json() == expected_response_json
+
+
+def test_simple_label_analysis_call_flow_with_fallback_on_base_error_too_long(
+ db, mocker
+):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ repository = RepositoryFactory.create(active=True)
+ commit = CommitFactory.create(repository=repository)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit_root = CommitFactory.create(repository=repository)
+ StaticAnalysisSuiteFactory.create(commit=base_commit_root)
+ current = base_commit_root
+ attempted_commit_list = [base_commit_root.commitid]
+ for i in range(12):
+ current = CommitFactory.create(
+ parent_commit_id=current.commitid, repository=repository
+ )
+ attempted_commit_list.append(current.commitid)
+ base_commit = current
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 400
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 0
+ response_json = response.json()
+ # reverse and get 10 first elements, thats how far we look
+ attempted_commit_list = ",".join(list(reversed(attempted_commit_list))[:10])
+ expected_response_json = {
+ "base_commit": [
+ f"No possible commits have static analysis sent. Attempted too many commits: {attempted_commit_list}"
+ ]
+ }
+ assert response_json == expected_response_json
+ assert not mocked_task_service.called
+
+
+def test_simple_label_analysis_call_flow_with_fallback_on_base_error(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit_parent_parent = CommitFactory.create(repository=commit.repository)
+ base_commit_parent = CommitFactory.create(
+ parent_commit_id=base_commit_parent_parent.commitid,
+ repository=commit.repository,
+ )
+ base_commit = CommitFactory.create(
+ parent_commit_id=base_commit_parent.commitid, repository=commit.repository
+ )
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 400
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 0
+ response_json = response.json()
+ attempted_commit_list = ",".join(
+ [
+ base_commit.commitid,
+ base_commit_parent.commitid,
+ base_commit_parent_parent.commitid,
+ ]
+ )
+ expected_response_json = {
+ "base_commit": [
+ f"No possible commits have static analysis sent. Attempted commits: {attempted_commit_list}"
+ ]
+ }
+ assert response_json == expected_response_json
+ assert not mocked_task_service.called
+
+
+def test_simple_label_analysis_call_flow_with_fallback_on_head_error(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ repository = RepositoryFactory.create(active=True)
+ head_commit_parent = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(
+ parent_commit_id=head_commit_parent.commitid, repository=repository
+ )
+ base_commit = CommitFactory.create(repository=repository)
+ StaticAnalysisSuiteFactory.create(commit=base_commit)
+ StaticAnalysisSuiteFactory.create(commit=head_commit_parent)
+ token = RepositoryTokenFactory.create(
+ repository=head_commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("create_label_analysis")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ payload = {
+ "base_commit": base_commit.commitid,
+ "head_commit": head_commit.commitid,
+ "requested_labels": None,
+ }
+ response = client.post(
+ url,
+ payload,
+ format="json",
+ )
+ assert response.status_code == 400
+ assert LabelAnalysisRequest.objects.filter(head_commit=head_commit).count() == 0
+ assert (
+ LabelAnalysisRequest.objects.filter(head_commit=head_commit_parent).count() == 0
+ )
+ response_json = response.json()
+ expected_response_json = {"head_commit": ["No static analysis found"]}
+ assert response_json == expected_response_json
+ assert not mocked_task_service.called
+
+
+def test_simple_label_analysis_only_get(db, mocker):
+ commit = CommitFactory.create(repository__active=True)
+ base_commit = CommitFactory.create(repository=commit.repository)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ label_analysis = LabelAnalysisRequestFactory.create(
+ head_commit=commit,
+ base_commit=base_commit,
+ state_id=LabelAnalysisRequestState.FINISHED.db_id,
+ result={"some": ["result"]},
+ )
+ larq_processing_error = LabelAnalysisProcessingError(
+ label_analysis_request=label_analysis,
+ error_code="Missing FileSnapshot",
+ error_params={"message": "Something is wrong"},
+ )
+ label_analysis.save()
+ larq_processing_error.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 1
+ produced_object = LabelAnalysisRequest.objects.get(head_commit=commit)
+ assert produced_object == label_analysis
+ expected_response_json = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": None,
+ "result": {"some": ["result"]},
+ "state": "finished",
+ "external_id": str(produced_object.external_id),
+ "errors": [
+ {
+ "error_code": "Missing FileSnapshot",
+ "error_params": {"message": "Something is wrong"},
+ }
+ ],
+ }
+ get_url = reverse(
+ "view_label_analysis", kwargs=dict(external_id=produced_object.external_id)
+ )
+ response = client.get(
+ get_url,
+ format="json",
+ )
+ assert response.status_code == 200
+ assert response.json() == expected_response_json
+
+
+def test_simple_label_analysis_get_does_not_exist(db, mocker):
+ token = RepositoryTokenFactory.create(
+ repository__active=True, token_type="static_analysis"
+ )
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ get_url = reverse("view_label_analysis", kwargs=dict(external_id=uuid4()))
+ response = client.get(
+ get_url,
+ format="json",
+ )
+ assert response.status_code == 404
+ assert response.json() == {"detail": "No such Label Analysis exists"}
+
+
+def test_simple_label_analysis_put_labels(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit = CommitFactory.create(repository=commit.repository)
+ StaticAnalysisSuiteFactory.create(commit=base_commit)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ label_analysis = LabelAnalysisRequestFactory.create(
+ head_commit=commit,
+ base_commit=base_commit,
+ state_id=LabelAnalysisRequestState.CREATED.db_id,
+ result=None,
+ )
+ label_analysis.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 1
+ produced_object = LabelAnalysisRequest.objects.get(head_commit=commit)
+ assert produced_object == label_analysis
+ assert produced_object.requested_labels is None
+ expected_response_json = {
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ "requested_labels": ["label_1", "label_2", "label_3"],
+ "result": None,
+ "state": "created",
+ "external_id": str(produced_object.external_id),
+ "errors": [],
+ }
+ patch_url = reverse(
+ "view_label_analysis", kwargs=dict(external_id=produced_object.external_id)
+ )
+ response = client.patch(
+ patch_url,
+ format="json",
+ data={
+ "requested_labels": ["label_1", "label_2", "label_3"],
+ "base_commit": base_commit.commitid,
+ "head_commit": commit.commitid,
+ },
+ )
+ assert response.status_code == 200
+ assert response.json() == expected_response_json
+ mocked_task_service.assert_called_with(
+ label_analysis_task_name,
+ kwargs=dict(request_id=label_analysis.id),
+ apply_async_kwargs=dict(),
+ )
+
+
+def test_simple_label_analysis_put_labels_wrong_base_return_404(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ StaticAnalysisSuiteFactory.create(commit=commit)
+ base_commit = CommitFactory.create(repository=commit.repository)
+ StaticAnalysisSuiteFactory.create(commit=base_commit)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ label_analysis = LabelAnalysisRequestFactory.create(
+ head_commit=commit,
+ base_commit=base_commit,
+ state_id=LabelAnalysisRequestState.CREATED.db_id,
+ result=None,
+ )
+ label_analysis.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ assert LabelAnalysisRequest.objects.filter(head_commit=commit).count() == 1
+ produced_object = LabelAnalysisRequest.objects.get(head_commit=commit)
+ assert produced_object == label_analysis
+ assert produced_object.requested_labels is None
+ patch_url = reverse(
+ "view_label_analysis", kwargs=dict(external_id=produced_object.external_id)
+ )
+ response = client.patch(
+ patch_url,
+ format="json",
+ data={
+ "requested_labels": ["label_1", "label_2", "label_3"],
+ "base_commit": "not_base_commit",
+ "head_commit": commit.commitid,
+ },
+ )
+ assert response.status_code == 404
+ mocked_task_service.assert_not_called()
diff --git a/apps/codecov-api/labelanalysis/urls.py b/apps/codecov-api/labelanalysis/urls.py
new file mode 100644
index 0000000000..2ebda9b54e
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/urls.py
@@ -0,0 +1,19 @@
+from django.urls import path
+
+from labelanalysis.views import (
+ LabelAnalysisRequestCreateView,
+ LabelAnalysisRequestDetailView,
+)
+
+urlpatterns = [
+ path(
+ "labels-analysis",
+ LabelAnalysisRequestCreateView.as_view(),
+ name="create_label_analysis",
+ ),
+ path(
+ "labels-analysis/",
+ LabelAnalysisRequestDetailView.as_view(),
+ name="view_label_analysis",
+ ),
+]
diff --git a/apps/codecov-api/labelanalysis/views.py b/apps/codecov-api/labelanalysis/views.py
new file mode 100644
index 0000000000..222ea6be3a
--- /dev/null
+++ b/apps/codecov-api/labelanalysis/views.py
@@ -0,0 +1,58 @@
+from rest_framework.exceptions import NotFound
+from rest_framework.generics import CreateAPIView, RetrieveAPIView, UpdateAPIView
+from shared.celery_config import label_analysis_task_name
+
+from codecov_auth.authentication.repo_auth import RepositoryTokenAuthentication
+from codecov_auth.permissions import SpecificScopePermission
+from labelanalysis.models import LabelAnalysisRequest, LabelAnalysisRequestState
+from labelanalysis.serializers import LabelAnalysisRequestSerializer
+from services.task import TaskService
+
+
+class LabelAnalysisRequestCreateView(CreateAPIView):
+ serializer_class = LabelAnalysisRequestSerializer
+ authentication_classes = [RepositoryTokenAuthentication]
+ permission_classes = [SpecificScopePermission]
+ # TODO Consider using a different permission scope
+ required_scopes = ["static_analysis"]
+
+ def perform_create(self, serializer):
+ instance = serializer.save(state_id=LabelAnalysisRequestState.CREATED.db_id)
+ TaskService().schedule_task(
+ label_analysis_task_name,
+ kwargs=dict(request_id=instance.id),
+ apply_async_kwargs=dict(),
+ )
+ return instance
+
+
+class LabelAnalysisRequestDetailView(RetrieveAPIView, UpdateAPIView):
+ serializer_class = LabelAnalysisRequestSerializer
+ authentication_classes = [RepositoryTokenAuthentication]
+ permission_classes = [SpecificScopePermission]
+ # TODO Consider using a different permission scope
+ required_scopes = ["static_analysis"]
+
+ def patch(self, request, *args, **kwargs):
+ # This is called by the CLI to patch the request_labels information after it's collected
+ # First we let rest_framework validate and update the larq object
+ response = super().patch(request, *args, **kwargs)
+ if response.status_code == 200:
+ # IF the larq update was successful
+ # we trigger the task again for the same larq to update the result saved
+ # The result saved is what we use to get metrics
+ uid = self.kwargs.get("external_id")
+ larq = LabelAnalysisRequest.objects.get(external_id=uid)
+ TaskService().schedule_task(
+ label_analysis_task_name,
+ kwargs=dict(request_id=larq.id),
+ apply_async_kwargs=dict(),
+ )
+ return response
+
+ def get_object(self):
+ uid = self.kwargs.get("external_id")
+ try:
+ return LabelAnalysisRequest.objects.get(external_id=uid)
+ except LabelAnalysisRequest.DoesNotExist:
+ raise NotFound("No such Label Analysis exists")
diff --git a/apps/codecov-api/legacy_migrations/__init__.py b/apps/codecov-api/legacy_migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/legacy_migrations/apps.py b/apps/codecov-api/legacy_migrations/apps.py
new file mode 100644
index 0000000000..3516f440e8
--- /dev/null
+++ b/apps/codecov-api/legacy_migrations/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class LegacyMigrationsConfig(AppConfig):
+ name = "legacy_migrations"
diff --git a/apps/codecov-api/legacy_migrations/management/commands/__init__.py b/apps/codecov-api/legacy_migrations/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/legacy_migrations/management/commands/migrate.py b/apps/codecov-api/legacy_migrations/management/commands/migrate.py
new file mode 100644
index 0000000000..c0d6512820
--- /dev/null
+++ b/apps/codecov-api/legacy_migrations/management/commands/migrate.py
@@ -0,0 +1,2 @@
+# We inherit the migrate command from Shared
+from shared.django_apps.legacy_migrations.management.commands.migrate import *
diff --git a/apps/codecov-api/legacy_migrations/models.py b/apps/codecov-api/legacy_migrations/models.py
new file mode 100644
index 0000000000..bb6c070473
--- /dev/null
+++ b/apps/codecov-api/legacy_migrations/models.py
@@ -0,0 +1 @@
+from shared.django_apps.legacy_migrations.models import *
diff --git a/apps/codecov-api/legacy_migrations/tests/factories.py b/apps/codecov-api/legacy_migrations/tests/factories.py
new file mode 100644
index 0000000000..93d4cf7e52
--- /dev/null
+++ b/apps/codecov-api/legacy_migrations/tests/factories.py
@@ -0,0 +1,15 @@
+import factory
+from django.utils import timezone
+from factory.django import DjangoModelFactory
+
+from legacy_migrations.models import YamlHistory
+
+
+class YamlHistoryFactory(DjangoModelFactory):
+ class Meta:
+ model = YamlHistory
+
+ timestamp = factory.LazyFunction(timezone.now)
+ message = "some_message"
+ source = "some source"
+ diff = "some diff between things"
diff --git a/apps/codecov-api/legacy_migrations/tests/unit/test_models.py b/apps/codecov-api/legacy_migrations/tests/unit/test_models.py
new file mode 100644
index 0000000000..45db86639d
--- /dev/null
+++ b/apps/codecov-api/legacy_migrations/tests/unit/test_models.py
@@ -0,0 +1,18 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from legacy_migrations.models import YamlHistory
+from legacy_migrations.tests.factories import YamlHistoryFactory
+
+
+class TestYamlHistory(TestCase):
+ def test_get_pieces_of_model(self):
+ owner = OwnerFactory()
+ author = OwnerFactory()
+ yaml = YamlHistoryFactory(author=author, ownerid=owner, message="some_message")
+
+ assert yaml.ownerid == owner
+ assert yaml.author == author
+ assert yaml.message == "some_message"
+
+ assert YamlHistory.objects.count() == 1
diff --git a/apps/codecov-api/manage.py b/apps/codecov-api/manage.py
new file mode 100755
index 0000000000..c9a00e4c99
--- /dev/null
+++ b/apps/codecov-api/manage.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+import os
+import sys
+
+from utils.config import get_settings_module
+
+if __name__ == "__main__":
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", get_settings_module())
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
diff --git a/apps/codecov-api/migrate.sh b/apps/codecov-api/migrate.sh
new file mode 100644
index 0000000000..151154f133
--- /dev/null
+++ b/apps/codecov-api/migrate.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+# Command ran by k8s to run migrations for api
+echo "Running Django migrations"
+prefix=""
+if [ -f "/usr/local/bin/berglas" ]; then
+ prefix="berglas exec --"
+fi
+
+$prefix python manage.py migrate
+$prefix python manage.py pgpartition --yes --skip-delete
\ No newline at end of file
diff --git a/apps/codecov-api/mypy.ini b/apps/codecov-api/mypy.ini
new file mode 100644
index 0000000000..1494f91bad
--- /dev/null
+++ b/apps/codecov-api/mypy.ini
@@ -0,0 +1,11 @@
+# Global options:
+
+[mypy]
+disallow_untyped_defs = True
+ignore_missing_imports = True
+disable_error_code = attr-defined,import-untyped,name-defined
+follow_imports = silent
+warn_no_return = False
+
+[mypy-*.tests.*]
+disallow_untyped_defs = False
\ No newline at end of file
diff --git a/apps/codecov-api/prod.sh b/apps/codecov-api/prod.sh
new file mode 100755
index 0000000000..f87719d145
--- /dev/null
+++ b/apps/codecov-api/prod.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+# Starts the production gunicorn server (no --reload)
+echo "Starting gunicorn in production mode"
+prefix=""
+if [ -f "/usr/local/bin/berglas" ]; then
+ prefix="berglas exec --"
+fi
+
+GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
+if [ "$GUNICORN_WORKERS" -gt 1 ];
+then
+ export PROMETHEUS_MULTIPROC_DIR="${PROMETHEUS_MULTIPROC_DIR:-$HOME/.prometheus}"
+ rm -r ${PROMETHEUS_MULTIPROC_DIR?}/* 2> /dev/null
+ mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
+fi
+
+$prefix gunicorn codecov.wsgi:application --workers=${GUNICORN_WORKERS} --threads=${GUNICORN_THREADS:-1} --worker-connections=${GUNICORN_WORKER_CONNECTIONS:-1000} --bind 0.0.0.0:8000 --access-logfile '-' --statsd-host ${STATSD_HOST}:${STATSD_PORT} --timeout "${GUNICORN_TIMEOUT:-600}" --disable-redirect-access-to-syslog --max-requests=50000 --max-requests-jitter=300
diff --git a/apps/codecov-api/production.yml b/apps/codecov-api/production.yml
new file mode 100644
index 0000000000..d5240709cd
--- /dev/null
+++ b/apps/codecov-api/production.yml
@@ -0,0 +1,39 @@
+setup:
+ codecov_url: https://codecov.io
+ debug: no
+ loglvl: INFO
+ encryption_secret: "zp^P9*i8aR3"
+ media:
+ assets: https://codecov-cdn.storage.googleapis.com/4.4.4-fd6aa1e
+ dependancies: https://codecov-cdn.storage.googleapis.com/4.4.4-fd6aa1e
+ http:
+ force_https: yes
+ cookie_secret: Z1353^dggqdbc,kp0)661
+ timeouts:
+ connect: 10
+ receive: 15
+ tasks:
+ celery:
+ soft_timelimit: 200
+ hard_timelimit: 240
+ upload:
+ queue: uploads
+ cache:
+ yaml: 600 # 10 minutes
+ tree: 600 # 10 minutes
+ diff: 300 # 5 minutes
+ chunks: 300 # 5 minutes
+ uploads: 86400 # 1 day
+
+services:
+ redis_url: redis://redis:@localhost:6379/
+ database:
+ username: postgres
+ name: postgres
+ password: postgres
+ host: localhost
+ minio:
+ hash_key: testixik8qdauiab1yiffydimvi72ekq # never change this
+ access_key_id: codecov-default-key
+ secret_access_key: codecov-default-secret
+ verify_ssl: false
diff --git a/apps/codecov-api/pyproject.toml b/apps/codecov-api/pyproject.toml
new file mode 100644
index 0000000000..76fb273e6c
--- /dev/null
+++ b/apps/codecov-api/pyproject.toml
@@ -0,0 +1,77 @@
+[project]
+name = "codecov-api"
+version = "0.0.1"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "aiodataloader==0.4.0",
+ "ariadne==0.23.0",
+ "ariadne-django==0.3.0",
+ "celery>=5.3.6",
+ "cerberus==1.3.5",
+ "certifi>=2024.7.4",
+ "django-autocomplete-light==3.11.0",
+ "django-better-admin-arrayfield==1.4.2",
+ "django-cors-headers==3.7.0",
+ "django-csp==3.8.0",
+ "django-cursor-pagination==0.3.0",
+ "django-filter==2.4.0",
+ "django-model-utils==4.5.1",
+ "django-postgres-extra>=2.0.8",
+ "django-prometheus==2.3.1",
+ "django>=4.2.16",
+ "djangorestframework==3.15.2",
+ "drf-spectacular==0.26.2",
+ "drf-spectacular-sidecar==2023.3.1",
+ "google-cloud-pubsub>=2.18.4",
+ "grpcio>=1.66.2",
+ "gunicorn>=22.0.0",
+ "idna>=3.7",
+ "minio==7.1.13",
+ "multidict>=6.1.0",
+ "polars==1.12.0",
+ "psycopg2-binary>=2.9.10",
+ "pydantic>=2.9.0",
+ "pyjwt>=2.4.0",
+ "python-dateutil==2.9.0.post0",
+ "python-json-logger==2.0.7",
+ "python-redis-lock==4.0.0",
+ "pytz==2022.1",
+ "redis==4.4.4",
+ "regex==2023.12.25",
+ "requests==2.32.3",
+ "sentry-sdk>=2.13.0",
+ "sentry-sdk[celery]==2.13.0",
+ "shared",
+ "simplejson==3.17.2",
+ "starlette==0.40.0",
+ "stripe>=11.4.1",
+ "whitenoise==5.2.0",
+]
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+py-modules = []
+
+[tool.uv]
+dev-dependencies = [
+ "factory-boy>=3.2.0",
+ "fakeredis==2.10.3",
+ "freezegun>=1.5.1",
+ "pre-commit>=3.4.0",
+ "pytest>=8.1.1",
+ "pytest-cov>=6.0.0",
+ "pytest-asyncio>=0.14.0",
+ "pytest-django==4.8.0",
+ "pytest-insta>=0.3.0",
+ "pytest-mock==3.14.0",
+ "urllib3==1.26.19",
+ "vcrpy>=6.0.1",
+ "ruff>=0.9.6",
+]
+
+[tool.uv.sources]
+shared = { path = "../../libs/shared" }
diff --git a/apps/codecov-api/pytest.ini b/apps/codecov-api/pytest.ini
new file mode 100644
index 0000000000..ec326ff696
--- /dev/null
+++ b/apps/codecov-api/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = codecov.settings_test
+addopts = -p no:warnings --ignore=shared --ignore-glob=**/test_results*
diff --git a/apps/codecov-api/reports/__init__.py b/apps/codecov-api/reports/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/reports/admin.py b/apps/codecov-api/reports/admin.py
new file mode 100644
index 0000000000..17b3e5bda8
--- /dev/null
+++ b/apps/codecov-api/reports/admin.py
@@ -0,0 +1,22 @@
+from django.contrib import admin
+
+from codecov.admin import AdminMixin
+from reports.models import ReportSession
+
+
+@admin.register(ReportSession)
+class ReportSessionAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("id", "external_id")
+ show_full_result_count = False
+ readonly_fields = ("external_id", "storage_path", "upload_type")
+ search_fields = ("external_id",)
+ fields = readonly_fields
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_add_permission(self, _, obj=None):
+ return False
+
+ def has_change_permission(self, _, obj=None):
+ return False
diff --git a/apps/codecov-api/reports/apps.py b/apps/codecov-api/reports/apps.py
new file mode 100644
index 0000000000..f5fbe30b47
--- /dev/null
+++ b/apps/codecov-api/reports/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ReportsConfig(AppConfig):
+ name = "reports"
diff --git a/apps/codecov-api/reports/managers.py b/apps/codecov-api/reports/managers.py
new file mode 100644
index 0000000000..0db5e5097e
--- /dev/null
+++ b/apps/codecov-api/reports/managers.py
@@ -0,0 +1,17 @@
+from django.db.models import Manager, Q, QuerySet
+
+
+class CommitReportQuerySet(QuerySet):
+ def coverage_reports(self):
+ """
+ Filters queryset such that only coverage reports are included.
+ """
+ return self.filter(Q(report_type=None) | Q(report_type="coverage"))
+
+
+class CommitReportManager(Manager):
+ def get_queryset(self):
+ return CommitReportQuerySet(self.model, using=self._db)
+
+ def coverage_reports(self, *args, **kwargs):
+ return self.get_queryset().coverage_reports(*args, **kwargs)
diff --git a/apps/codecov-api/reports/models.py b/apps/codecov-api/reports/models.py
new file mode 100644
index 0000000000..c4588d2a97
--- /dev/null
+++ b/apps/codecov-api/reports/models.py
@@ -0,0 +1 @@
+from shared.django_apps.reports.models import *
diff --git a/apps/codecov-api/reports/tests/__init__.py b/apps/codecov-api/reports/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/reports/tests/factories.py b/apps/codecov-api/reports/tests/factories.py
new file mode 100644
index 0000000000..3c77e59d71
--- /dev/null
+++ b/apps/codecov-api/reports/tests/factories.py
@@ -0,0 +1,144 @@
+from datetime import date, datetime
+
+import factory
+from factory.django import DjangoModelFactory
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from graphql_api.types.enums import UploadErrorEnum
+from reports import models
+from reports.models import ReportResults, TestInstance
+
+
+class CommitReportFactory(DjangoModelFactory):
+ class Meta:
+ model = models.CommitReport
+
+ commit = factory.SubFactory(CommitFactory)
+
+
+class UploadFactory(DjangoModelFactory):
+ class Meta:
+ model = models.ReportSession
+
+ build_code = factory.Sequence(lambda n: f"{n}")
+ report = factory.SubFactory(CommitReportFactory)
+ state = "processed"
+
+
+class RepositoryFlagFactory(DjangoModelFactory):
+ class Meta:
+ model = models.RepositoryFlag
+
+ repository = factory.SubFactory(RepositoryFactory)
+ flag_name = factory.Faker("word")
+
+
+class UploadFlagMembershipFactory(DjangoModelFactory):
+ class Meta:
+ model = models.UploadFlagMembership
+
+ flag = factory.SubFactory(RepositoryFlagFactory)
+ report_session = factory.SubFactory(UploadFactory)
+
+
+class ReportLevelTotalsFactory(DjangoModelFactory):
+ class Meta:
+ model = models.ReportLevelTotals
+
+ report = factory.SubFactory(CommitReportFactory)
+ branches = factory.Faker("pyint")
+ coverage = factory.Faker("pydecimal", min_value=10, max_value=90, right_digits=2)
+ hits = factory.Faker("pyint")
+ lines = factory.Faker("pyint")
+ methods = factory.Faker("pyint")
+ misses = factory.Faker("pyint")
+ partials = factory.Faker("pyint")
+ files = factory.Faker("pyint")
+
+
+class UploadLevelTotalsFactory(DjangoModelFactory):
+ class Meta:
+ model = models.UploadLevelTotals
+
+ report_session = factory.SubFactory(UploadFactory)
+
+
+class UploadErrorFactory(DjangoModelFactory):
+ class Meta:
+ model = models.UploadError
+
+ report_session = factory.SubFactory(UploadFactory)
+ error_code = factory.Iterator(
+ [
+ UploadErrorEnum.FILE_NOT_IN_STORAGE,
+ UploadErrorEnum.REPORT_EMPTY,
+ UploadErrorEnum.REPORT_EXPIRED,
+ ]
+ )
+
+
+class ReportResultsFactory(DjangoModelFactory):
+ class Meta:
+ model = ReportResults
+
+ report = factory.SubFactory(CommitReportFactory)
+ state = factory.Iterator(
+ [
+ ReportResults.ReportResultsStates.PENDING,
+ ReportResults.ReportResultsStates.COMPLETED,
+ ]
+ )
+
+
+class TestFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = models.Test
+
+ id = factory.Sequence(lambda n: f"{n}")
+ name = factory.Sequence(lambda n: f"{n}")
+ repository = factory.SubFactory(RepositoryFactory)
+ computed_name = None
+
+
+class TestInstanceFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = models.TestInstance
+
+ test = factory.SubFactory(TestFactory)
+ duration_seconds = 1.0
+ outcome = TestInstance.Outcome.FAILURE.value
+ failure_message = "Test failed"
+ branch = "master"
+ repoid = factory.SelfAttribute("test.repository.repoid")
+ commitid = "123456"
+ upload = factory.SubFactory(UploadFactory)
+
+
+class DailyTestRollupFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = models.DailyTestRollup
+
+ test = factory.SubFactory(TestFactory)
+
+ repoid = factory.SelfAttribute("test.repository.repoid")
+ branch = "main"
+
+ last_duration_seconds = 1.0
+ avg_duration_seconds = 0.5
+ pass_count = 1
+ skip_count = 2
+ fail_count = 3
+ flaky_fail_count = 0
+
+ latest_run = datetime.now()
+ date = date.today()
+
+ commits_where_fail = ["123", "456", "789"]
+
+
+class TestFlagBridgeFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = models.TestFlagBridge
+
+ test = factory.SubFactory(TestFactory)
+ flag = factory.SubFactory(RepositoryFlagFactory)
diff --git a/apps/codecov-api/rollouts/__init__.py b/apps/codecov-api/rollouts/__init__.py
new file mode 100644
index 0000000000..7f46f49555
--- /dev/null
+++ b/apps/codecov-api/rollouts/__init__.py
@@ -0,0 +1,13 @@
+from shared.rollouts import Feature
+
+from codecov_auth.models import Owner
+
+
+def owner_slug(owner: Owner) -> str:
+ return f"{owner.service}/{owner.username}"
+
+
+__all__ = ["Feature"]
+
+# By default, features have one variant:
+# { "enabled": FeatureVariant(True, 1.0) }
diff --git a/apps/codecov-api/ruff.toml b/apps/codecov-api/ruff.toml
new file mode 100644
index 0000000000..872bae8c14
--- /dev/null
+++ b/apps/codecov-api/ruff.toml
@@ -0,0 +1,74 @@
+# Exclude a variety of commonly ignored directories.
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+]
+
+# Same as Black.
+line-length = 88
+indent-width = 4
+
+# Assume Python 3.13
+target-version = "py313"
+
+[lint]
+# https://docs.astral.sh/ruff/rules/
+select = [
+ "ASYNC", # flake8-async - async checks
+ "C4", # flake8-comprehensions - list/set/dict/generator comprehensions
+ "E", # pycodestyle - error rules
+ "F", # pyflakes - general Python errors, undefined names
+ "I", # isort - import sorting
+ "PERF", # perflint - performance anti-pattern rules
+ "PLC", # pylint - convention rules
+ "PLE", # pylint - error rules
+ "T20", # flake8-print - print statements
+ "W", # pycodestyle - warning rules
+]
+ignore = ["F405", "F403", "E501", "E712", "C408"]
+
+# Allow fix for all enabled rules (when `--fix`) is provided.
+# The preferred method (for now) w.r.t. fixable rules is to manually update the makefile
+# with --fix and re-run 'make lint_local'
+fixable = ["ALL"]
+unfixable = []
+
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+[format]
+# Like Black, use double quotes for strings.
+quote-style = "double"
+
+# Like Black, indent with spaces, rather than tabs.
+indent-style = "space"
+
+# Like Black, respect magic trailing commas.
+skip-magic-trailing-comma = false
+
+# Like Black, automatically detect the appropriate line ending.
+line-ending = "auto"
diff --git a/apps/codecov-api/services/__init__.py b/apps/codecov-api/services/__init__.py
new file mode 100644
index 0000000000..9061d9ec29
--- /dev/null
+++ b/apps/codecov-api/services/__init__.py
@@ -0,0 +1,2 @@
+class ServiceException(Exception):
+ pass
diff --git a/apps/codecov-api/services/activation.py b/apps/codecov-api/services/activation.py
new file mode 100644
index 0000000000..9c229c7625
--- /dev/null
+++ b/apps/codecov-api/services/activation.py
@@ -0,0 +1,96 @@
+import abc
+import logging
+
+from django.conf import settings
+
+import services.self_hosted as self_hosted
+from codecov_auth.models import Owner
+
+log = logging.getLogger(__name__)
+
+
+class BaseActivator(abc.ABC):
+ def __init__(self, org: Owner, owner: Owner):
+ self.org = org
+ self.owner = owner
+
+ @abc.abstractmethod
+ def is_autoactivation_enabled(self) -> bool:
+ pass
+
+ @abc.abstractmethod
+ def can_activate_user(self) -> bool:
+ pass
+
+ @abc.abstractmethod
+ def activate_user(self):
+ pass
+
+ @abc.abstractmethod
+ def is_activated(self) -> bool:
+ pass
+
+
+class CloudActivator(BaseActivator):
+ def is_autoactivation_enabled(self) -> bool:
+ return self.org.plan_auto_activate
+
+ def can_activate_user(self) -> bool:
+ return self.org.can_activate_user(self.owner)
+
+ def activate_user(self):
+ self.org.activate_user(self.owner)
+
+ def is_activated(self) -> bool:
+ if not self.org.plan_activated_users:
+ return False
+
+ return self.owner.pk in self.org.plan_activated_users
+
+
+class SelfHostedActivator(BaseActivator):
+ def is_autoactivation_enabled(self) -> bool:
+ return self_hosted.is_autoactivation_enabled()
+
+ def can_activate_user(self) -> bool:
+ return self_hosted.can_activate_owner(self.owner)
+
+ def activate_user(self):
+ return self_hosted.activate_owner(self.owner)
+
+ def is_activated(self) -> bool:
+ return self_hosted.is_activated_owner(self.owner)
+
+
+def _get_activator(org: Owner, owner: Owner) -> BaseActivator:
+ if settings.IS_ENTERPRISE:
+ return SelfHostedActivator(org, owner)
+ else:
+ return CloudActivator(org, owner)
+
+
+def try_auto_activate(org: Owner, owner: Owner) -> bool:
+ """
+ Returns true if the user was able to be activated, false otherwise.
+ """
+ activator = _get_activator(org, owner)
+
+ if activator.is_autoactivation_enabled():
+ log.info(
+ "Attemping to auto-activate user",
+ extra=dict(owner_id=owner.ownerid, org_id=org.ownerid),
+ )
+ if activator.can_activate_user():
+ activator.activate_user()
+ return True
+ else:
+ log.info(
+ "Auto-activation failed -- not enough seats remaining",
+ extra=dict(owner_id=owner.ownerid, org_id=org.ownerid),
+ )
+ return False
+
+
+def is_activated(org: Owner, owner: Owner) -> bool:
+ activator = _get_activator(org, owner)
+ return activator.is_activated()
diff --git a/apps/codecov-api/services/analytics.py b/apps/codecov-api/services/analytics.py
new file mode 100644
index 0000000000..0039c28957
--- /dev/null
+++ b/apps/codecov-api/services/analytics.py
@@ -0,0 +1,210 @@
+import logging
+import re
+
+from django.conf import settings
+from shared.analytics_tracking import analytics_manager
+from shared.analytics_tracking.events import Events
+
+log = logging.getLogger(__name__)
+
+
+def inject_analytics_owner(method):
+ """
+ Decorator: promotes type of 'owner' arg to 'AnalyticsOwner'.
+ """
+
+ def exec_method(self, owner, **kwargs):
+ analytics_owner = AnalyticsOwner(owner, cookies=kwargs.get("cookies", {}))
+ return method(self, analytics_owner, **kwargs)
+
+ return exec_method
+
+
+def inject_analytics_repository(method):
+ """
+ Decorator: promotes type of second parameter (a repository) to AnalyticsRepository
+ """
+
+ def exec_method(self, ownerid, repository):
+ analytics_repository = AnalyticsRepository(repository)
+ return method(self, ownerid, analytics_repository)
+
+ return exec_method
+
+
+class AnalyticsOwner:
+ """
+ An object wrapper around 'Owner' that provides "user_id", "traits",
+ and "context" properties.
+ """
+
+ def __init__(self, owner, cookies={}, owner_collection_type="users"):
+ self.owner = owner
+ self.cookies = cookies
+ self.owner_collection_type = owner_collection_type
+
+ @property
+ def user_id(self):
+ return self.owner.ownerid
+
+ @property
+ def traits(self):
+ return {
+ "service": self.owner.service,
+ "service_id": self.owner.service_id,
+ "plan": self.owner.plan,
+ "email": self.owner.email or "unknown@codecov.io",
+ "username": self.owner.username or "unknown",
+ "owner_id": self.owner.ownerid,
+ "user_id": self.owner.ownerid,
+ }
+
+ @property
+ def context(self):
+ """
+ Mostly copied from
+ https://github.com/codecov/codecov.io/blob/master/app/services/analytics_tracking.py#L107
+ """
+ context = {"externalIds": []}
+
+ context["externalIds"].append(
+ {
+ "id": self.owner.service_id,
+ "type": f"{self.owner.service}_id",
+ "collection": self.owner_collection_type,
+ "encoding": "none",
+ }
+ )
+
+ if self.owner.stripe_customer_id:
+ context["externalIds"].append(
+ {
+ "id": self.owner.stripe_customer_id,
+ "type": "stripe_customer_id",
+ "collection": self.owner_collection_type,
+ "encoding": "none",
+ }
+ )
+
+ if self.cookies and self.owner_collection_type == "users":
+ marketo_cookie = self.cookies.get("_mkto_trk")
+ ga_cookie = self.cookies.get("_ga")
+ if marketo_cookie:
+ context["externalIds"].append(
+ {
+ "id": marketo_cookie,
+ "type": "marketo_cookie",
+ "collection": "users",
+ "encoding": "none",
+ }
+ )
+ context["Marketo"] = {"marketo_cookie": marketo_cookie}
+ if ga_cookie:
+ # id is everything after the "GA.1." prefix
+ match = re.match(r"^.+\.(.+?\..+?)$", ga_cookie)
+ if match:
+ ga_client_id = match.group(1)
+ context["externalIds"].append(
+ {
+ "id": ga_client_id,
+ "type": "ga_client_id",
+ "collection": "users",
+ "encoding": "none",
+ }
+ )
+
+ return context
+
+
+class AnalyticsRepository:
+ """
+ Wrapper object around Repository to provide a similar "traits" field as in AnalyticsOwner above.
+ """
+
+ def __init__(self, repo):
+ self.repo = repo
+
+ @property
+ def traits(self):
+ return {
+ "repoid": self.repo.repoid,
+ "ownerid": self.repo.author.ownerid,
+ "service_id": self.repo.service_id,
+ "name": self.repo.name,
+ "private": self.repo.private,
+ "branch": self.repo.branch,
+ "updatestamp": self.repo.updatestamp,
+ "language": self.repo.language,
+ "active": self.repo.active,
+ "deleted": self.repo.deleted,
+ "activated": self.repo.activated,
+ "bot": self.repo.bot,
+ "using_integration": self.repo.using_integration,
+ "hookid": self.repo.hookid,
+ "has_yaml": self.repo.yaml is not None,
+ }
+
+
+class AnalyticsService:
+ """
+ Various methods for emitting events related to user actions.
+ """
+
+ @inject_analytics_owner
+ def user_signed_up(self, analytics_owner, **kwargs):
+ analytics_manager.track_event(
+ Events.USER_SIGNED_UP.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=analytics_owner.traits,
+ )
+
+ @inject_analytics_owner
+ def user_signed_in(self, analytics_owner, **kwargs):
+ analytics_manager.track_event(
+ Events.USER_SIGNED_IN.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=analytics_owner.traits,
+ )
+
+ @inject_analytics_repository
+ def account_activated_repository(self, current_user_ownerid, analytics_repository):
+ event_data = {
+ **analytics_repository.traits,
+ "user_id": current_user_ownerid,
+ }
+ analytics_manager.track_event(
+ Events.ACCOUNT_ACTIVATED_REPOSITORY.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=event_data,
+ context={"groupId": analytics_repository.repo.author.ownerid},
+ )
+
+ @inject_analytics_repository
+ def account_activated_repository_on_upload(self, org_ownerid, analytics_repository):
+ event_data = {
+ **analytics_repository.traits,
+ "user_id": org_ownerid,
+ }
+ analytics_manager.track_event(
+ Events.ACCOUNT_ACTIVATED_REPOSITORY_ON_UPLOAD.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=event_data,
+ context={"groupId": org_ownerid},
+ )
+
+ def account_uploaded_coverage_report(self, org_ownerid, upload_details):
+ upload_details = {**upload_details, "user_id": org_ownerid}
+ analytics_manager.track_event(
+ Events.ACCOUNT_UPLOADED_COVERAGE_REPORT.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=upload_details,
+ context={"groupId": org_ownerid},
+ )
+
+ def opt_in_email(self, user_id, data: dict):
+ data = {**data, "user_id": user_id}
+ analytics_manager.track_event(
+ Events.GDPR_OPT_IN.value,
+ is_enterprise=settings.IS_ENTERPRISE,
+ event_data=data,
+ )
diff --git a/apps/codecov-api/services/billing.py b/apps/codecov-api/services/billing.py
new file mode 100644
index 0000000000..d945b2e85d
--- /dev/null
+++ b/apps/codecov-api/services/billing.py
@@ -0,0 +1,1076 @@
+import logging
+import re
+from abc import ABC, abstractmethod
+from datetime import datetime, timezone
+
+import stripe
+from dateutil.relativedelta import relativedelta
+from django.conf import settings
+from shared.plan.constants import PlanBillingRate, TierName
+from shared.plan.service import PlanService
+
+from billing.constants import REMOVED_INVOICE_STATUSES
+from codecov_auth.models import Owner, Plan
+
+log = logging.getLogger(__name__)
+
+SCHEDULE_RELEASE_OFFSET = 10
+
+if settings.STRIPE_API_KEY:
+ stripe.api_key = settings.STRIPE_API_KEY
+ stripe.api_version = "2024-12-18.acacia"
+
+
+def _log_stripe_error(method):
+ def catch_and_raise(*args, **kwargs):
+ try:
+ return method(*args, **kwargs)
+ except stripe.StripeError as e:
+ log.warning(f"StripeError raised: {e.user_message}")
+ raise
+
+ return catch_and_raise
+
+
+class AbstractPaymentService(ABC):
+ @abstractmethod
+ def get_invoice(self, owner, invoice_id):
+ pass
+
+ @abstractmethod
+ def list_filtered_invoices(self, owner, limit=10):
+ pass
+
+ @abstractmethod
+ def delete_subscription(self, owner):
+ pass
+
+ @abstractmethod
+ def modify_subscription(self, owner, plan):
+ pass
+
+ @abstractmethod
+ def create_checkout_session(self, owner, plan):
+ pass
+
+ @abstractmethod
+ def get_subscription(self, owner):
+ pass
+
+ @abstractmethod
+ def update_payment_method(self, owner, payment_method):
+ pass
+
+ @abstractmethod
+ def update_email_address(self, owner, email_address):
+ pass
+
+ @abstractmethod
+ def update_billing_address(self, owner, name, billing_address):
+ pass
+
+ @abstractmethod
+ def get_schedule(self, owner):
+ pass
+
+ @abstractmethod
+ def apply_cancellation_discount(self, owner: Owner):
+ pass
+
+ @abstractmethod
+ def create_setup_intent(self, owner):
+ pass
+
+
+class StripeService(AbstractPaymentService):
+ def __init__(self, requesting_user):
+ if settings.STRIPE_API_KEY is None:
+ log.critical(
+ "Missing stripe API key configuration -- communication with stripe won't be possible."
+ )
+ if not isinstance(requesting_user, Owner):
+ raise Exception(
+ "StripeService requires requesting_user to be Owner instance"
+ )
+
+ self.requesting_user = requesting_user
+
+ def _get_checkout_session_and_subscription_metadata(self, owner: Owner):
+ return {
+ "service": owner.service,
+ "obo_organization": owner.ownerid,
+ "username": owner.username,
+ "obo_name": self.requesting_user.name,
+ "obo_email": self.requesting_user.email,
+ "obo": self.requesting_user.ownerid,
+ }
+
+ @_log_stripe_error
+ def get_invoice(self, owner, invoice_id):
+ log.info(
+ f"Fetching invoice {invoice_id} from Stripe for ownerid {owner.ownerid}"
+ )
+ try:
+ invoice = stripe.Invoice.retrieve(invoice_id)
+ except stripe.InvalidRequestError:
+ log.info(f"invoice {invoice_id} not found for owner {owner.ownerid}")
+ return None
+ if invoice["customer"] != owner.stripe_customer_id:
+ log.info(
+ f"customer id ({invoice['customer']}) on invoice does not match the owner customer id ({owner.stripe_customer_id})"
+ )
+ return None
+ return invoice
+
+ def filter_invoices_by_status(self, invoice):
+ if invoice["status"] and invoice["status"] not in REMOVED_INVOICE_STATUSES:
+ return invoice
+
+ def filter_invoices_by_total(self, invoice):
+ if invoice["total"] and invoice["total"] != 0:
+ return invoice
+
+ @_log_stripe_error
+ def list_filtered_invoices(self, owner: Owner, limit=10):
+ log.info(f"Fetching invoices from Stripe for ownerid {owner.ownerid}")
+ if owner.stripe_customer_id is None:
+ log.info("stripe_customer_id is None, not fetching invoices")
+ return []
+ invoices = stripe.Invoice.list(customer=owner.stripe_customer_id, limit=limit)[
+ "data"
+ ]
+
+ invoices_filtered_by_status = filter(self.filter_invoices_by_status, invoices)
+ invoices_filtered_by_status_and_total = filter(
+ self.filter_invoices_by_total, invoices_filtered_by_status
+ )
+ return list(invoices_filtered_by_status_and_total)
+
+ def cancel_and_refund(
+ self,
+ owner,
+ current_subscription_datetime,
+ subscription_plan_interval,
+ autorefunds_remaining,
+ ):
+ # cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
+ stripe.Subscription.cancel(owner.stripe_subscription_id)
+
+ start_of_last_period = current_subscription_datetime - relativedelta(months=1)
+ invoice_grace_period_start = current_subscription_datetime - relativedelta(
+ days=1
+ )
+
+ if subscription_plan_interval == "year":
+ start_of_last_period = current_subscription_datetime - relativedelta(
+ years=1
+ )
+ invoice_grace_period_start = current_subscription_datetime - relativedelta(
+ days=3
+ )
+
+ invoices_list = stripe.Invoice.list(
+ subscription=owner.stripe_subscription_id,
+ status="paid",
+ created={
+ "gte": int(start_of_last_period.timestamp()),
+ "lt": int(current_subscription_datetime.timestamp()),
+ },
+ )
+
+ # we only want to refund the invoices PAID recently for the latest, current period. "invoices_list" gives us any invoice
+ # created over the last month/year based on what period length they are on but the customer could have possibly
+ # switched from monthly to yearly recently.
+ recently_paid_invoices_list = [
+ invoice
+ for invoice in invoices_list["data"]
+ if invoice["status_transitions"]["paid_at"] is not None
+ and invoice["status_transitions"]["paid_at"]
+ >= int(invoice_grace_period_start.timestamp())
+ ]
+
+ created_refund = False
+ # there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period
+ for invoice in recently_paid_invoices_list:
+ # refund if the invoice has a charge and it has been fully paid
+ if invoice["charge"] is not None and invoice["amount_remaining"] == 0:
+ stripe.Refund.create(invoice["charge"])
+ created_refund = True
+
+ if created_refund:
+ # update the customer's balance back to 0 in accordance to
+ # https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
+ stripe.Customer.modify(
+ owner.stripe_customer_id,
+ balance=0,
+ metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
+ )
+ log.info(
+ "Grace period cancelled a subscription and autorefunded associated invoices",
+ extra=dict(
+ owner_id=owner.ownerid,
+ user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ autorefunds_remaining=autorefunds_remaining - 1,
+ ),
+ )
+ else:
+ log.info(
+ "Grace period cancelled a subscription but did not find any appropriate invoices to autorefund",
+ extra=dict(
+ owner_id=owner.ownerid,
+ user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ autorefunds_remaining=autorefunds_remaining,
+ ),
+ )
+
+ @_log_stripe_error
+ def delete_subscription(self, owner: Owner):
+ subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
+ subscription_schedule_id = subscription.schedule
+
+ log.info(
+ f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
+ extra=dict(ownerid=owner.ownerid),
+ )
+
+ if subscription_schedule_id:
+ log.info(
+ f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
+ extra=dict(ownerid=owner.ownerid),
+ )
+ stripe.SubscriptionSchedule.release(subscription_schedule_id)
+
+ # we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
+ current_subscription_datetime = datetime.fromtimestamp(
+ subscription["current_period_start"], tz=timezone.utc
+ )
+ difference_from_now = datetime.now(timezone.utc) - current_subscription_datetime
+
+ subscription_plan_interval = getattr(
+ getattr(subscription, "plan", None), "interval", None
+ )
+ within_refund_grace_period = (
+ subscription_plan_interval == "month" and difference_from_now.days < 1
+ ) or (subscription_plan_interval == "year" and difference_from_now.days < 3)
+
+ if within_refund_grace_period:
+ customer = stripe.Customer.retrieve(owner.stripe_customer_id)
+ # we are currently allowing customers 2 autorefund instances
+ autorefunds_remaining = int(
+ customer["metadata"].get("autorefunds_remaining", "2")
+ )
+ log.info(
+ "Deleting subscription with attempted immediate cancellation with autorefund within grace period",
+ extra=dict(
+ owner_id=owner.ownerid,
+ user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ autorefunds_remaining=autorefunds_remaining,
+ ),
+ )
+ if autorefunds_remaining > 0:
+ return self.cancel_and_refund(
+ owner,
+ current_subscription_datetime,
+ subscription_plan_interval,
+ autorefunds_remaining,
+ )
+
+ # schedule a cancellation at the end of the paid period with no refund
+ stripe.Subscription.modify(
+ owner.stripe_subscription_id,
+ cancel_at_period_end=True,
+ proration_behavior="none",
+ )
+
+ @_log_stripe_error
+ def get_subscription(self, owner: Owner):
+ if not owner.stripe_subscription_id:
+ return None
+ return stripe.Subscription.retrieve(
+ owner.stripe_subscription_id,
+ expand=[
+ "latest_invoice",
+ "customer",
+ "customer.invoice_settings.default_payment_method",
+ "customer.tax_ids",
+ ],
+ )
+
+ @_log_stripe_error
+ def get_schedule(self, owner: Owner):
+ if not owner.stripe_subscription_id:
+ return None
+
+ subscription = self.get_subscription(owner)
+ subscription_schedule_id = subscription.schedule
+
+ if not subscription_schedule_id:
+ return None
+
+ return stripe.SubscriptionSchedule.retrieve(subscription_schedule_id)
+
+ @_log_stripe_error
+ def modify_subscription(self, owner: Owner, desired_plan: dict):
+ desired_plan_info = Plan.objects.filter(name=desired_plan["value"]).first()
+ if not desired_plan_info:
+ log.error(
+ f"Plan {desired_plan['value']} not found",
+ extra=dict(owner_id=owner.ownerid),
+ )
+ return
+
+ subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
+ proration_behavior = self._get_proration_params(
+ owner,
+ desired_plan_info=desired_plan_info,
+ desired_quantity=desired_plan["quantity"],
+ )
+ subscription_schedule_id = subscription.schedule
+
+ # proration_behavior indicates whether we immediately invoice a user or not. We only immediately
+ # invoice a user if the user increases the number of seats or if the plan changes from monthly to yearly.
+ # An increase in seats and/or plan implies the user is upgrading, hence 'is_upgrading' is a consequence
+ # of proration_behavior providing an invoice, in this case, != "none"
+ # TODO: change this to "self._is_upgrading_seats(owner, desired_plan) or self._is_extending_term(owner, desired_plan)"
+ is_upgrading = (
+ True
+ if proration_behavior != "none" and desired_plan_info.stripe_id
+ else False
+ )
+
+ # Divide logic bw immediate updates and scheduled updates
+ # Immediate updates: when user upgrades seats or plan
+ # If the user is not in a schedule, update immediately
+ # If the user is in a schedule, update the existing schedule
+ # Scheduled updates: when the user decreases seats or plan
+ # If the user is not in a schedule, create a schedule
+ # If the user is in a schedule, update the existing schedule
+ if is_upgrading:
+ if subscription_schedule_id:
+ log.info(
+ f"Releasing Stripe schedule for owner {owner.ownerid} to {desired_plan['value']} with {desired_plan['quantity']} seats by user #{self.requesting_user.ownerid}"
+ )
+ stripe.SubscriptionSchedule.release(subscription_schedule_id)
+ log.info(
+ f"Updating Stripe subscription for owner {owner.ownerid} to {desired_plan['value']} by user #{self.requesting_user.ownerid}"
+ )
+
+ subscription = stripe.Subscription.modify(
+ owner.stripe_subscription_id,
+ cancel_at_period_end=False,
+ items=[
+ {
+ "id": subscription["items"]["data"][0]["id"],
+ "plan": desired_plan_info.stripe_id,
+ "quantity": desired_plan["quantity"],
+ }
+ ],
+ metadata=self._get_checkout_session_and_subscription_metadata(owner),
+ proration_behavior=proration_behavior,
+ # TODO: we need to include this arg, but it means we need to remove some of the existing args
+ # on the .modify() call https://docs.stripe.com/billing/subscriptions/pending-updates-reference
+ # payment_behavior="pending_if_incomplete",
+ )
+ log.info(
+ f"Stripe subscription upgrade attempted for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
+ )
+ indication_of_payment_failure = getattr(
+ subscription, "pending_update", None
+ )
+ if indication_of_payment_failure:
+ # payment failed, raise this to user by setting as delinquent
+ owner.delinquent = True
+ owner.save()
+ log.info(
+ f"Stripe subscription upgrade failed for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
+ extra=dict(pending_update=indication_of_payment_failure),
+ )
+ else:
+ # payment successful
+ plan_service = PlanService(current_org=owner)
+ plan_service.update_plan(
+ name=desired_plan["value"], user_count=desired_plan["quantity"]
+ )
+ log.info(
+ f"Stripe subscription upgraded successfully for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
+ )
+ else:
+ if not subscription_schedule_id:
+ schedule = stripe.SubscriptionSchedule.create(
+ from_subscription=owner.stripe_subscription_id
+ )
+ subscription_schedule_id = schedule.id
+ self._modify_subscription_schedule(
+ owner, subscription, subscription_schedule_id, desired_plan
+ )
+
+ def _modify_subscription_schedule(
+ self,
+ owner: Owner,
+ subscription: stripe.Subscription,
+ subscription_schedule_id: str,
+ desired_plan: dict,
+ ):
+ current_subscription_start_date = subscription["current_period_start"]
+ current_subscription_end_date = subscription["current_period_end"]
+
+ subscription_item = subscription["items"]["data"][0]
+ current_plan = subscription_item["plan"]["id"]
+ current_quantity = subscription_item["quantity"]
+
+ plan = Plan.objects.filter(name=desired_plan["value"]).first()
+ if not plan or not plan.stripe_id:
+ log.error(
+ f"Plan {desired_plan['value']} not found",
+ extra=dict(owner_id=owner.ownerid),
+ )
+ return
+
+ stripe.SubscriptionSchedule.modify(
+ subscription_schedule_id,
+ end_behavior="release",
+ phases=[
+ {
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "items": [
+ {
+ "plan": current_plan,
+ "price": current_plan,
+ "quantity": current_quantity,
+ }
+ ],
+ "proration_behavior": "none",
+ },
+ {
+ "start_date": current_subscription_end_date,
+ "end_date": current_subscription_end_date + SCHEDULE_RELEASE_OFFSET,
+ "items": [
+ {
+ "plan": plan.stripe_id,
+ "price": plan.stripe_id,
+ "quantity": desired_plan["quantity"],
+ }
+ ],
+ "proration_behavior": "none",
+ },
+ ],
+ metadata=self._get_checkout_session_and_subscription_metadata(owner),
+ )
+
+ def _is_upgrading_seats(self, owner: Owner, desired_quantity: int) -> bool:
+ """
+ Returns `True` if purchasing more seats.
+ """
+ return bool(owner.plan_user_count and owner.plan_user_count < desired_quantity)
+
+ def _is_extending_term(
+ self, current_plan_info: Plan, desired_plan_info: Plan
+ ) -> bool:
+ """
+ Returns `True` if switching from monthly to yearly plan.
+ """
+
+ return bool(
+ current_plan_info
+ and current_plan_info.billing_rate == PlanBillingRate.MONTHLY.value
+ and desired_plan_info
+ and desired_plan_info.billing_rate == PlanBillingRate.YEARLY.value
+ )
+
+ def _is_similar_plan(
+ self,
+ owner: Owner,
+ current_plan_info: Plan,
+ desired_plan_info: Plan,
+ desired_quantity: int,
+ ) -> bool:
+ """
+ Returns `True` if switching to a plan with similar term and seats.
+ """
+ is_same_term = (
+ current_plan_info
+ and desired_plan_info
+ and current_plan_info.billing_rate == desired_plan_info.billing_rate
+ )
+
+ is_same_seats = (
+ owner.plan_user_count and owner.plan_user_count == desired_quantity
+ )
+ # If from PRO to TEAM, then not a similar plan
+ if (
+ current_plan_info.tier.tier_name != TierName.TEAM.value
+ and desired_plan_info.tier.tier_name == TierName.TEAM.value
+ ):
+ return False
+ # If from TEAM to PRO, then considered a similar plan but really is an upgrade
+ elif (
+ current_plan_info.tier.tier_name == TierName.TEAM.value
+ and desired_plan_info.tier.tier_name != TierName.TEAM.value
+ ):
+ return True
+
+ return bool(is_same_term and is_same_seats)
+
+ def _get_proration_params(
+ self, owner: Owner, desired_plan_info: Plan, desired_quantity: int
+ ) -> str:
+ current_plan_info = Plan.objects.select_related("tier").get(name=owner.plan)
+ if (
+ self._is_upgrading_seats(owner=owner, desired_quantity=desired_quantity)
+ or self._is_extending_term(
+ current_plan_info=current_plan_info, desired_plan_info=desired_plan_info
+ )
+ or self._is_similar_plan(
+ owner=owner,
+ current_plan_info=current_plan_info,
+ desired_plan_info=desired_plan_info,
+ desired_quantity=desired_quantity,
+ )
+ ):
+ return "always_invoice"
+ else:
+ return "none"
+
+ def _get_success_and_cancel_url(self, owner: Owner):
+ short_services = {"github": "gh", "bitbucket": "bb", "gitlab": "gl"}
+ base_path = f"/plan/{short_services[owner.service]}/{owner.username}"
+ success_url = f"{settings.CODECOV_DASHBOARD_URL}{base_path}?success"
+ cancel_url = f"{settings.CODECOV_DASHBOARD_URL}{base_path}?cancel"
+ return success_url, cancel_url
+
+ @_log_stripe_error
+ def create_checkout_session(self, owner: Owner, desired_plan: dict):
+ success_url, cancel_url = self._get_success_and_cancel_url(owner)
+ log.info(
+ "Creating Stripe Checkout Session for owner",
+ extra=dict(owner_id=owner.ownerid),
+ )
+
+ plan = Plan.objects.filter(name=desired_plan["value"]).first()
+ if not plan or not plan.stripe_id:
+ log.error(
+ f"Plan {desired_plan['value']} not found",
+ extra=dict(owner_id=owner.ownerid),
+ )
+ return
+
+ session = stripe.checkout.Session.create(
+ payment_method_configuration=settings.STRIPE_PAYMENT_METHOD_CONFIGURATION_ID,
+ billing_address_collection="required",
+ payment_method_collection="if_required",
+ client_reference_id=str(owner.ownerid),
+ success_url=success_url,
+ cancel_url=cancel_url,
+ customer=owner.stripe_customer_id,
+ mode="subscription",
+ line_items=[
+ {
+ "price": plan.stripe_id,
+ "quantity": desired_plan["quantity"],
+ }
+ ],
+ subscription_data={
+ "metadata": self._get_checkout_session_and_subscription_metadata(owner),
+ },
+ tax_id_collection={"enabled": True},
+ customer_update=(
+ {"name": "auto", "address": "auto"}
+ if owner.stripe_customer_id
+ else None
+ ),
+ )
+ log.info(
+ f"Stripe Checkout Session created successfully for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
+ )
+ return session["id"]
+
+ def _is_unverified_payment_method(self, payment_method_id: str) -> bool:
+ payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
+
+ is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(
+ payment_method, "us_bank_account"
+ )
+ if is_us_bank_account:
+ setup_intents = stripe.SetupIntent.list(
+ payment_method=payment_method_id, limit=1
+ )
+
+ try:
+ latest_intent = setup_intents.data[0]
+ if (
+ latest_intent.status == "requires_action"
+ and latest_intent.next_action
+ and latest_intent.next_action.type == "verify_with_microdeposits"
+ ):
+ return True
+ except Exception as e:
+ log.error(
+ "Error retrieving latest setup intent",
+ payment_method_id=payment_method_id,
+ extra=dict(error=e),
+ )
+ return False
+
+ return False
+
+ @_log_stripe_error
+ def update_payment_method(self, owner: Owner, payment_method: str) -> None:
+ log.info(
+ "Stripe update payment method for owner",
+ extra=dict(
+ owner_id=owner.ownerid,
+ user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ ),
+ )
+ if owner.stripe_subscription_id is None or owner.stripe_customer_id is None:
+ log.warn(
+ "Missing subscription or customer id, returning early",
+ extra=dict(
+ owner_id=owner.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ ),
+ )
+ return None
+
+ # do not set as default if the new payment method is unverified (e.g., awaiting microdeposits)
+ should_set_as_default = not self._is_unverified_payment_method(payment_method)
+
+ if should_set_as_default:
+ stripe.PaymentMethod.attach(
+ payment_method, customer=owner.stripe_customer_id
+ )
+ stripe.Customer.modify(
+ owner.stripe_customer_id,
+ invoice_settings={"default_payment_method": payment_method},
+ )
+ stripe.Subscription.modify(
+ owner.stripe_subscription_id, default_payment_method=payment_method
+ )
+ log.info(
+ f"Successfully updated payment method for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
+ extra=dict(
+ owner_id=owner.ownerid,
+ user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ ),
+ )
+
+ @_log_stripe_error
+ def update_email_address(
+ self,
+ owner: Owner,
+ email_address: str,
+ apply_to_default_payment_method: bool = False,
+ ):
+ if not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", email_address):
+ return None
+
+ log.info(f"Stripe update email address for owner {owner.ownerid}")
+ if owner.stripe_subscription_id is None:
+ log.info(
+ f"stripe_subscription_id is None, not updating stripe email for owner {owner.ownerid}"
+ )
+ return None
+ stripe.Customer.modify(owner.stripe_customer_id, email=email_address)
+ log.info(
+ f"Stripe successfully updated email address for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
+ )
+
+ if apply_to_default_payment_method:
+ try:
+ default_payment_method = stripe.Customer.retrieve(
+ owner.stripe_customer_id
+ )["invoice_settings"]["default_payment_method"]
+
+ stripe.PaymentMethod.modify(
+ default_payment_method,
+ billing_details={"email": email_address},
+ )
+ log.info(
+ "Stripe successfully updated billing email for payment method",
+ extra=dict(
+ payment_method=default_payment_method,
+ stripe_customer_id=owner.stripe_customer_id,
+ ownerid=owner.ownerid,
+ ),
+ )
+ except Exception as e:
+ log.error(
+ "Unable to update billing email for payment method",
+ extra=dict(
+ payment_method=default_payment_method,
+ stripe_customer_id=owner.stripe_customer_id,
+ error=str(e),
+ ownerid=owner.ownerid,
+ ),
+ )
+
+ @_log_stripe_error
+ def update_billing_address(self, owner: Owner, name, billing_address):
+ log.info(f"Stripe update billing address for owner {owner.ownerid}")
+ if owner.stripe_customer_id is None:
+ log.info(
+ f"stripe_customer_id is None, cannot update default billing address for owner {owner.ownerid}"
+ )
+ return None
+
+ try:
+ default_payment_method = stripe.Customer.retrieve(
+ owner.stripe_customer_id
+ ).invoice_settings.default_payment_method
+
+ stripe.PaymentMethod.modify(
+ default_payment_method,
+ billing_details={"name": name, "address": billing_address},
+ )
+
+ stripe.Customer.modify(owner.stripe_customer_id, address=billing_address)
+ log.info(
+ f"Stripe successfully updated billing address for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
+ )
+ except Exception:
+ log.error(
+ "Unable to update billing address for customer",
+ extra=dict(
+ customer_id=owner.stripe_customer_id,
+ subscription_id=owner.stripe_subscription_id,
+ ),
+ )
+
+ @_log_stripe_error
+ def apply_cancellation_discount(self, owner: Owner):
+ if owner.stripe_subscription_id is None:
+ log.info(
+ f"stripe_subscription_id is None, not applying cancellation coupon for owner {owner.ownerid}"
+ )
+ return
+ plan_service = PlanService(current_org=owner)
+ billing_rate = plan_service.billing_rate
+
+ if billing_rate == PlanBillingRate.MONTHLY.value and not owner.stripe_coupon_id:
+ log.info(f"Creating Stripe cancellation coupon for owner {owner.ownerid}")
+ coupon = stripe.Coupon.create(
+ percent_off=30.0,
+ duration="repeating",
+ duration_in_months=6,
+ name="30% off for 6 months",
+ max_redemptions=1,
+ metadata={
+ "ownerid": owner.ownerid,
+ "username": owner.username,
+ "email": owner.email,
+ "name": owner.name,
+ },
+ )
+
+ owner.stripe_coupon_id = coupon.id
+ owner.save()
+
+ log.info(
+ f"Applying cancellation coupon to Stripe subscription for owner {owner.ownerid}"
+ )
+ stripe.Customer.modify(
+ owner.stripe_customer_id,
+ coupon=owner.stripe_coupon_id,
+ )
+
+ @_log_stripe_error
+ def create_setup_intent(self, owner: Owner) -> stripe.SetupIntent:
+ log.info(
+ "Stripe create setup intent for owner",
+ extra=dict(
+ owner_id=owner.ownerid,
+ requesting_user_id=self.requesting_user.ownerid,
+ subscription_id=owner.stripe_subscription_id,
+ customer_id=owner.stripe_customer_id,
+ ),
+ )
+ return stripe.SetupIntent.create(
+ payment_method_configuration=settings.STRIPE_PAYMENT_METHOD_CONFIGURATION_ID,
+ customer=owner.stripe_customer_id,
+ )
+
+ @_log_stripe_error
+ def get_unverified_payment_methods(self, owner: Owner):
+ log.info(
+ "Getting unverified payment methods",
+ extra=dict(
+ owner_id=owner.ownerid, stripe_customer_id=owner.stripe_customer_id
+ ),
+ )
+ if not owner.stripe_customer_id:
+ return []
+
+ unverified_payment_methods = []
+
+ # Check payment intents
+ has_more = True
+ starting_after = None
+ while has_more:
+ payment_intents = stripe.PaymentIntent.list(
+ customer=owner.stripe_customer_id,
+ limit=20,
+ starting_after=starting_after,
+ )
+ for intent in payment_intents.data or []:
+ if (
+ intent.get("next_action")
+ and intent.next_action
+ and intent.next_action.get("type") == "verify_with_microdeposits"
+ ):
+ unverified_payment_methods.extend(
+ [
+ {
+ "payment_method_id": intent.payment_method,
+ "hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
+ }
+ ]
+ )
+ has_more = payment_intents.has_more
+ if has_more and payment_intents.data:
+ starting_after = payment_intents.data[-1].id
+
+ # Check setup intents
+ has_more = True
+ starting_after = None
+ while has_more:
+ setup_intents = stripe.SetupIntent.list(
+ customer=owner.stripe_customer_id,
+ limit=20,
+ starting_after=starting_after,
+ )
+ for intent in setup_intents.data:
+ if (
+ intent.get("next_action")
+ and intent.next_action
+ and intent.next_action.get("type") == "verify_with_microdeposits"
+ ):
+ unverified_payment_methods.extend(
+ [
+ {
+ "payment_method_id": intent.payment_method,
+ "hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
+ }
+ ]
+ )
+ has_more = setup_intents.has_more
+ if has_more and setup_intents.data:
+ starting_after = setup_intents.data[-1].id
+
+ return unverified_payment_methods
+
+
+class EnterprisePaymentService(AbstractPaymentService):
+ # enterprise has no payments setup so these are all noops
+
+ def get_invoice(self, owner, invoice_id):
+ pass
+
+ def list_filtered_invoices(self, owner, limit=10):
+ pass
+
+ def delete_subscription(self, owner):
+ pass
+
+ def modify_subscription(self, owner, plan):
+ pass
+
+ def create_checkout_session(self, owner, plan):
+ pass
+
+ def get_subscription(self, owner):
+ pass
+
+ def update_payment_method(self, owner, payment_method):
+ pass
+
+ def update_email_address(self, owner, email_address):
+ pass
+
+ def update_billing_address(self, owner, name, billing_address):
+ pass
+
+ def get_schedule(self, owner):
+ pass
+
+ def apply_cancellation_discount(self, owner: Owner):
+ pass
+
+ def create_setup_intent(self, owner):
+ pass
+
+ def get_unverified_payment_methods(self, owner: Owner):
+ pass
+
+
+class BillingService:
+ payment_service = None
+
+ def __init__(self, payment_service=None, requesting_user=None):
+ if payment_service is None:
+ if settings.IS_ENTERPRISE:
+ self.payment_service = EnterprisePaymentService()
+ else:
+ self.payment_service = StripeService(requesting_user=requesting_user)
+ else:
+ self.payment_service = payment_service
+
+ if not issubclass(type(self.payment_service), AbstractPaymentService):
+ raise Exception(
+ "self.payment_service must subclass AbstractPaymentService!"
+ )
+
+ def get_subscription(self, owner):
+ return self.payment_service.get_subscription(owner)
+
+ def get_schedule(self, owner):
+ return self.payment_service.get_schedule(owner)
+
+ def get_invoice(self, owner, invoice_id):
+ return self.payment_service.get_invoice(owner, invoice_id)
+
+ def list_filtered_invoices(self, owner, limit=10):
+ return self.payment_service.list_filtered_invoices(owner, limit)
+
+ def get_unverified_payment_methods(self, owner: Owner):
+ return self.payment_service.get_unverified_payment_methods(owner)
+
+ def update_plan(self, owner, desired_plan):
+ """
+ Takes an owner and desired plan, and updates the owner's plan. Depending
+ on current state, might create a stripe checkout session and return
+ the checkout session's ID, which is a string. Otherwise returns None.
+ """
+ try:
+ plan = Plan.objects.get(name=desired_plan["value"])
+ except Plan.DoesNotExist:
+ log.warning(
+ f"Unable to find plan {desired_plan['value']} for owner {owner.ownerid}"
+ )
+ return None
+
+ if not plan.is_active:
+ log.warning(
+ f"Attempted to transition to non-existent or legacy plan: "
+ f"owner {owner.ownerid}, plan: {desired_plan}"
+ )
+ return None
+
+ if plan.paid_plan is False:
+ if owner.stripe_subscription_id is not None:
+ self.payment_service.delete_subscription(owner)
+ else:
+ plan_service = PlanService(current_org=owner)
+ plan_service.set_default_plan_data()
+ else:
+ if owner.stripe_subscription_id is not None:
+ # if the existing subscription is incomplete, clean it up and create a new checkout session
+ subscription = self.payment_service.get_subscription(owner)
+
+ if subscription and subscription.status == "incomplete":
+ self._cleanup_incomplete_subscription(subscription, owner)
+ return self.payment_service.create_checkout_session(
+ owner, desired_plan
+ )
+
+ # if the existing subscription is complete, modify the plan
+ self.payment_service.modify_subscription(owner, desired_plan)
+ else:
+ # if the owner has no subscription, create a new checkout session
+ return self.payment_service.create_checkout_session(owner, desired_plan)
+
+ def update_payment_method(self, owner, payment_method):
+ """
+ Takes an owner and a new card. card is an object coming directly from
+ the front-end; without any validation, as payment service can handle
+ the card data differently
+ """
+ return self.payment_service.update_payment_method(owner, payment_method)
+
+ def update_email_address(
+ self,
+ owner: Owner,
+ email_address: str,
+ apply_to_default_payment_method: bool = False,
+ ):
+ """
+ Takes an owner and a new email. Email is a string coming directly from
+ the front-end. If the owner has a payment id and if it's a valid email,
+ the payment service will update the email address in the upstream service.
+ Otherwise returns None.
+ """
+ return self.payment_service.update_email_address(
+ owner, email_address, apply_to_default_payment_method
+ )
+
+ def update_billing_address(self, owner: Owner, name: str, billing_address):
+ """
+ Takes an owner and a billing address. Try to update the owner's billing address
+ to the address passed in. Address should be validated via stripe component prior
+ to hitting this service method. Return None if invalid.
+ """
+ return self.payment_service.update_billing_address(owner, name, billing_address)
+
+ def apply_cancellation_discount(self, owner: Owner):
+ return self.payment_service.apply_cancellation_discount(owner)
+
+ def create_setup_intent(self, owner: Owner):
+ """
+ Creates a SetupIntent for the given owner to securely collect payment details
+ See https://docs.stripe.com/api/setup_intents/create
+ """
+ return self.payment_service.create_setup_intent(owner)
+
+ def _cleanup_incomplete_subscription(
+ self, subscription: stripe.Subscription, owner: Owner
+ ):
+ try:
+ payment_intent_id = subscription.latest_invoice.payment_intent
+ except Exception as e:
+ log.error(
+ "Latest invoice is missing payment intent id",
+ extra=dict(error=e),
+ )
+ return None
+
+ payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
+ if payment_intent.status == "requires_action":
+ log.info(
+ "Subscription has pending payment verification",
+ extra=dict(
+ subscription_id=subscription.get("id"),
+ payment_intent_id=payment_intent.get("id"),
+ payment_intent_status=payment_intent.get("status"),
+ ),
+ )
+ try:
+ # Delete the subscription, which also removes the
+ # pending payment method and unverified payment intent
+ stripe.Subscription.delete(subscription)
+ log.info(
+ "Deleted incomplete subscription",
+ extra=dict(
+ subscription_id=subscription.get("id"),
+ payment_intent_id=payment_intent.get("id"),
+ ),
+ )
+ except Exception as e:
+ log.error(
+ "Failed to delete subscription",
+ extra=dict(
+ subscription_id=subscription.get("id"),
+ payment_intent_id=payment_intent.get("id"),
+ error=str(e),
+ ),
+ )
diff --git a/apps/codecov-api/services/bundle_analysis.py b/apps/codecov-api/services/bundle_analysis.py
new file mode 100644
index 0000000000..a34814d575
--- /dev/null
+++ b/apps/codecov-api/services/bundle_analysis.py
@@ -0,0 +1,553 @@
+import enum
+import os
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, Iterable, List, Optional, Union
+
+import sentry_sdk
+from asgiref.sync import sync_to_async
+from django.utils.functional import cached_property
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import AssetReport as SharedAssetReport
+from shared.bundle_analysis import (
+ BundleAnalysisComparison as SharedBundleAnalysisComparison,
+)
+from shared.bundle_analysis import BundleAnalysisReport as SharedBundleAnalysisReport
+from shared.bundle_analysis import BundleAnalysisReportLoader
+from shared.bundle_analysis import BundleChange as SharedBundleChange
+from shared.bundle_analysis import BundleReport as SharedBundleReport
+from shared.bundle_analysis import ModuleReport as SharedModuleReport
+from shared.bundle_analysis.models import AssetType
+from shared.django_apps.bundle_analysis.service.bundle_analysis import (
+ BundleAnalysisCacheConfigService,
+)
+from shared.storage import get_appropriate_storage_service
+
+from core.models import Commit, Repository
+from graphql_api.actions.measurements import (
+ measurements_by_ids,
+ measurements_last_uploaded_before_start_date,
+)
+from reports.models import CommitReport
+from timeseries.helpers import fill_sparse_measurements
+from timeseries.models import Interval, MeasurementName
+
+
+@sentry_sdk.trace
+def load_report(
+ commit: Commit, report_code: Optional[str] = None
+) -> Optional[SharedBundleAnalysisReport]:
+ storage = get_appropriate_storage_service(commit.repository.repoid)
+
+ commit_report = commit.reports.filter(
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ code=report_code,
+ ).first()
+ if commit_report is None:
+ return None
+
+ loader = BundleAnalysisReportLoader(
+ storage_service=storage,
+ repo_key=ArchiveService.get_archive_hash(commit.repository),
+ )
+ return loader.load(commit_report.external_id)
+
+
+def get_extension(filename: str) -> str:
+ """
+ Gets the file extension of the file without the dot
+ """
+ # At times file can be something like './index.js + 12 modules', only keep the real filepath
+ filename = filename.split(" ")[0]
+ # Retrieve the file extension with the dot
+ _, file_extension = os.path.splitext(filename)
+ # Return empty string if file has no extension
+ if not file_extension or file_extension[0] != ".":
+ return file_extension
+ # Remove the dot in the extension
+ file_extension = file_extension[1:]
+ # At times file can be something like './index.js?module', remove the ?
+ if "?" in file_extension:
+ file_extension = file_extension[: file_extension.rfind("?")]
+
+ return file_extension
+
+
+class BundleAnalysisMeasurementsAssetType(enum.Enum):
+ REPORT_SIZE = MeasurementName.BUNDLE_ANALYSIS_REPORT_SIZE
+ JAVASCRIPT_SIZE = MeasurementName.BUNDLE_ANALYSIS_JAVASCRIPT_SIZE
+ STYLESHEET_SIZE = MeasurementName.BUNDLE_ANALYSIS_STYLESHEET_SIZE
+ FONT_SIZE = MeasurementName.BUNDLE_ANALYSIS_FONT_SIZE
+ IMAGE_SIZE = MeasurementName.BUNDLE_ANALYSIS_IMAGE_SIZE
+ ASSET_SIZE = MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE
+
+
+class BundleAnalysisMeasurementData(object):
+ def __init__(
+ self,
+ raw_measurements: List[dict],
+ asset_type: Union[BundleAnalysisMeasurementsAssetType, str],
+ asset_name: Optional[str],
+ interval: Interval,
+ after: Optional[datetime],
+ before: datetime,
+ ):
+ self.raw_measurements = raw_measurements
+ self.measurement_type = asset_type
+ self.measurement_name = asset_name
+ self.interval = interval
+ self.after = after
+ self.before = before
+
+ @cached_property
+ def asset_type(self) -> str:
+ if isinstance(self.measurement_type, str):
+ return self.measurement_type
+ return self.measurement_type.name
+
+ @cached_property
+ def name(self) -> Optional[str]:
+ return self.measurement_name
+
+ @cached_property
+ def size(self) -> Optional["BundleData"]:
+ if len(self.raw_measurements) > 0:
+ return BundleData(self.raw_measurements[-1]["avg"])
+
+ @cached_property
+ def change(self) -> Optional["BundleData"]:
+ if len(self.raw_measurements) > 1:
+ return BundleData(
+ self.raw_measurements[-1]["avg"] - self.raw_measurements[0]["avg"]
+ )
+
+ @cached_property
+ def measurements(self) -> Iterable[Dict[str, Any]]:
+ if not self.raw_measurements:
+ return []
+ return fill_sparse_measurements(
+ self.raw_measurements, self.interval, self.after, self.before
+ )
+
+
+@dataclass
+class BundleLoadTime:
+ """
+ Value in Milliseconds
+ Reference for speed estimation:
+ https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/throttling/index.html
+ """
+
+ # Speed of internet in bits per second (as reference above)
+ THREE_G_SPEED = 750 * 1000 # Equivalent to 750 Kbps
+ HIGH_SPEED = 30 * 1000 * 1000 # Equivalent to 30 Mbps
+
+ # Computed load time in milliseconds
+ three_g: int
+ high_speed: int
+
+
+@dataclass
+class BundleSize:
+ """
+ Value in Bytes
+ """
+
+ # Compression ratio compared to uncompressed size
+ GZIP = 0.001
+ UNCOMPRESS = 1.0
+
+ # Computed size in bytes
+ gzip: int
+ uncompress: int
+
+
+@dataclass
+class BundleData:
+ def __init__(self, size_in_bytes: int, gzip_size_in_bytes: Optional[int] = None):
+ self.size_in_bytes = size_in_bytes
+ self.size_in_bits = size_in_bytes * 8
+ self.gzip_size_in_bytes = gzip_size_in_bytes
+
+ @cached_property
+ def size(self) -> BundleSize:
+ gzip_size = (
+ self.gzip_size_in_bytes
+ if self.gzip_size_in_bytes is not None
+ else int(float(self.size_in_bytes) * BundleSize.GZIP)
+ )
+ return BundleSize(
+ gzip=gzip_size,
+ uncompress=int(float(self.size_in_bytes) * BundleSize.UNCOMPRESS),
+ )
+
+ @cached_property
+ def load_time(self) -> BundleLoadTime:
+ return BundleLoadTime(
+ three_g=int((self.size_in_bits / BundleLoadTime.THREE_G_SPEED) * 1000),
+ high_speed=int((self.size_in_bits / BundleLoadTime.HIGH_SPEED) * 1000),
+ )
+
+
+@dataclass
+class ModuleReport(object):
+ def __init__(self, module: SharedModuleReport):
+ self.module = module
+
+ @cached_property
+ def name(self) -> str:
+ return self.module.name
+
+ @cached_property
+ def size_total(self) -> int:
+ return self.module.size
+
+ @cached_property
+ def extension(self) -> str:
+ return get_extension(self.name)
+
+
+@dataclass
+class AssetReport(object):
+ def __init__(self, asset: SharedAssetReport):
+ self.asset = asset
+ self.all_modules = None
+
+ @cached_property
+ def id(self) -> int:
+ return self.asset.id
+
+ @cached_property
+ def name(self) -> str:
+ return self.asset.hashed_name
+
+ @cached_property
+ def normalized_name(self) -> str:
+ return self.asset.name
+
+ @cached_property
+ def extension(self) -> str:
+ return get_extension(self.name)
+
+ @cached_property
+ def size_total(self) -> int:
+ return self.asset.size
+
+ @cached_property
+ def gzip_size_total(self) -> int:
+ return self.asset.gzip_size
+
+ @cached_property
+ def modules(self) -> List[ModuleReport]:
+ return [ModuleReport(module) for module in self.asset.modules()]
+
+ @cached_property
+ def module_extensions(self) -> List[str]:
+ return list({module.extension for module in self.modules})
+
+ @cached_property
+ def routes(self) -> Optional[List[str]]:
+ return self.asset.routes()
+
+
+@dataclass
+class BundleReport(object):
+ def __init__(self, report: SharedBundleReport, filters: Dict[str, Any] = {}):
+ self.report = report
+ self.filters = filters
+
+ @cached_property
+ def name(self) -> str:
+ return self.report.name
+
+ @cached_property
+ def all_assets(self) -> List[AssetReport]:
+ return [AssetReport(asset) for asset in self.report.asset_reports()]
+
+ def assets(
+ self, ordering: Optional[str] = None, ordering_desc: Optional[bool] = None
+ ) -> List[AssetReport]:
+ ordering_dict: Dict[str, Any] = {}
+ if ordering:
+ ordering_dict["ordering_column"] = ordering
+ if ordering_desc is not None:
+ ordering_dict["ordering_desc"] = ordering_desc
+ return [
+ AssetReport(asset)
+ for asset in self.report.asset_reports(**{**ordering_dict, **self.filters})
+ ]
+
+ def asset(self, name: str) -> Optional[AssetReport]:
+ for asset_report in self.all_assets:
+ if asset_report.name == name:
+ return asset_report
+
+ @cached_property
+ def size_total(self) -> int:
+ return self.report.total_size(**self.filters)
+
+ @cached_property
+ def gzip_size_total(self) -> int:
+ return self.report.total_gzip_size(**self.filters)
+
+ @cached_property
+ def module_extensions(self) -> List[str]:
+ extensions = set()
+ for asset in self.assets():
+ extensions.update(asset.module_extensions)
+ return list(extensions)
+
+ @cached_property
+ def module_count(self) -> int:
+ return sum([len(asset.modules) for asset in self.assets()])
+
+ @cached_property
+ def is_cached(self) -> bool:
+ return self.report.is_cached()
+
+ @cached_property
+ def info(self) -> dict:
+ return self.report.info()
+
+ @sync_to_async
+ def cache_config(self, repo_id: int) -> bool:
+ return BundleAnalysisCacheConfigService.get_cache_option(
+ repo_id=repo_id, name=self.report.name
+ )
+
+
+@dataclass
+class BundleReportInfo(object):
+ def __init__(self, info: dict) -> None:
+ self.info = info
+
+ @cached_property
+ def version(self) -> str:
+ return self.info.get("version", "unknown")
+
+ @cached_property
+ def plugin_name(self) -> str:
+ return self.info.get("plugin_name", "unknown")
+
+ @cached_property
+ def plugin_version(self) -> str:
+ return self.info.get("plugin_version", "unknown")
+
+ @cached_property
+ def built_at(self) -> str:
+ return str(datetime.fromtimestamp(self.info.get("built_at", 0) / 1000))
+
+ @cached_property
+ def duration(self) -> int:
+ return self.info.get("duration", -1)
+
+ @cached_property
+ def bundler_name(self) -> str:
+ return self.info.get("bundler_name", "unknown")
+
+ @cached_property
+ def bundler_version(self) -> str:
+ return self.info.get("bundler_version", "unknown")
+
+
+@dataclass
+class BundleAnalysisReport(object):
+ def __init__(self, report: SharedBundleAnalysisReport):
+ self.report = report
+
+ def bundle(
+ self, name: str, filters: Dict[str, List[str]]
+ ) -> Optional[BundleReport]:
+ bundle_report = self.report.bundle_report(name)
+ if bundle_report:
+ return BundleReport(bundle_report, filters)
+
+ @cached_property
+ def bundles(self) -> List[BundleReport]:
+ return [BundleReport(bundle) for bundle in self.report.bundle_reports()]
+
+ @cached_property
+ def size_total(self) -> int:
+ return sum([bundle.size_total for bundle in self.bundles])
+
+ @cached_property
+ def is_cached(self) -> bool:
+ return self.report.is_cached()
+
+
+@dataclass
+class BundleAnalysisComparison(object):
+ def __init__(
+ self,
+ loader: BundleAnalysisReportLoader,
+ base_report_key: str,
+ head_report_key: str,
+ repository: Repository,
+ ):
+ self.comparison = SharedBundleAnalysisComparison(
+ loader,
+ base_report_key,
+ head_report_key,
+ repository,
+ )
+ self.head_report = self.comparison.head_report
+
+ @cached_property
+ def bundles(self) -> List["BundleComparison"]:
+ bundle_comparisons = []
+ for bundle_change in self.comparison.bundle_changes():
+ head_bundle_report = self.comparison.head_report.bundle_report(
+ bundle_change.bundle_name
+ )
+ head_size = head_bundle_report.total_size() if head_bundle_report else 0
+ bundle_comparisons.append(BundleComparison(bundle_change, head_size))
+ return bundle_comparisons
+
+ @cached_property
+ def size_delta(self) -> int:
+ return sum([change.size_delta for change in self.comparison.bundle_changes()])
+
+ @cached_property
+ def size_total(self) -> int:
+ return BundleAnalysisReport(self.head_report).size_total
+
+
+@dataclass
+class BundleComparison(object):
+ def __init__(self, bundle_change: SharedBundleChange, head_bundle_report_size: int):
+ self.bundle_change = bundle_change
+ self.head_bundle_report_size = head_bundle_report_size
+
+ @cached_property
+ def bundle_name(self) -> str:
+ return self.bundle_change.bundle_name
+
+ @cached_property
+ def change_type(self) -> str:
+ return self.bundle_change.change_type.value
+
+ @cached_property
+ def size_delta(self) -> int:
+ return self.bundle_change.size_delta
+
+ @cached_property
+ def size_total(self) -> int:
+ return self.head_bundle_report_size
+
+
+class BundleAnalysisMeasurementsService(object):
+ def __init__(
+ self,
+ repository: Repository,
+ interval: Interval,
+ before: datetime,
+ after: Optional[datetime] = None,
+ branch: Optional[str] = None,
+ ) -> None:
+ self.repository = repository
+ self.interval = interval
+ self.after = after
+ self.before = before
+ self.branch = branch
+
+ @sentry_sdk.trace
+ def _compute_measurements(
+ self, measurable_name: str, measurable_ids: List[str]
+ ) -> Dict[str, List[Dict[str, Any]]]:
+ all_measurements = measurements_by_ids(
+ repository=self.repository,
+ measurable_name=measurable_name,
+ measurable_ids=measurable_ids,
+ interval=self.interval,
+ after=self.after,
+ before=self.before,
+ branch=self.branch,
+ ) or {measurable_ids[0]: []}
+
+ # Carry over previous available value for start date if its value is null
+ for measurable_id, measurements in all_measurements.items():
+ if self.after is not None and (
+ not measurements or measurements[0]["timestamp_bin"] > self.after
+ ):
+ carryover_measurement = measurements_last_uploaded_before_start_date(
+ owner_id=self.repository.author.ownerid,
+ repo_id=self.repository.repoid,
+ measurable_name=measurable_name,
+ measurable_id=measurable_id,
+ start_date=self.after,
+ branch=self.branch,
+ )
+
+ # Create a new datapoint in the measurements and prepend it to the existing list
+ # If there isn't any measurements before the start date range, measurements will be untouched
+ if carryover_measurement:
+ value = Decimal(carryover_measurement[0]["value"])
+ carryover = dict(measurements[0]) if measurements else {}
+ carryover["timestamp_bin"] = self.after.replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ carryover["min"] = value
+ carryover["max"] = value
+ carryover["avg"] = value
+ all_measurements[measurable_id] = [carryover] + all_measurements[
+ measurable_id
+ ]
+
+ return all_measurements
+
+ @sentry_sdk.trace
+ def compute_asset(
+ self, asset_report: AssetReport
+ ) -> Optional[BundleAnalysisMeasurementData]:
+ asset = asset_report.asset
+ if asset.asset_type != AssetType.JAVASCRIPT:
+ return None
+
+ measurements = self._compute_measurements(
+ measurable_name=MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE.value,
+ measurable_ids=[asset.uuid],
+ )
+
+ return BundleAnalysisMeasurementData(
+ raw_measurements=list(measurements.get(asset.uuid, [])),
+ asset_type=BundleAnalysisMeasurementsAssetType.JAVASCRIPT_SIZE,
+ asset_name=asset.name,
+ interval=self.interval,
+ after=self.after,
+ before=self.before,
+ )
+
+ @sentry_sdk.trace
+ def compute_report(
+ self,
+ bundle_report: BundleReport,
+ asset_type: BundleAnalysisMeasurementsAssetType,
+ ) -> List[BundleAnalysisMeasurementData]:
+ asset_uuid_to_name_mapping = {}
+ if asset_type.value == MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE:
+ measurable_ids = []
+ for asset_report in bundle_report.all_assets:
+ asset = asset_report.asset
+ if asset.asset_type == AssetType.JAVASCRIPT:
+ measurable_ids.append(asset.uuid)
+ asset_uuid_to_name_mapping[asset.uuid] = asset.name
+ else:
+ measurable_ids = [bundle_report.name]
+
+ measurements = self._compute_measurements(
+ measurable_name=asset_type.value.value,
+ measurable_ids=measurable_ids,
+ )
+
+ return [
+ BundleAnalysisMeasurementData(
+ raw_measurements=list(measurements.get(measurable_id, [])),
+ asset_type=asset_type,
+ asset_name=asset_uuid_to_name_mapping.get(measurable_id, None),
+ interval=self.interval,
+ after=self.after,
+ before=self.before,
+ )
+ for measurable_id in measurable_ids
+ ]
diff --git a/apps/codecov-api/services/comparison.py b/apps/codecov-api/services/comparison.py
new file mode 100644
index 0000000000..df6ca410c0
--- /dev/null
+++ b/apps/codecov-api/services/comparison.py
@@ -0,0 +1,1298 @@
+import copy
+import functools
+import json
+import logging
+from collections import Counter
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import List, Optional, Tuple
+
+import minio
+import pytz
+import shared.reports.api_report_service as report_service
+from asgiref.sync import async_to_sync
+from django.db.models import Prefetch, QuerySet
+from django.utils.functional import cached_property
+from shared.api_archive.archive import ArchiveService
+from shared.helpers.redis import get_redis_connection
+from shared.helpers.yaml import walk
+from shared.reports.types import ReportTotals
+from shared.torngit.base import TorngitBaseAdapter
+from shared.utils.merge import LineType, line_type
+
+from compare.models import CommitComparison
+from core.models import Commit, Pull
+from reports.models import CommitReport
+from services import ServiceException
+from services.repo_providers import RepoProviderService
+from utils.config import get_config
+
+log = logging.getLogger(__name__)
+
+
+redis = get_redis_connection()
+
+
+MAX_DIFF_SIZE = 170
+
+
+def _is_added(line_value):
+ return line_value and line_value[0] == "+"
+
+
+def _is_removed(line_value):
+ return line_value and line_value[0] == "-"
+
+
+class ComparisonException(ServiceException):
+ @property
+ def message(self):
+ return str(self)
+
+
+class MissingComparisonCommit(ComparisonException):
+ pass
+
+
+class MissingComparisonReport(ComparisonException):
+ pass
+
+
+class FirstPullRequest:
+ message = "This is the first pull request for this repository"
+
+
+class FileComparisonTraverseManager:
+ """
+ The FileComparisonTraverseManager uses the visitor-pattern to execute a series
+ of arbitrary actions on each line in a FileComparison. The main entrypoint to
+ this class is the '.apply()' method, which is the only method client code should invoke.
+ """
+
+ def __init__(self, head_file_eof=0, base_file_eof=0, segments=[], src=[]):
+ """
+ head_file_eof -- end-line of the head_file we are traversing, plus 1
+ base_file_eof -- same as above, for base_file
+
+ ^^ Generally client code should supply both, except in a couple cases:
+ 1. The file is newly tracked. In this case, there is no base file, so we should
+ iterate only over the head file lines.
+ 2. The file is deleted. As of right now (4/2/2020), we don't show deleted files in
+ comparisons, but if we were to support that, we would not supply a head_file_eof
+ and instead only iterate over lines in the base file.
+
+ segments -- these come from the provider API response related to the comparison, and
+ constitute the 'diff' between the base and head references. Each segment takes this form:
+
+ {
+ "header": [
+ base reference offset,
+ number of lines in file-segment before changes applied,
+ head reference offset,
+ number of lines in file-segment after changes applied
+ ],
+ "lines": [ # line values for lines in the diff
+ "+this is an added line",
+ "-this is a removed line",
+ "this line is unchanged in the diff",
+ ...
+ ]
+ }
+
+ The segment["header"], also known as the hunk-header (https://en.wikipedia.org/wiki/Diff#Unified_format),
+ is an array of strings, which is why we have to use the int() builtin function
+ to compare with self.head_ln and self.base_ln. It is used by this algorithm to
+ 1. Set initial values for the self.base_ln and self.head_ln line-counters, and
+ 2. Detect if self.base and/or self.head refer to lines in the diff at any given time
+
+ This algorithm relies on the fact that segments are returned in ascending
+ order for each file, which means that the "nearest" segment to the current line
+ being traversed is located at segments[0].
+
+ src -- this is the source code of the file at the head-reference, where each line
+ is a cell in the array. If we are not traversing a segment, and src is provided,
+ the line value passed to the visitors will be the line at src[self.head_ln - 1].
+ """
+ self.head_file_eof = head_file_eof
+ self.base_file_eof = base_file_eof
+ self.segments = copy.deepcopy(segments)
+ self.src = src
+
+ if self.segments:
+ # Base offsets can be 0 if files are added or removed
+ self.base_ln = min(1, int(self.segments[0]["header"][0]))
+ self.head_ln = min(1, int(self.segments[0]["header"][2]))
+ else:
+ self.base_ln, self.head_ln = 1, 1
+
+ def traverse_finished(self):
+ if self.segments:
+ return False
+ if self.src:
+ return self.head_ln > len(self.src)
+ return self.head_ln >= self.head_file_eof and self.base_ln >= self.base_file_eof
+
+ def traversing_diff(self):
+ if self.segments == []:
+ return False
+
+ base_ln_within_offset = (
+ int(self.segments[0]["header"][0])
+ <= self.base_ln
+ < int(self.segments[0]["header"][0])
+ + int(self.segments[0]["header"][1] or 1)
+ )
+ head_ln_within_offset = (
+ int(self.segments[0]["header"][2])
+ <= self.head_ln
+ < int(self.segments[0]["header"][2])
+ + int(self.segments[0]["header"][3] or 1)
+ )
+ return base_ln_within_offset or head_ln_within_offset
+
+ def pop_line(self):
+ if self.traversing_diff():
+ return self.segments[0]["lines"].pop(0)
+
+ if self.src:
+ return self.src[self.head_ln - 1]
+
+ def apply(self, visitors):
+ """
+ Traverses the lines in a file comparison while accounting for the diff.
+ If a line only appears in the base file (removed in head), it is prefixed
+ with '-', and we only increment self.base_ln. If a line only appears in
+ the head file, it is newly added and prefixed with '+', and we only
+ increment self.head_ln.
+
+ visitors -- A list of visitors applied to each line.
+ """
+ while not self.traverse_finished():
+ line_value = self.pop_line()
+ is_diff = self.traversing_diff()
+
+ for visitor in visitors:
+ visitor(
+ None if is_diff and _is_added(line_value) else self.base_ln,
+ None if is_diff and _is_removed(line_value) else self.head_ln,
+ line_value,
+ is_diff, # TODO(pierce): remove when upon combining diff + changes tabs in UI
+ )
+
+ if is_diff and _is_added(line_value):
+ self.head_ln += 1
+ elif is_diff and _is_removed(line_value):
+ self.base_ln += 1
+ else:
+ self.head_ln += 1
+ self.base_ln += 1
+
+ if self.segments and not self.segments[0]["lines"]:
+ # Either the segment has no lines (and is therefore of no use)
+ # or all lines have been popped and visited, which means we are
+ # done traversing it
+ self.segments.pop(0)
+
+
+class FileComparisonVisitor:
+ """
+ Abstract class with a convenience method for getting lines amongst
+ all the edge cases.
+ """
+
+ def _get_line(self, report_file, ln):
+ """
+ Kindof a hacky way to bypass the dataclasses used in `reports`
+ library, because they are extremely slow. This basically copies
+ some logic from ReportFile.get and ReportFile._line, which work
+ together to take an index and turn it into a ReportLine. Here
+ we do something similar, but just return the underlying array instead.
+ Not sure if this will be the final solution.
+
+ Note: the underlying array representation cn be seen here:
+ https://github.com/codecov/shared/blob/master/shared/reports/types.py#L75
+ The index in the array representation is 1-1 with the index of the
+ dataclass attribute for ReportLine.
+ """
+ if report_file is None or ln is None:
+ return None
+
+ # copied from ReportFile.get
+ try:
+ line = report_file._lines[ln - 1]
+ except IndexError:
+ return None
+
+ # copied from ReportFile._line, minus dataclass instantiation
+ if line:
+ if isinstance(line, list):
+ return line
+ else:
+ # these are old versions
+ # note:(pierce) ^^ this comment is copied, not sure what it means
+ return json.loads(line)
+
+ def _get_lines(self, base_ln, head_ln):
+ base_line = self._get_line(self.base_file, base_ln)
+ head_line = self._get_line(self.head_file, head_ln)
+ return base_line, head_line
+
+ def __call__(self, base_ln, head_ln, value, is_diff):
+ pass
+
+
+class CreateLineComparisonVisitor(FileComparisonVisitor):
+ """
+ A visitor that creates LineComparisons, and stores the
+ result in self.lines. Only operates on lines that have
+ code-values derived from segments or src in FileComparisonTraverseManager.
+ """
+
+ def __init__(self, base_file, head_file):
+ self.base_file, self.head_file = base_file, head_file
+ self.lines = []
+
+ def __call__(self, base_ln, head_ln, value, is_diff):
+ if value is None:
+ return
+
+ base_line, head_line = self._get_lines(base_ln, head_ln)
+
+ self.lines.append(
+ LineComparison(
+ base_line=base_line,
+ head_line=head_line,
+ base_ln=base_ln,
+ head_ln=head_ln,
+ value=value,
+ is_diff=is_diff,
+ )
+ )
+
+
+class CreateChangeSummaryVisitor(FileComparisonVisitor):
+ """
+ A visitor for summarizing the "unexpected coverage changes"
+ to a certain file. We specifically ignore lines that are changed
+ in the source code, which are prefixed with '+' or '-'. Result
+ is stored in self.summary.
+ """
+
+ def __init__(self, base_file, head_file):
+ self.base_file, self.head_file = base_file, head_file
+ self.summary = Counter()
+ self.coverage_type_map = {
+ LineType.hit: "hits",
+ LineType.miss: "misses",
+ LineType.partial: "partials",
+ }
+
+ def _update_summary(self, base_line, head_line):
+ """
+ Updates the change summary based on the coverage type (0
+ for miss, 1 for hit, 2 for partial) found at index 0 of the
+ line-array.
+ """
+ self.summary[self.coverage_type_map[line_type(base_line[0])]] -= 1
+ self.summary[self.coverage_type_map[line_type(head_line[0])]] += 1
+
+ def __call__(self, base_ln, head_ln, value, is_diff):
+ if value and value[0] in ["+", "-"]:
+ return
+
+ base_line, head_line = self._get_lines(base_ln, head_ln)
+ if base_line is None or head_line is None:
+ return
+
+ if line_type(base_line[0]) == line_type(head_line[0]):
+ return
+
+ self._update_summary(base_line, head_line)
+
+
+class LineComparison:
+ def __init__(self, base_line, head_line, base_ln, head_ln, value, is_diff):
+ self.base_line = base_line
+ self.head_line = head_line
+ self.head_ln = head_ln
+ self.base_ln = base_ln
+ self.value = value
+ self.is_diff = is_diff
+
+ self.added = is_diff and _is_added(value)
+ self.removed = is_diff and _is_removed(value)
+
+ @property
+ def number(self):
+ return {
+ "base": self.base_ln if not self.added else None,
+ "head": self.head_ln if not self.removed else None,
+ }
+
+ @property
+ def coverage(self):
+ return {
+ "base": None
+ if self.added or not self.base_line
+ else line_type(self.base_line[0]),
+ "head": None
+ if self.removed or not self.head_line
+ else line_type(self.head_line[0]),
+ }
+
+ @cached_property
+ def head_line_sessions(self) -> Optional[List[tuple]]:
+ if self.head_line is None:
+ return None
+
+ # `head_line` is the tuple representation of a `shared.reports.types.ReportLine`
+ # it has the following shape:
+ # (coverage, type, sessions, messages, complexity, datapoints)
+
+ # each session is a tuple representation of a `shared.reports.types.LineSession`
+ # is has the following shape:
+ # (id, coverage, branches, partials, complexity)
+ sessions = self.head_line[2]
+
+ return sessions
+
+ @cached_property
+ def hit_count(self) -> Optional[int]:
+ if self.head_line_sessions is None:
+ return None
+
+ hit_count = 0
+ for id, coverage, *rest in self.head_line_sessions:
+ if line_type(coverage) == LineType.hit:
+ hit_count += 1
+ if hit_count > 0:
+ return hit_count
+
+ @cached_property
+ def hit_session_ids(self) -> Optional[List[int]]:
+ if self.head_line_sessions is None:
+ return None
+
+ ids = []
+ for id, coverage, *rest in self.head_line_sessions:
+ if line_type(coverage) == LineType.hit:
+ ids.append(id)
+ if len(ids) > 0:
+ return ids
+
+
+class Segment:
+ """
+ A segment represents a contiguous subset of lines in a file where either
+ the coverage has changed or the code has changed (i.e. is part of a diff).
+ """
+
+ # additional lines included before and after each segment
+ padding_lines = 3
+
+ # max distance between lines with coverage changes in a single segment
+ line_distance = 6
+
+ @classmethod
+ def segments(cls, file_comparison):
+ lines = file_comparison.lines
+
+ # line numbers of interest (i.e. coverage changed or code changed)
+ line_numbers = []
+ for idx, line in enumerate(lines):
+ if (
+ line.coverage["base"] != line.coverage["head"]
+ or line.added
+ or line.removed
+ ):
+ line_numbers.append(idx)
+
+ segmented_lines = []
+ if len(line_numbers) > 0:
+ segmented_lines, last = [[]], None
+ for line_number in line_numbers:
+ if last is None or line_number - last <= cls.line_distance:
+ segmented_lines[-1].append(line_number)
+ else:
+ segmented_lines.append([line_number])
+ last = line_number
+
+ segments = []
+ for group in segmented_lines:
+ # padding lines before first line of interest
+ start_line_number = group[0] - cls.padding_lines
+ start_line_number = max(start_line_number, 0)
+ # padding lines after last line of interest
+ end_line_number = group[-1] + cls.padding_lines
+ end_line_number = min(end_line_number, len(lines) - 1)
+
+ segment = cls(lines[start_line_number : end_line_number + 1])
+ segments.append(segment)
+
+ return segments
+
+ def __init__(self, lines):
+ self._lines = lines
+
+ @property
+ def header(self):
+ base_start = None
+ head_start = None
+ num_removed = 0
+ num_added = 0
+ num_context = 0
+
+ for line in self.lines:
+ if base_start is None and line.number["base"] is not None:
+ base_start = int(line.number["base"])
+ if head_start is None and line.number["head"] is not None:
+ head_start = int(line.number["head"])
+ if line.added:
+ num_added += 1
+ elif line.removed:
+ num_removed += 1
+ else:
+ num_context += 1
+
+ return (
+ base_start or 0,
+ num_context + num_removed,
+ head_start or 0,
+ num_context + num_added,
+ )
+
+ @property
+ def lines(self):
+ return self._lines
+
+ @property
+ def has_diff_changes(self):
+ for line in self.lines:
+ if line.added or line.removed:
+ return True
+ return False
+
+ @property
+ def has_unintended_changes(self):
+ for line in self.lines:
+ head_coverage = line.coverage["base"]
+ base_coverage = line.coverage["head"]
+ if not (line.added or line.removed) and (base_coverage != head_coverage):
+ return True
+ return False
+
+ def remove_unintended_changes(self):
+ filtered = []
+ for line in self._lines:
+ base_cov = line.coverage["base"]
+ head_cov = line.coverage["head"]
+ if (line.added or line.removed) or (base_cov == head_cov):
+ filtered.append(line)
+ self._lines = filtered
+
+
+class FileComparison:
+ def __init__(
+ self,
+ base_file,
+ head_file,
+ diff_data=None,
+ src=[],
+ bypass_max_diff=False,
+ should_search_for_changes=None,
+ ):
+ """
+ comparison -- the enclosing Comparison object that owns this FileComparison
+
+ base_file -- the ReportFile for this file from the base report
+
+ head_file -- the ReportFile for this file from the head report
+
+ diff_data -- the git-comparison between the base and head references in the instantiation
+ Comparison object. fields include:
+
+ stats: -- {"added": number of added lines, "removed": number of removed lines}
+ segments: (described in detail in the FileComparisonTraverseManager docstring)
+ before: the name of this file in the base reference, if different from name in head ref
+
+ If this file is unchanged in the comparison between base and head, the default will be used.
+
+ src -- The full source of the file in the head reference. Used in FileComparisonTraverseManager
+ to join src-code with coverage data. Default is used when retrieving full comparison,
+ whereas full-src is serialized when retrieving individual file comparison.
+
+ bypass_max_diff -- configuration paramater that tells this class to ignore max-diff truncating.
+ default is used when retrieving full comparison; True is passed when fetching individual
+ file comparison.
+
+ should_search_for_changes -- flag that indicates if this FileComparison has unexpected coverage changes,
+ according to a value cached during asynchronous processing. Has three values:
+ 1. True - indicates this FileComparison has unexpected coverage changes according to worker,
+ and we should process the lines in this FileComparison using FileComparisonTraverseManager
+ to calculate a change summary.
+ 2. False - indicates this FileComparison does not have unexpected coverage changes according to
+ worker, and we should not traverse this file or calculate a change summary.
+ 3. None (default) - indicates we do not have information cached from worker to rely on here
+ (no value in cache), so we need to traverse this FileComparison and calculate a change
+ summary to find out.
+ """
+ self.base_file = base_file
+ self.head_file = head_file
+ self.diff_data = diff_data
+ self.src = src
+
+ # Some extra fields for truncating large diffs in the initial response
+ self.total_diff_length = (
+ functools.reduce(
+ lambda a, b: a + b,
+ [len(segment["lines"]) for segment in self.diff_data["segments"]],
+ )
+ if self.diff_data is not None and self.diff_data.get("segments")
+ else 0
+ )
+
+ self.bypass_max_diff = bypass_max_diff
+ self.should_search_for_changes = should_search_for_changes
+
+ @property
+ def name(self):
+ return {
+ "base": self.base_file.name if self.base_file is not None else None,
+ "head": self.head_file.name if self.head_file is not None else None,
+ }
+
+ @property
+ def totals(self):
+ head_totals = self.head_file.totals if self.head_file is not None else None
+
+ # The call to '.apply_diff()' in 'Comparison.head_report' stores diff totals
+ # for each file in the diff_data for that file (in a field called 'totals').
+ # Here we pass this along to the frontend by assigning the diff totals
+ # to the head_totals' 'diff' attribute. It is absolutely worth considering
+ # modifying the behavior of shared.reports to implement something similar.
+ diff_totals = None
+ if head_totals and self.diff_data:
+ diff_totals = self.diff_data.get("totals")
+ head_totals.diff = diff_totals or 0
+
+ return {
+ "base": self.base_file.totals if self.base_file is not None else None,
+ "head": head_totals,
+ "diff": diff_totals,
+ }
+
+ @property
+ def has_diff(self):
+ return self.diff_data is not None
+
+ @property
+ def stats(self):
+ return self.diff_data["stats"] if self.diff_data else None
+
+ @cached_property
+ def _calculated_changes_and_lines(self):
+ """
+ Applies visitors to the file to generate response data (line comparison representations
+ and change summary). Only applies visitors if
+
+ 1. The file has a diff or src, in which case we need to generate response data for it anyway, or
+ 2. The should_search_for_changes flag is defined (not None) and is True
+
+ This limitation improves performance by limiting searching for changes to only files that
+ have them.
+ """
+ change_summary_visitor = CreateChangeSummaryVisitor(
+ self.base_file, self.head_file
+ )
+ create_lines_visitor = CreateLineComparisonVisitor(
+ self.base_file, self.head_file
+ )
+
+ if self.diff_data or self.src or self.should_search_for_changes is not False:
+ FileComparisonTraverseManager(
+ head_file_eof=self.head_file.eof if self.head_file is not None else 0,
+ base_file_eof=self.base_file.eof if self.base_file is not None else 0,
+ segments=self.diff_data["segments"]
+ if self.diff_data and "segments" in self.diff_data
+ else [],
+ src=self.src,
+ ).apply([change_summary_visitor, create_lines_visitor])
+
+ return change_summary_visitor.summary, create_lines_visitor.lines
+
+ @cached_property
+ def change_summary(self):
+ return self._calculated_changes_and_lines[0]
+
+ @property
+ def has_changes(self):
+ return any(self.change_summary.values())
+
+ @cached_property
+ def lines(self):
+ if self.total_diff_length > MAX_DIFF_SIZE and not self.bypass_max_diff:
+ return None
+ return self._calculated_changes_and_lines[1]
+
+ @cached_property
+ def segments(self):
+ return Segment.segments(self)
+
+
+class Comparison(object):
+ def __init__(self, user, base_commit, head_commit):
+ # TODO: rename to owner
+ self.user = user
+ self._base_commit = base_commit
+ self._head_commit = head_commit
+
+ @cached_property
+ def _adapter(self) -> TorngitBaseAdapter:
+ return RepoProviderService().get_adapter(
+ owner=self.user, repo=self.base_commit.repository
+ )
+
+ def validate(self):
+ # make sure head and base reports exist (will throw an error if not)
+ self.head_report
+ self.base_report
+
+ @cached_property
+ def base_commit(self):
+ return self._base_commit
+
+ @cached_property
+ def head_commit(self):
+ return self._head_commit
+
+ @cached_property
+ def files(self):
+ for file_name in self.head_report.files:
+ yield self.get_file_comparison(file_name)
+
+ def get_file_comparison(self, file_name, with_src=False, bypass_max_diff=False):
+ head_file = self.head_report.get(file_name)
+ diff_data = self.git_comparison["diff"]["files"].get(file_name)
+
+ if self.base_report is not None:
+ base_file = self.base_report.get(file_name)
+ if base_file is None and diff_data:
+ base_file = self.base_report.get(diff_data.get("before"))
+ else:
+ base_file = None
+
+ if with_src:
+ file_content = async_to_sync(self._adapter.get_source)(
+ file_name, self.head_commit.commitid
+ )["content"]
+ # make sure the file is str utf-8
+ if not isinstance(file_content, str):
+ file_content = str(file_content, "utf-8")
+ src = file_content.splitlines()
+ else:
+ src = []
+
+ return FileComparison(
+ base_file=base_file,
+ head_file=head_file,
+ diff_data=diff_data,
+ src=src,
+ bypass_max_diff=bypass_max_diff,
+ )
+
+ @cached_property
+ def git_comparison(self) -> dict:
+ """
+ Fetches comparison, and caches the result.
+ """
+ return async_to_sync(self._adapter.get_compare)(
+ self.base_commit.commitid, self.head_commit.commitid
+ )
+
+ @cached_property
+ def base_report(self):
+ try:
+ return report_service.build_report_from_commit(self.base_commit)
+ except minio.error.S3Error as e:
+ if e.code == "NoSuchKey":
+ raise MissingComparisonReport("Missing base report")
+ else:
+ raise e
+
+ @cached_property
+ def head_report(self):
+ try:
+ report = report_service.build_report_from_commit(self.head_commit)
+ except minio.error.S3Error as e:
+ if e.code == "NoSuchKey":
+ raise MissingComparisonReport("Missing head report")
+ else:
+ raise e
+
+ # Return the old report if the github API call fails for any reason
+ try:
+ report.apply_diff(self.git_comparison["diff"])
+ except Exception:
+ pass
+ return report
+
+ @cached_property
+ def head_report_without_applied_diff(self):
+ """
+ This is a variant to the head_report property without having an applied diff.
+ This is created because applying the diff calls the provider, which adds a
+ diff_totals key to the head_report object as well as adjusting the diff_total
+ values in each session. This variant should only be used if you are not using
+ any diff related data, as it saves an unnecessary request to the provider otherwise.
+ """
+ try:
+ report = report_service.build_report_from_commit(self.head_commit)
+ except minio.error.S3Error as e:
+ if e.code == "NoSuchKey":
+ raise MissingComparisonReport("Missing head report")
+ else:
+ raise e
+
+ return report
+
+ @cached_property
+ def has_different_number_of_head_and_base_sessions(self):
+ """
+ This method checks if the head and the base have different number of sessions.
+ It makes use of the head_report_without_applied_diff property instead of the
+ head_report one as it doesn't need diff related data for this computation (see
+ the description of that property above for more context).
+ This method should be replaced with a direct call to the report_uploads table instead,
+ but leaving the implementation the same for now for consistency.
+ """
+ try:
+ head_sessions = self.head_report_without_applied_diff.sessions
+ base_sessions = self.base_report.sessions
+ except Exception:
+ return False
+
+ # We're treating this case as false since considering CFF's complicates the logic
+ if self._has_cff_sessions(head_sessions) or self._has_cff_sessions(
+ base_sessions
+ ):
+ return False
+ return len(head_sessions) != len(base_sessions)
+
+ # I feel this method should belong to the API Report class, but we're thinking of getting rid of that class soon
+ # In truth, this should be in the shared.Report class
+ def _has_cff_sessions(self, sessions) -> bool:
+ for session in sessions.values():
+ if session.session_type.value == "carriedforward":
+ return True
+ return False
+
+ @property
+ def totals(self):
+ return {
+ "base": self.base_report.totals if self.base_report is not None else None,
+ "head": self.head_report.totals if self.head_report is not None else None,
+ "diff": self.git_comparison["diff"].get("totals"),
+ }
+
+ @property
+ def git_commits(self):
+ return self.git_comparison["commits"]
+
+ @property
+ def upload_commits(self):
+ """
+ Returns the commits that have uploads between base and head.
+ :return: Queryset of core.models.Commit objects
+ """
+ commit_ids = [commit["commitid"] for commit in self.git_commits]
+ commits_queryset = Commit.objects.filter(
+ commitid__in=commit_ids, repository=self.base_commit.repository
+ )
+ commits_queryset.exclude(deleted=True)
+ return commits_queryset
+
+ def flag_comparison(self, flag_name):
+ return FlagComparison(self, flag_name)
+
+ @property
+ def non_carried_forward_flags(self):
+ flags_dict = self.head_report.flags
+ return [flag for flag, vals in flags_dict.items() if not vals.carriedforward]
+
+
+class FlagComparison(object):
+ def __init__(self, comparison, flag_name):
+ self.comparison = comparison
+ self.flag_name = flag_name
+
+ @cached_property
+ def head_report(self):
+ return self.comparison.head_report.flags.get(self.flag_name)
+
+ @cached_property
+ def base_report(self):
+ return self.comparison.base_report.flags.get(self.flag_name)
+
+ @cached_property
+ def diff_totals(self):
+ if self.head_report is None:
+ return None
+ git_comparison = self.comparison.git_comparison
+ return self.head_report.apply_diff(git_comparison["diff"])
+
+
+@dataclass
+class ImpactedFile:
+ @dataclass
+ class Totals(ReportTotals):
+ def __post_init__(self):
+ nb_branches = self.hits + self.misses + self.partials
+ self.coverage = (100 * self.hits / nb_branches) if nb_branches > 0 else None
+
+ base_name: Optional[str] = None # will be `None` for created files
+ head_name: Optional[str] = None # will be `None` for deleted files
+ file_was_added_by_diff: bool = False
+ file_was_removed_by_diff: bool = False
+ base_coverage: Optional[Totals] = None # will be `None` for created files
+ head_coverage: Optional[Totals] = None # will be `None` for deleted files
+
+ # lists of (line number, coverage) tuples
+ added_diff_coverage: Optional[List[tuple[int, str]]] = None
+ removed_diff_coverage: Optional[List[tuple[int, str]]] = field(default_factory=list)
+ unexpected_line_changes: Optional[List[tuple[int, str]]] = field(
+ default_factory=list
+ )
+
+ lines_only_on_base: List[int] = field(default_factory=list)
+ lines_only_on_head: List[int] = field(default_factory=list)
+
+ @classmethod
+ def create(cls, **kwargs):
+ base_coverage = kwargs.pop("base_coverage")
+ head_coverage = kwargs.pop("head_coverage")
+ return cls(
+ **kwargs,
+ base_coverage=ImpactedFile.Totals(**base_coverage)
+ if base_coverage
+ else None,
+ head_coverage=ImpactedFile.Totals(**head_coverage)
+ if head_coverage
+ else None,
+ )
+
+ @cached_property
+ def has_diff(self) -> bool:
+ """
+ Returns `True` if the file has any additions or removals in the diff
+ """
+ return bool(
+ self.added_diff_coverage
+ and len(self.added_diff_coverage) > 0
+ or self.removed_diff_coverage
+ and len(self.removed_diff_coverage) > 0
+ or self.file_was_added_by_diff
+ or self.file_was_removed_by_diff
+ )
+
+ @cached_property
+ def has_changes(self) -> bool:
+ """
+ Returns `True` if the file has any unexpected changes
+ """
+ return (
+ self.unexpected_line_changes is not None
+ and len(self.unexpected_line_changes) > 0
+ )
+
+ @cached_property
+ def misses_count(self) -> int:
+ total_misses = 0
+ total_misses += self._direct_misses_count
+ total_misses += self._unintended_misses_count
+ return total_misses
+
+ @cached_property
+ def _unintended_misses_count(self) -> int:
+ """
+ Returns the misses count for a unintended impacted file
+ """
+ misses = 0
+
+ unexpected_line_changes = self.unexpected_line_changes or []
+ for [
+ base,
+ [head_line_number, head_coverage_value],
+ ] in unexpected_line_changes:
+ if head_coverage_value == "m":
+ misses += 1
+
+ return misses
+
+ @cached_property
+ def _direct_misses_count(self) -> int:
+ """
+ Returns the misses count for a direct impacted file
+ """
+
+ misses = 0
+
+ diff_coverage = self.added_diff_coverage or []
+ for line_number, line_coverage_value in diff_coverage:
+ if line_coverage_value == "m":
+ misses += 1
+
+ return misses
+
+ @cached_property
+ def patch_coverage(self) -> Optional[Totals]:
+ """
+ Sums of hits, misses and partials in the diff
+ """
+ if self.added_diff_coverage and len(self.added_diff_coverage) > 0:
+ hits, misses, partials = (0, 0, 0)
+ for added_coverage in self.added_diff_coverage:
+ [_, type_coverage] = added_coverage
+ if type_coverage == "h":
+ hits += 1
+ if type_coverage == "m":
+ misses += 1
+ if type_coverage == "p":
+ partials += 1
+
+ return ImpactedFile.Totals(hits=hits, misses=misses, partials=partials)
+
+ @cached_property
+ def change_coverage(self) -> Optional[float]:
+ if (
+ self.base_coverage
+ and self.base_coverage.coverage
+ and self.head_coverage
+ and self.head_coverage.coverage
+ ):
+ return float(
+ float(self.head_coverage.coverage or 0)
+ - float(self.base_coverage.coverage or 0)
+ )
+
+ @cached_property
+ def file_name(self) -> Optional[str]:
+ if self.head_name:
+ parts = self.head_name.split("/")
+ return parts[-1]
+
+
+@dataclass
+class ComparisonReport(object):
+ """
+ This is a wrapper around the data computed by the worker's commit comparison task.
+ The raw data is stored in blob storage and accessible via the `report_storage_path`
+ on a `CommitComparison`
+ """
+
+ commit_comparison: CommitComparison = None
+
+ @cached_property
+ def files(self) -> List[ImpactedFile]:
+ if not self.commit_comparison.report_storage_path:
+ return []
+
+ comparison_data = self._fetch_raw_comparison_data()
+ return [
+ ImpactedFile.create(**data) for data in comparison_data.get("files", [])
+ ]
+
+ def impacted_file(self, path: str) -> Optional[ImpactedFile]:
+ for file in self.files:
+ if file.head_name == path:
+ return file
+
+ @cached_property
+ def impacted_files(self) -> List[ImpactedFile]:
+ return self.files
+
+ @cached_property
+ def impacted_files_with_unintended_changes(self) -> List[ImpactedFile]:
+ return [file for file in self.files if file.has_changes]
+
+ @cached_property
+ def impacted_files_with_direct_changes(self) -> List[ImpactedFile]:
+ return [file for file in self.files if file.has_diff or not file.has_changes]
+
+ def _fetch_raw_comparison_data(self) -> dict:
+ """
+ Fetches the raw comparison data from storage
+ """
+ repository = self.commit_comparison.compare_commit.repository
+ archive_service = ArchiveService(repository)
+ try:
+ data = archive_service.read_file(self.commit_comparison.report_storage_path)
+ return json.loads(data)
+ except Exception:
+ log.error(
+ "ComparisonReport - couldn't fetch data from storage", exc_info=True
+ )
+ return {}
+
+
+class PullRequestComparison(Comparison):
+ """
+ A Comparison instantiated with a Pull. Contains relevant additional processing
+ required for Pulls, including caching of files-with-changes and support for
+ 'pseudo-comparisons'.
+ """
+
+ def __init__(self, user, pull):
+ self.pull = pull
+ super().__init__(
+ user=user,
+ # these are lazy loaded in the property methods below
+ base_commit=None,
+ head_commit=None,
+ )
+
+ # TODO: try using the dataloader to fetch the commits before you create this class, and pass those commits
+ # to the constructor
+ @cached_property
+ def base_commit(self):
+ try:
+ return Commit.objects.defer("_report").get(
+ repository=self.pull.repository,
+ commitid=self.pull.compared_to
+ if self.is_pseudo_comparison
+ else self.pull.base,
+ )
+ except Commit.DoesNotExist:
+ raise MissingComparisonCommit("Missing base commit")
+
+ @cached_property
+ def head_commit(self):
+ try:
+ return Commit.objects.defer("_report").get(
+ repository=self.pull.repository, commitid=self.pull.head
+ )
+ except Commit.DoesNotExist:
+ raise MissingComparisonCommit("Missing head commit")
+
+ @cached_property
+ def _files_with_changes_hash_key(self):
+ return "/".join(
+ (
+ "compare-changed-files",
+ self.pull.repository.author.service,
+ self.pull.repository.author.username,
+ self.pull.repository.name,
+ f"{self.pull.pullid}",
+ )
+ )
+
+ @cached_property
+ def _files_with_changes(self):
+ try:
+ key = self._files_with_changes_hash_key
+ changes = json.loads(redis.get(key) or json.dumps(None))
+ log.info(
+ f"Found {len(changes) if changes else 0} files with changes in cache.",
+ extra=dict(repoid=self.pull.repository.repoid, pullid=self.pull.pullid),
+ )
+ return changes
+ except OSError as e:
+ log.warning(
+ f"Error connecting to redis: {e}",
+ extra=dict(repoid=self.pull.repository.repoid, pullid=self.pull.pullid),
+ )
+
+ def _set_files_with_changes_in_cache(self, files_with_changes):
+ redis.set(
+ self._files_with_changes_hash_key,
+ json.dumps(files_with_changes),
+ ex=86400, # 1 day in seconds
+ )
+ log.info(
+ f"Stored {len(files_with_changes)} files with changes in cache",
+ extra=dict(repoid=self.pull.repository.repoid, pullid=self.pull.pullid),
+ )
+
+ @cached_property
+ def files(self):
+ """
+ Overrides the 'files' property to do additional caching of
+ 'files_with_changes', for future performance improvements.
+ """
+ files_with_changes = []
+ for file_comparison in super().files:
+ if file_comparison.change_summary:
+ files_with_changes.append(file_comparison.name["head"])
+ yield file_comparison
+ self._set_files_with_changes_in_cache(files_with_changes)
+
+ def get_file_comparison(self, file_name, with_src=False, bypass_max_diff=False):
+ """
+ Overrides the 'get_file_comparison' method to set the "should_search_for_changes"
+ field.
+ """
+ file_comparison = super().get_file_comparison(
+ file_name, with_src=with_src, bypass_max_diff=bypass_max_diff
+ )
+ file_comparison.should_search_for_changes = (
+ file_name in self._files_with_changes
+ if self._files_with_changes is not None
+ else None
+ )
+ return file_comparison
+
+ @cached_property
+ def is_pseudo_comparison(self):
+ """
+ Returns True if this comparison is a pseudo-comparison, False if not.
+
+ Depends on
+ 1) The repository yaml or app yaml settings allow pseudo_comparisons
+ 2) the pull request's 'compared_to' field is defined
+ """
+ return walk(
+ _dict=self.pull.repository.yaml,
+ keys=("codecov", "allow_pseudo_compare"),
+ _else=get_config(("site", "codecov", "allow_pseudo_compare"), default=True),
+ ) and bool(self.pull.compared_to)
+
+ @cached_property
+ def pseudo_diff(self):
+ """
+ Returns the diff between the 'self.pull.compared_to' field and the
+ 'self.pull.base' field.
+ """
+ adapter = RepoProviderService().get_adapter(self.user, self.pull.repository)
+ return async_to_sync(adapter.get_compare)(
+ self.pull.compared_to, self.pull.base
+ )["diff"]
+
+ @cached_property
+ def pseudo_diff_adjusts_tracked_lines(self):
+ """
+ Returns True if we are doing a pull request pseudo-comparison, and tracked
+ lines have changed between the pull's 'base' and 'compared_to' fields. This
+ signifies an error-condition for the comparison, I think because if tracked lines
+ have been adjusted between the 'base' and 'compared_to' commits, the 'compared_to'
+ report can't be substituted for the 'base' report, since it will throw off the
+ unexpected coverage change results. If `self.allow_coverage_offests` is True,
+ client code can adjust the lines in the base report according to the diff
+ with `self.update_base_report_with_pseudo_diff'.
+
+ Ported from the block at: https://github.com/codecov/codecov.io/blob/master/app/handlers/compare.py#L137
+ """
+ if (
+ self.is_pseudo_comparison
+ and self.pull.base != self.pull.compared_to
+ and self.base_report is not None
+ and self.head_report is not None
+ ):
+ if self.pseudo_diff and self.pseudo_diff.get("files"):
+ return self.base_report.does_diff_adjust_tracked_lines(
+ self.pseudo_diff,
+ future_report=self.head_report,
+ future_diff=self.git_comparison["diff"],
+ )
+ return False
+
+ def update_base_report_with_pseudo_diff(self):
+ self.base_report.shift_lines_by_diff(self.pseudo_diff, forward=True)
+
+
+class CommitComparisonService:
+ """
+ Utilities for determining whether a commit comparison needs to be recomputed
+ (and enqueueing that recompute when necessary), and fetching associated comparisons
+ for pulls
+ """
+
+ def __init__(self, commit_comparison: CommitComparison):
+ self.commit_comparison = commit_comparison
+
+ @cached_property
+ def base_commit(self):
+ if "base_commit" not in self.commit_comparison._state.fields_cache:
+ # base_commit is already preloaded
+ self.commit_comparison.base_commit = self._load_commit(
+ self.commit_comparison.base_commit_id
+ )
+ return self.commit_comparison.base_commit
+
+ @cached_property
+ def compare_commit(self):
+ if "compare_commit" not in self.commit_comparison._state.fields_cache:
+ # compare_commit is already preloaded
+ self.commit_comparison.compare_commit = self._load_commit(
+ self.commit_comparison.compare_commit_id
+ )
+ return self.commit_comparison.compare_commit
+
+ def needs_recompute(self) -> bool:
+ if self._last_updated_before(self.compare_commit.updatestamp):
+ return True
+
+ if self._last_updated_before(self.base_commit.updatestamp):
+ return True
+
+ return False
+
+ def _last_updated_before(self, timestamp: datetime) -> bool:
+ """
+ Returns true if the given timestamp occurred after the commit comparison's last update
+ """
+ timezone = pytz.utc
+ if not timestamp:
+ return False
+
+ if timestamp.tzinfo is None:
+ timestamp = timezone.localize(timestamp)
+ else:
+ timestamp = timezone.normalize(timestamp)
+
+ return timezone.normalize(self.commit_comparison.updated_at) < timestamp
+
+ def _load_commit(self, commit_id: int) -> Optional[Commit]:
+ prefetch = Prefetch(
+ "reports",
+ queryset=CommitReport.objects.coverage_reports().filter(code=None),
+ )
+ return (
+ Commit.objects.filter(pk=commit_id)
+ .prefetch_related(prefetch)
+ .defer("_report")
+ .first()
+ )
+
+ @staticmethod
+ def get_commit_comparison_for_pull(obj: Pull) -> Optional[CommitComparison]:
+ comparison_qs = CommitComparison.objects.filter(
+ base_commit__commitid=obj.compared_to,
+ compare_commit__commitid=obj.head,
+ base_commit__repository_id=obj.repository_id,
+ compare_commit__repository_id=obj.repository_id,
+ ).select_related("compare_commit", "base_commit")
+ return comparison_qs.first()
+
+ @classmethod
+ def fetch_precomputed(self, repo_id: int, keys: List[Tuple]) -> QuerySet:
+ comparison_table = CommitComparison._meta.db_table
+ commit_table = Commit._meta.db_table
+ queryset = CommitComparison.objects.raw(
+ f"""
+ select
+ {comparison_table}.*,
+ base_commit.commitid as base_commitid,
+ compare_commit.commitid as compare_commitid
+ from {comparison_table}
+ inner join {commit_table} base_commit
+ on base_commit.id = {comparison_table}.base_commit_id and base_commit.repoid = {repo_id}
+ inner join {commit_table} compare_commit
+ on compare_commit.id = {comparison_table}.compare_commit_id and compare_commit.repoid = {repo_id}
+ where (base_commit.commitid, compare_commit.commitid) in %s
+ """,
+ [tuple(keys)],
+ )
+
+ # we need to make sure we're performing the query against the primary database
+ # (and not the read replica) since we may have just inserted new comparisons
+ # that we'd like to ensure are returned here
+ return queryset.using("default")
diff --git a/apps/codecov-api/services/components.py b/apps/codecov-api/services/components.py
new file mode 100644
index 0000000000..afd0be6e4d
--- /dev/null
+++ b/apps/codecov-api/services/components.py
@@ -0,0 +1,135 @@
+from datetime import datetime
+from typing import Any, Dict, Iterable, List, Optional
+
+from django.utils.functional import cached_property
+from shared.components import Component
+from shared.reports.filtered import FilteredReport
+from shared.reports.resources import Report
+from shared.reports.types import ReportTotals
+
+from codecov_auth.models import Owner
+from core.models import Commit
+from services.comparison import Comparison
+from services.yaml import final_commit_yaml
+from timeseries.helpers import fill_sparse_measurements
+from timeseries.models import Interval
+
+
+def commit_components(commit: Commit, owner: Owner | None) -> List[Component]:
+ """
+ Get the list of components for a commit.
+ A request is made to the provider on behalf of the given `owner`
+ to fetch the commit YAML (from which component config is parsed).
+ """
+ yaml = final_commit_yaml(commit, owner)
+ return yaml.get_components()
+
+
+def component_filtered_report(
+ report: Report, components: List[Component]
+) -> FilteredReport:
+ """
+ Filter a report such that the totals, etc. are only pertaining to the given component.
+ """
+ flags, paths = [], []
+ report_flags = report.get_flag_names() if report else []
+ for component in components:
+ flags.extend(component.get_matching_flags(report_flags))
+ paths.extend(component.paths)
+ filtered_report = report.filter(flags=flags, paths=paths)
+ return filtered_report
+
+
+def filter_components_by_name_or_id(
+ components: List[Component], terms: List[str]
+) -> List[Component]:
+ """
+ Given a list of Components and a list of strings (terms),
+ return a new list of Components only including Components with names in terms (case insensitive)
+ OR component_id in terms (case insensitive)
+ """
+ terms = [v.lower() for v in terms]
+ return [
+ component
+ for component in components
+ if component.name.lower() in terms or component.component_id.lower() in terms
+ ]
+
+
+class ComponentComparison:
+ def __init__(self, comparison: Comparison, component: Component):
+ self.comparison = comparison
+ self.component = component
+
+ @cached_property
+ def base_report(self) -> FilteredReport:
+ return component_filtered_report(self.comparison.base_report, [self.component])
+
+ @cached_property
+ def head_report(self) -> FilteredReport:
+ return component_filtered_report(self.comparison.head_report, [self.component])
+
+ @cached_property
+ def base_totals(self) -> ReportTotals:
+ return self.base_report.totals
+
+ @cached_property
+ def head_totals(self) -> ReportTotals:
+ return self.head_report.totals
+
+ @cached_property
+ def patch_totals(self) -> ReportTotals:
+ git_comparison = self.comparison.git_comparison
+ return self.head_report.apply_diff(git_comparison["diff"])
+
+
+class ComponentMeasurements:
+ def __init__(
+ self,
+ raw_measurements: List[dict],
+ component_id: str,
+ interval: Interval,
+ after: datetime,
+ before: datetime,
+ last_measurement: datetime,
+ components_mapping: Dict[str, str],
+ ):
+ self.raw_measurements = raw_measurements
+ self.component_id = component_id
+ self.interval = interval
+ self.after = after
+ self.before = before
+ self.last_measurement = last_measurement
+ self.components_mapping = components_mapping
+
+ @cached_property
+ def name(self) -> str:
+ if self.components_mapping.get(self.component_id):
+ return self.components_mapping[self.component_id]
+ return self.component_id
+
+ @cached_property
+ def component_id(self) -> str:
+ return self.component_id
+
+ @cached_property
+ def percent_covered(self) -> Optional[float]:
+ if len(self.raw_measurements) > 0:
+ return self.raw_measurements[-1]["avg"]
+
+ @cached_property
+ def percent_change(self) -> Optional[float]:
+ if len(self.raw_measurements) > 1:
+ return self.raw_measurements[-1]["avg"] - self.raw_measurements[0]["avg"]
+
+ @cached_property
+ def measurements(self) -> Iterable[Dict[str, Any]]:
+ if not self.raw_measurements:
+ return []
+ return fill_sparse_measurements(
+ self.raw_measurements, self.interval, self.after, self.before
+ )
+
+ @cached_property
+ def last_uploaded(self) -> datetime:
+ return self.last_measurement
diff --git a/apps/codecov-api/services/decorators.py b/apps/codecov-api/services/decorators.py
new file mode 100644
index 0000000000..47244ae2f9
--- /dev/null
+++ b/apps/codecov-api/services/decorators.py
@@ -0,0 +1,39 @@
+import stripe
+from rest_framework.exceptions import APIException
+from shared.torngit.exceptions import TorngitClientError
+
+
+def torngit_safe(method):
+ """
+ Translatess torngit exceptions into DRF APIExceptions.
+ For use in DRF views.
+ """
+
+ def exec_method(*args, **kwargs):
+ try:
+ return method(*args, **kwargs)
+ except TorngitClientError as e:
+ exception = APIException(detail=e.message)
+ exception.status_code = e.code
+ raise exception
+
+ # This is needed to decorate custom DRF viewset actions
+ exec_method.__name__ = method.__name__
+ return exec_method
+
+
+def stripe_safe(method):
+ """
+ Translates stripe-api errors into DRF APIExceptions.
+ """
+
+ def exec_method(*args, **kwargs):
+ try:
+ return method(*args, **kwargs)
+ except stripe.StripeError as e:
+ exception = APIException(detail=e.user_message)
+ exception.status_code = e.http_status
+ raise exception
+
+ exec_method.__name__ = method.__name__
+ return exec_method
diff --git a/apps/codecov-api/services/path.py b/apps/codecov-api/services/path.py
new file mode 100644
index 0000000000..ea9295464f
--- /dev/null
+++ b/apps/codecov-api/services/path.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from dataclasses import dataclass
+from functools import cached_property
+from typing import Iterable
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from shared.reports.filtered import FilteredReport, FilteredReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportTotals
+from shared.torngit.exceptions import TorngitClientError
+
+from codecov_auth.models import Owner
+from core.models import Commit
+from services.repo_providers import RepoProviderService
+
+
+class PathNode:
+ """
+ Generic node in a file/directory tree that has coverage totals.
+ Expects a `totals: ReportTotals` attribute to be set.
+ """
+
+ @property
+ def name(self) -> str:
+ return self.full_path.split("/")[-1]
+
+ @property
+ def lines(self) -> int:
+ return self.totals.lines or 0
+
+ @property
+ def hits(self) -> int:
+ return self.totals.hits or 0
+
+ @property
+ def partials(self) -> int:
+ return self.totals.partials or 0
+
+ @property
+ def misses(self) -> int:
+ return self.totals.misses or 0
+
+ @property
+ def coverage(self) -> float:
+ if self.lines > 0:
+ return float(self.hits / self.lines) * 100
+ else:
+ return 0.0
+
+
+@dataclass
+class File(PathNode):
+ """
+ File node in a file/directory tree.
+ """
+
+ full_path: str
+ totals: ReportTotals
+
+
+@dataclass
+class Dir(PathNode):
+ """
+ Directory node in a file/directory tree.
+ """
+
+ full_path: str
+ children: list[File | Dir]
+
+ @cached_property
+ def totals(self) -> ReportTotals:
+ # A dir's totals are sum of its children's totals
+ totals = ReportTotals.default_totals()
+ for child in self.children:
+ totals.lines += child.lines
+ totals.hits += child.hits
+ totals.partials += child.partials
+ totals.misses += child.misses
+ return totals
+
+
+@dataclass
+class PrefixedPath:
+ full_path: str
+ prefix: str
+
+ @property
+ def relative_path(self) -> str:
+ """
+ The path relative to the `prefix`. For example, if `full_path`
+ is `a/b/c/d.txt` and `prefix` is `a/b` then this method would return `c/d.txt`.
+ """
+ if not self.prefix:
+ return self.full_path
+ else:
+ return self.full_path.removeprefix(f"{self.prefix}/")
+
+ @property
+ def is_file(self) -> bool:
+ parts = self.relative_path.split("/")
+ return len(parts) == 1
+
+ @property
+ def basename(self) -> str:
+ """
+ The base path name (including the prefix). For example, if `full_path`
+ is `a/b/c/d.txt` and `prefix` is `a/b` then this method would return `a/b/c`.
+ """
+ name = self.relative_path.split("/", 1)[0]
+ if self.prefix:
+ return f"{self.prefix}/{name}"
+ else:
+ return name
+
+
+def is_subpath(full_path: str, subpath: str) -> bool:
+ if not subpath:
+ return True
+ return full_path.startswith(f"{subpath}/") or full_path == subpath
+
+
+def dashboard_commit_file_url(
+ path: str | None,
+ service: str,
+ owner: str,
+ repo: str,
+ commit: Commit,
+) -> str:
+ if path is None:
+ path = ""
+ report = commit.full_report
+ is_file = report and path in report.files
+ commit_path = f"blob/{path}" if is_file else f"tree/{path}"
+ return f"{settings.CODECOV_DASHBOARD_URL}/{service}/{owner}/{repo}/commit/{commit.commitid}/{commit_path}"
+
+
+class ReportPaths:
+ """
+ Contains methods for getting path information out of a single report.
+ """
+
+ @sentry_sdk.trace
+ def __init__(
+ self,
+ report: Report,
+ path: str | None = None,
+ search_term: str | None = None,
+ filter_flags: list[str] = None,
+ filter_paths: list[str] = None,
+ ):
+ self.report: Report | FilteredReport = report
+ self.filter_flags = filter_flags or []
+ self.filter_paths = filter_paths or []
+ self.prefix = path or ""
+
+ # Filter report if flags or paths exist
+ if self.filter_flags or self.filter_paths:
+ self.report = self.report.filter(
+ paths=self.filter_paths, flags=self.filter_flags
+ )
+
+ self._paths = [
+ PrefixedPath(full_path=full_path, prefix=self.prefix)
+ for full_path in self.files
+ if is_subpath(full_path, self.prefix)
+ ]
+
+ if search_term:
+ search_term = search_term.lower()
+ self._paths = [
+ path
+ for path in self._paths
+ if search_term in path.relative_path.lower()
+ ]
+
+ @cached_property
+ def files(self) -> list[str]:
+ # No flags filtering, just return (path-filtered) files in Report
+ if not self.filter_flags:
+ return self.report.files
+
+ # When there is a flag filter, `FilteredReport` currently yields
+ # `FilteredReportFile`s without actually checking whether they match the sessions.
+ # Before that bug is fixed, lets do the filtering manually here. Once that bug is fixed,
+ # this should just forward to `self.report.files` like above.
+ files = []
+ for file in self.report:
+ if isinstance(file, FilteredReportFile):
+ found = False
+ for _ln, line in file.lines:
+ if line and any(s.id in file.session_ids for s in line.sessions):
+ found = True
+ break
+ if not found:
+ continue
+
+ files.append(file.name)
+ return files
+
+ @property
+ def paths(self) -> list[PrefixedPath]:
+ return self._paths
+
+ @sentry_sdk.trace
+ def full_filelist(self) -> list[File | Dir]:
+ """
+ Return a flat file list of all files under the specified `path` prefix/directory.
+ """
+ return [
+ File(full_path=path.full_path, totals=self._totals(path))
+ for path in self.paths
+ ]
+
+ @sentry_sdk.trace
+ def single_directory(self) -> list[File | Dir]:
+ """
+ Return a single directory (specified by `path`) of mixed file/directory results.
+ """
+ return self._single_directory_recursive(self.paths)
+
+ def _totals(self, path: PrefixedPath) -> ReportTotals:
+ """
+ Returns the report totals for a given prefixed path.
+ """
+ # Fixes an issue when filtering by flags does not work in the case where
+ # one flag covers half of the file and another flag covers another half.
+ # Using get_file_totals will return the totals for coverage of all flags
+ # applied to the file instead of just the filter flags being queried
+ if self.filter_flags:
+ return self.report.get(path.full_path).totals
+ else:
+ return self.report.get_file_totals(path.full_path)
+
+ def _single_directory_recursive(
+ self, paths: Iterable[PrefixedPath]
+ ) -> list[File | Dir]:
+ grouped = defaultdict(list)
+ for path in paths:
+ grouped[path.basename].append(path)
+
+ results: list[File | Dir] = []
+
+ for basename, paths in grouped.items():
+ if len(paths) == 1 and paths[0].is_file:
+ path = paths[0]
+ results.append(
+ File(full_path=path.full_path, totals=self._totals(path))
+ )
+ else:
+ children = self._single_directory_recursive(
+ PrefixedPath(full_path=path.full_path, prefix=basename)
+ for path in paths
+ )
+ results.append(Dir(full_path=basename, children=children))
+
+ return results
+
+
+def provider_path_exists(path: str, commit: Commit, owner: Owner) -> bool | None:
+ """
+ Check whether the given path exists on the provider.
+ """
+ try:
+ adapter = RepoProviderService().get_adapter(owner, commit.repository)
+ async_to_sync(adapter.list_files)(commit.commitid, path)
+ return True
+ except TorngitClientError as e:
+ if e.code == 404:
+ return False
+ else:
+ # more generic error from provider
+ return None
diff --git a/apps/codecov-api/services/refresh.py b/apps/codecov-api/services/refresh.py
new file mode 100644
index 0000000000..c2fc920875
--- /dev/null
+++ b/apps/codecov-api/services/refresh.py
@@ -0,0 +1,49 @@
+from json import dumps, loads
+
+from celery.result import result_from_tuple
+from shared.helpers.redis import get_redis_connection
+
+from services.task import TaskService, celery_app
+
+
+class RefreshService(object):
+ def __init__(self):
+ self.task_service = TaskService()
+ self.redis = get_redis_connection()
+
+ def _get_key_name(self, ownerid):
+ return f"refresh_{ownerid}"
+
+ def is_refreshing(self, ownerid):
+ key_name = self._get_key_name(ownerid)
+ data_task = self.redis.get(key_name)
+ if not data_task:
+ return False
+ try:
+ res = result_from_tuple(loads(data_task), app=celery_app)
+ except ValueError:
+ self.redis.delete(key_name)
+ return False
+ has_failed = res.failed() or (res.parent and res.parent.failed())
+ if res.successful() or has_failed:
+ self.redis.delete(key_name)
+ return False
+ # task is not success, nor failed, so probably pending or in progress
+ return True
+
+ def trigger_refresh(
+ self,
+ ownerid,
+ username,
+ sync_teams=True,
+ sync_repos=True,
+ using_integration=False,
+ manual_trigger=False,
+ ):
+ if self.is_refreshing(ownerid):
+ return
+ resp = self.task_service.refresh(
+ ownerid, username, sync_repos, sync_teams, using_integration, manual_trigger
+ )
+ # store in redis the task data to be used for `is_refreshing` logic
+ self.redis.setex(self._get_key_name(ownerid), 900, dumps(resp.as_tuple()))
diff --git a/apps/codecov-api/services/repo_providers.py b/apps/codecov-api/services/repo_providers.py
new file mode 100644
index 0000000000..da25e7cf33
--- /dev/null
+++ b/apps/codecov-api/services/repo_providers.py
@@ -0,0 +1,200 @@
+import logging
+from os import getenv
+from typing import Callable, Dict, Optional
+
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from shared.encryption.token import encode_token
+from shared.torngit import get
+
+from codecov_auth.models import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+ Owner,
+ Service,
+)
+from core.models import Repository
+from utils.config import get_config
+from utils.encryption import encryptor
+
+log = logging.getLogger(__name__)
+
+
+class TorngitInitializationFailed(Exception):
+ """
+ Exception when initializing the torngit provider object.
+ """
+
+ pass
+
+
+def get_token_refresh_callback(
+ owner: Optional[Owner], service: Service
+) -> Callable[[Dict], None]:
+ """
+ Produces a callback function that will encode and update the oauth token of an owner.
+ This callback is passed to the TorngitAdapter for the service.
+ """
+ if owner is None:
+ return None
+ if service == Service.BITBUCKET or service == Service.BITBUCKET_SERVER:
+ return None
+
+ @sync_to_async
+ def callback(new_token: Dict) -> None:
+ log.info(
+ "Saving new token after refresh",
+ extra=dict(owner=owner.username, ownerid=owner.ownerid),
+ )
+ string_to_save = encode_token(new_token)
+ owner.oauth_token = encryptor.encode(string_to_save).decode()
+ owner.save()
+
+ return callback
+
+
+def get_generic_adapter_params(
+ owner: Optional[Owner], service, use_ssl=False, token=None
+):
+ if use_ssl:
+ verify_ssl = (
+ get_config(service, "ssl_pem")
+ if get_config(service, "verify_ssl") is not False
+ else getenv("REQUESTS_CA_BUNDLE")
+ )
+ else:
+ verify_ssl = None
+
+ if token is None:
+ if owner is not None and owner.oauth_token is not None:
+ token = encryptor.decrypt_token(owner.oauth_token)
+ token["username"] = owner.username
+ else:
+ token = {"key": getattr(settings, f"{service.upper()}_BOT_KEY")}
+ return dict(
+ verify_ssl=verify_ssl,
+ token=token,
+ timeouts=(5, 15),
+ oauth_consumer_token=dict(
+ key=getattr(settings, f"{service.upper()}_CLIENT_ID", "unknown"),
+ secret=getattr(settings, f"{service.upper()}_CLIENT_SECRET", "unknown"),
+ ),
+ # By checking the "username" in token we can know if the token belongs to an Owner
+ # We only try to refresh user-to-server tokens (e.g. belongs to owner)
+ on_token_refresh=(
+ get_token_refresh_callback(owner, service) if "username" in token else None
+ ),
+ )
+
+
+def get_provider(service, adapter_params):
+ provider = get(service, **adapter_params)
+ if provider:
+ return provider
+ else:
+ raise TorngitInitializationFailed()
+
+
+def get_ghapp_default_installation(
+ owner: Optional[Owner],
+) -> Optional[GithubAppInstallation]:
+ if owner is None or owner.service not in [
+ Service.GITHUB.value,
+ Service.GITHUB_ENTERPRISE.value,
+ ]:
+ return None
+ return owner.github_app_installations.filter(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ ).first()
+
+
+async def async_get_ghapp_default_installation(
+ owner: Optional[Owner],
+) -> Optional[GithubAppInstallation]:
+ if owner is None or owner.service not in [
+ Service.GITHUB.value,
+ Service.GITHUB_ENTERPRISE.value,
+ ]:
+ return None
+ return await owner.github_app_installations.filter(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ ).afirst()
+
+
+class RepoProviderService(object):
+ def _is_using_integration(
+ self, ghapp_installation: GithubAppInstallation, repo: Repository
+ ) -> bool:
+ if ghapp_installation:
+ return ghapp_installation.is_repo_covered_by_integration(repo)
+ return repo.using_integration
+
+ async def async_get_adapter(
+ self, owner: Optional[Owner], repo: Repository, use_ssl=False, token=None
+ ):
+ ghapp = await async_get_ghapp_default_installation(owner)
+ return self._get_adapter(owner, repo, ghapp=ghapp)
+
+ def get_adapter(
+ self, owner: Optional[Owner], repo: Repository, use_ssl=False, token=None
+ ):
+ ghapp = get_ghapp_default_installation(owner)
+ return self._get_adapter(owner, repo, ghapp=ghapp, token=token)
+
+ def _get_adapter(
+ self,
+ owner: Optional[Owner],
+ repo: Repository,
+ use_ssl=False,
+ token=None,
+ ghapp=None,
+ ):
+ """
+ Return the corresponding implementation for calling the repository provider
+
+ :param owner: :class:`codecov_auth.models.Owner`
+ :param repo: :class:`core.models.Repository`
+ :return:
+ :raises: TorngitInitializationFailed
+ """
+ generic_adapter_params = get_generic_adapter_params(
+ owner, repo.author.service, use_ssl, token
+ )
+
+ owner_and_repo_params = {
+ "repo": {
+ "name": repo.name,
+ "using_integration": self._is_using_integration(ghapp, repo),
+ "service_id": repo.service_id,
+ "private": repo.private,
+ "repoid": repo.repoid,
+ },
+ "owner": {
+ "username": repo.author.username,
+ "service_id": repo.author.service_id,
+ },
+ }
+
+ return get_provider(
+ repo.author.service, {**generic_adapter_params, **owner_and_repo_params}
+ )
+
+ def get_by_name(self, owner, repo_name, repo_owner_username, repo_owner_service):
+ """
+ Return the corresponding implementation for calling the repository provider
+
+ :param owner: Owner object of the user
+ :param repo_name: string, name of the repo
+ :param owner: Owner, owner of the repo in question
+ :repo_owner_service: 'github', 'gitlab' etc
+ :return:
+ :raises: TorngitInitializationFailed
+ """
+ generic_adapter_params = get_generic_adapter_params(owner, repo_owner_service)
+ owner_and_repo_params = {
+ "repo": {"name": repo_name},
+ "owner": {"username": repo_owner_username},
+ }
+ return get_provider(
+ repo_owner_service, {**generic_adapter_params, **owner_and_repo_params}
+ )
diff --git a/apps/codecov-api/services/report.py b/apps/codecov-api/services/report.py
new file mode 100644
index 0000000000..4e768c0646
--- /dev/null
+++ b/apps/codecov-api/services/report.py
@@ -0,0 +1,31 @@
+from shared.reports.resources import Report
+
+
+def files_belonging_to_flags(commit_report: Report, flags: list[str]) -> list[str]:
+ flags_set = set(flags)
+ session_ids = {
+ sid
+ for sid, session in commit_report.sessions.items()
+ if session.flags and not set(session.flags).isdisjoint(flags_set)
+ }
+ files_in_specific_sessions = files_in_sessions(
+ commit_report=commit_report, session_ids=session_ids
+ )
+ return files_in_specific_sessions
+
+
+def files_in_sessions(commit_report: Report, session_ids: set[int]) -> list[str]:
+ files = []
+ for file in commit_report:
+ found = False
+ for line in file:
+ if line:
+ for session in line.sessions:
+ if session.id in session_ids:
+ found = True
+ break
+ if found:
+ break
+ if found:
+ files.append(file.name)
+ return files
diff --git a/apps/codecov-api/services/self_hosted.py b/apps/codecov-api/services/self_hosted.py
new file mode 100644
index 0000000000..e2b5273873
--- /dev/null
+++ b/apps/codecov-api/services/self_hosted.py
@@ -0,0 +1,180 @@
+import operator
+from functools import reduce
+from typing import Optional
+
+from django.conf import settings
+from django.db import transaction
+from django.db.models import F, Func, Q, QuerySet
+from shared.license import get_current_license
+
+from codecov_auth.models import Owner
+from services import ServiceException
+from utils.config import get_config
+
+
+class LicenseException(ServiceException):
+ def __init__(self, message: str):
+ self.message = message
+ super().__init__(message)
+
+
+def admin_owners() -> QuerySet:
+ """
+ Returns a queryset of admin owners based on the YAML config:
+
+ setup:
+ admins:
+ - service:
+ username:
+ - ...
+ """
+ admins = get_config("setup", "admins", default=[])
+
+ filters = [
+ Q(service=admin["service"], username=admin["username"])
+ for admin in admins
+ if "service" in admin and "username" in admin
+ ]
+
+ if len(filters) == 0:
+ return Owner.objects.none()
+ else:
+ return Owner.objects.filter(reduce(operator.or_, filters))
+
+
+def is_admin_owner(owner: Optional[Owner]) -> bool:
+ """
+ Returns true if the given owner is an admin.
+ """
+ return owner is not None and admin_owners().filter(pk=owner.pk).exists()
+
+
+def activated_owners() -> QuerySet:
+ """
+ Returns all owners that are activated in ANY org's `plan_activated_users`
+ across the entire instance.
+ """
+ owner_ids = (
+ Owner.objects.annotate(
+ plan_activated_owner_ids=Func(
+ F("plan_activated_users"),
+ function="unnest",
+ )
+ )
+ .values_list("plan_activated_owner_ids", flat=True)
+ .distinct()
+ )
+ return Owner.objects.filter(pk__in=owner_ids)
+
+
+def is_activated_owner(owner: Owner) -> bool:
+ """
+ Returns true if the given owner is activated in this instance.
+ """
+ return activated_owners().filter(pk=owner.pk).exists()
+
+
+def license_seats() -> int:
+ """
+ Max number of seats allowed by the current license.
+ """
+ enterprise_license = get_current_license()
+ if not enterprise_license.is_valid:
+ return 0
+ return enterprise_license.number_allowed_users or 0
+
+
+def enterprise_has_seats_left() -> bool:
+ """
+ The activated_owner_query is heavy, so check the license first, only proceed if they have a valid license.
+ """
+ license_seat_count = license_seats()
+ if license_seat_count == 0:
+ return False
+ owners = activated_owners()
+ count = owners.count()
+ return count < license_seat_count
+
+
+def can_activate_owner(owner: Owner) -> bool:
+ """
+ Returns true if there are available seats left for activation.
+ """
+ if is_activated_owner(owner):
+ # user is already activated in at least 1 org
+ return True
+ else:
+ return activated_owners().count() < license_seats()
+
+
+@transaction.atomic
+def activate_owner(owner: Owner):
+ """
+ Activate the given owner in ALL orgs that the owner is a part of.
+ """
+ if not settings.IS_ENTERPRISE:
+ raise Exception("activate_owner is only available in self-hosted environments")
+
+ if not can_activate_owner(owner):
+ raise LicenseException(
+ "No seats remaining. Please contact Codecov support or deactivate users."
+ )
+
+ Owner.objects.filter(pk__in=owner.organizations).update(
+ plan_activated_users=Func(
+ owner.pk,
+ function="array_append_unique",
+ template="%(function)s(plan_activated_users, %(expressions)s)",
+ )
+ )
+
+
+def deactivate_owner(owner: Owner):
+ """
+ Deactivate the given owner across ALL orgs.
+ """
+ if not settings.IS_ENTERPRISE:
+ raise Exception(
+ "deactivate_owner is only available in self-hosted environments"
+ )
+
+ Owner.objects.filter(
+ plan_activated_users__contains=Func(
+ owner.pk,
+ function="array",
+ template="%(function)s[%(expressions)s]",
+ )
+ ).update(
+ plan_activated_users=Func(
+ owner.pk,
+ function="array_remove",
+ template="%(function)s(plan_activated_users, %(expressions)s)",
+ )
+ )
+
+
+def enable_autoactivation():
+ """
+ Enable auto-activation for the entire instance.
+
+ There's no good place to store this instance-wide so we're just saving this
+ for all owners.
+ """
+ Owner.objects.all().update(plan_auto_activate=True)
+
+
+def disable_autoactivation():
+ """
+ Disable auto-activation for the entire instance.
+
+ There's no good place to store this instance-wide so we're just saving this
+ for all owners.
+ """
+ Owner.objects.all().update(plan_auto_activate=False)
+
+
+def is_autoactivation_enabled():
+ """
+ Returns true if ANY org has auto-activation enabled.
+ """
+ return Owner.objects.filter(plan_auto_activate=True).exists()
diff --git a/apps/codecov-api/services/sentry.py b/apps/codecov-api/services/sentry.py
new file mode 100644
index 0000000000..f78d3e7b1b
--- /dev/null
+++ b/apps/codecov-api/services/sentry.py
@@ -0,0 +1,139 @@
+import json
+import logging
+from typing import Optional
+
+import jwt
+from django.conf import settings
+from django.db.utils import IntegrityError
+
+from codecov_auth.models import Owner
+from services.task import TaskService
+
+log = logging.getLogger(__name__)
+
+
+class SentryError(Exception):
+ pass
+
+
+class SentryInvalidStateError(SentryError):
+ """
+ The Sentry `state` JWT was malformed or not signed properly.
+ """
+
+ pass
+
+
+class SentryUserAlreadyExistsError(SentryError):
+ """
+ The Sentry `user_id` was already claimed by a Codecov owner.
+ """
+
+ pass
+
+
+class SentryState:
+ def __init__(self, data: dict):
+ self.data = data
+
+ @property
+ def user_id(self) -> Optional[str]:
+ return self.data.get("user_id")
+
+
+def decode_state(state: str) -> Optional[SentryState]:
+ """
+ Decode the given state (a JWT) using our shared secret.
+ Returns `None` if the state could not be decoded.
+ """
+ secret = settings.SENTRY_JWT_SHARED_SECRET
+ try:
+ data = jwt.decode(state, secret, algorithms=["HS256"])
+ return SentryState(data)
+ except jwt.exceptions.InvalidSignatureError:
+ # signed with a different secret
+ log.error(
+ "Sentry state has invalid signature",
+ extra=dict(sentry_state=state),
+ )
+ return None
+ except jwt.exceptions.DecodeError:
+ # malformed JWT
+ log.error(
+ "Sentry state is malformed",
+ extra=dict(sentry_state=state),
+ )
+ return None
+
+
+def save_sentry_state(owner: Owner, encoded_state: str):
+ """
+ If the given state decodes successfully then save it with the owner.
+ """
+ decoded_state = decode_state(encoded_state)
+ if decoded_state is None:
+ log.error(
+ "Invalid Sentry state",
+ extra=dict(owner_id=owner.pk, sentry_state=encoded_state),
+ )
+ raise SentryInvalidStateError()
+
+ if decoded_state.user_id is not None:
+ owner.sentry_user_id = decoded_state.user_id
+ owner.sentry_user_data = decoded_state.data
+
+ try:
+ owner.save()
+ except IntegrityError:
+ log.error(
+ "Sentry user already exists",
+ extra=dict(
+ owner_id=owner.pk,
+ sentry_user_id=decoded_state.user_id,
+ sentry_user_data=decoded_state.data,
+ ),
+ )
+ raise SentryUserAlreadyExistsError()
+
+
+def is_sentry_user(owner: Owner) -> bool:
+ """
+ Returns true if the given owner has been linked with a Sentry user.
+ """
+ return owner.sentry_user_id is not None
+
+
+def send_user_webhook(user: Owner, org: Owner):
+ """
+ Sends data back to Sentry about the Sentry <-> Codecov user link.
+ """
+ assert is_sentry_user(user)
+
+ webhook_url = settings.SENTRY_USER_WEBHOOK_URL
+ if webhook_url is None:
+ log.warning("No Sentry webhook URL is configured")
+ return
+
+ state = {
+ "user_id": user.sentry_user_id,
+ "org_id": (user.sentry_user_data or {}).get("org_id"),
+ "codecov_owner_id": user.pk,
+ "codecov_organization_id": org.pk,
+ "service": org.service,
+ "service_id": org.service_id,
+ }
+
+ secret = settings.SENTRY_JWT_SHARED_SECRET
+ encoded_state = jwt.encode(state, secret, algorithm="HS256")
+
+ payload = json.dumps({"state": encoded_state})
+
+ TaskService().http_request(
+ url=webhook_url,
+ method="POST",
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": "Codecov",
+ },
+ data=payload,
+ )
diff --git a/apps/codecov-api/services/task/__init__.py b/apps/codecov-api/services/task/__init__.py
new file mode 100644
index 0000000000..47396c6744
--- /dev/null
+++ b/apps/codecov-api/services/task/__init__.py
@@ -0,0 +1 @@
+from services.task.task import *
diff --git a/apps/codecov-api/services/task/task.py b/apps/codecov-api/services/task/task.py
new file mode 100644
index 0000000000..0c201bb4a2
--- /dev/null
+++ b/apps/codecov-api/services/task/task.py
@@ -0,0 +1,424 @@
+import logging
+from datetime import datetime, timedelta
+from typing import Iterable, List, Optional, Tuple
+
+import celery
+from celery import Celery, chain, group, signature
+from celery.canvas import Signature
+from django.conf import settings
+from sentry_sdk import set_tag
+from sentry_sdk.integrations.celery import _wrap_apply_async
+from shared import celery_config
+
+from core.models import Repository
+from services.task.task_router import route_task
+from timeseries.models import Dataset, MeasurementName
+
+celery_app = Celery("tasks")
+celery_app.config_from_object("shared.celery_config:BaseCeleryConfig")
+
+log = logging.getLogger(__name__)
+
+if settings.SENTRY_ENV:
+ celery.group.apply_async = _wrap_apply_async(celery.group.apply_async)
+ celery.chunks.apply_async = _wrap_apply_async(celery.chunks.apply_async)
+ celery.canvas._chain.apply_async = _wrap_apply_async(
+ celery.canvas._chain.apply_async
+ )
+ celery.canvas._chord.apply_async = _wrap_apply_async(
+ celery.canvas._chord.apply_async
+ )
+ Signature.apply_async = _wrap_apply_async(Signature.apply_async)
+
+
+class TaskService(object):
+ def _create_signature(self, name, args=None, kwargs=None, immutable=False):
+ """
+ Create Celery signature
+ """
+ queue_and_config = route_task(name, args=args, kwargs=kwargs)
+ queue_name = queue_and_config["queue"]
+ extra_config = queue_and_config.get("extra_config", {})
+ celery_compatible_config = {
+ "time_limit": extra_config.get("hard_timelimit", None),
+ "soft_time_limit": extra_config.get("soft_timelimit", None),
+ }
+ headers = dict(created_timestamp=datetime.now().isoformat())
+ set_tag("celery.queue", queue_name)
+ return signature(
+ name,
+ args=args,
+ kwargs=kwargs,
+ app=celery_app,
+ queue=queue_name,
+ headers=headers,
+ immutable=immutable,
+ **celery_compatible_config,
+ )
+
+ def schedule_task(self, task_name, *, kwargs, apply_async_kwargs):
+ return self._create_signature(
+ task_name,
+ kwargs=kwargs,
+ ).apply_async(**apply_async_kwargs)
+
+ def compute_comparison(self, comparison_id):
+ self._create_signature(
+ celery_config.compute_comparison_task_name,
+ kwargs=dict(comparison_id=comparison_id),
+ ).apply_async()
+
+ def compute_comparisons(self, comparison_ids: List[int]):
+ """
+ Enqueue a batch of comparison tasks using a Celery group
+ """
+ if len(comparison_ids) > 0:
+ queue_and_config = route_task(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=comparison_ids[0]),
+ )
+ celery_compatible_config = {
+ "queue": queue_and_config["queue"],
+ "time_limit": queue_and_config.get("extra_config", {}).get(
+ "hard_timelimit", None
+ ),
+ "soft_time_limit": queue_and_config.get("extra_config", {}).get(
+ "soft_timelimit", None
+ ),
+ }
+ signatures = [
+ signature(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=comparison_id),
+ app=celery_app,
+ **celery_compatible_config,
+ )
+ for comparison_id in comparison_ids
+ ]
+ for comparison_id in comparison_ids:
+ # log each separately so it can be filtered easily in the logs
+ log.info(
+ "Triggering compute comparison task",
+ extra=dict(comparison_id=comparison_id),
+ )
+ group(signatures).apply_async()
+
+ def status_set_pending(self, repoid, commitid, branch, on_a_pull_request):
+ self._create_signature(
+ "app.tasks.status.SetPending",
+ kwargs=dict(
+ repoid=repoid,
+ commitid=commitid,
+ branch=branch,
+ on_a_pull_request=on_a_pull_request,
+ ),
+ ).apply_async()
+
+ def upload_signature(
+ self,
+ repoid,
+ commitid,
+ report_type=None,
+ report_code=None,
+ arguments=None,
+ debug=False,
+ rebuild=False,
+ immutable=False,
+ ):
+ return self._create_signature(
+ "app.tasks.upload.Upload",
+ kwargs=dict(
+ repoid=repoid,
+ commitid=commitid,
+ report_type=report_type,
+ report_code=report_code,
+ arguments=arguments,
+ debug=debug,
+ rebuild=rebuild,
+ ),
+ immutable=immutable,
+ )
+
+ def upload(
+ self,
+ repoid,
+ commitid,
+ report_type=None,
+ report_code=None,
+ arguments=None,
+ countdown=0,
+ debug=False,
+ rebuild=False,
+ ):
+ return self.upload_signature(
+ repoid,
+ commitid,
+ report_type=report_type,
+ report_code=report_code,
+ arguments=arguments,
+ debug=debug,
+ rebuild=rebuild,
+ ).apply_async(countdown=countdown)
+
+ def notify_signature(self, repoid, commitid, current_yaml=None, empty_upload=None):
+ return self._create_signature(
+ "app.tasks.notify.Notify",
+ kwargs=dict(
+ repoid=repoid,
+ commitid=commitid,
+ current_yaml=current_yaml,
+ empty_upload=empty_upload,
+ ),
+ )
+
+ def notify(self, repoid, commitid, current_yaml=None, empty_upload=None):
+ self.notify_signature(
+ repoid, commitid, current_yaml=current_yaml, empty_upload=empty_upload
+ ).apply_async()
+
+ def pulls_sync(self, repoid, pullid):
+ self._create_signature(
+ "app.tasks.pulls.Sync", kwargs=dict(repoid=repoid, pullid=pullid)
+ ).apply_async()
+
+ def refresh(
+ self,
+ ownerid,
+ username,
+ sync_teams=True,
+ sync_repos=True,
+ using_integration=False,
+ manual_trigger=False,
+ repos_affected: Optional[List[Tuple[str, str]]] = None,
+ ):
+ """
+ Send sync_teams and/or sync_repos task message
+ If running both tasks on new worker, we create a chain with sync_teams to run
+ first so that when sync_repos starts it has the most up to date teams/groups
+ data for the user. Otherwise, we may miss some repos.
+ """
+ chain_to_call = []
+ if sync_teams:
+ chain_to_call.append(
+ self._create_signature(
+ "app.tasks.sync_teams.SyncTeams",
+ kwargs=dict(
+ ownerid=ownerid,
+ username=username,
+ using_integration=using_integration,
+ ),
+ )
+ )
+
+ if sync_repos:
+ chain_to_call.append(
+ self._create_signature(
+ "app.tasks.sync_repos.SyncRepos",
+ kwargs=dict(
+ ownerid=ownerid,
+ username=username,
+ using_integration=using_integration,
+ manual_trigger=manual_trigger,
+ repository_service_ids=repos_affected,
+ ),
+ )
+ )
+
+ return chain(*chain_to_call).apply_async()
+
+ def sync_plans(self, sender=None, account=None, action=None):
+ self._create_signature(
+ celery_config.ghm_sync_plans_task_name,
+ kwargs=dict(sender=sender, account=account, action=action),
+ ).apply_async()
+
+ def delete_owner(self, ownerid):
+ log.info(f"Triggering delete_owner task for owner: {ownerid}")
+ self._create_signature(
+ "app.tasks.delete_owner.DeleteOwner", kwargs=dict(ownerid=ownerid)
+ ).apply_async()
+
+ def backfill_repo(
+ self,
+ repository: Repository,
+ start_date: datetime,
+ end_date: datetime,
+ dataset_names: Iterable[str] = None,
+ ):
+ log.info(
+ "Triggering timeseries backfill tasks for repo",
+ extra=dict(
+ repoid=repository.pk,
+ start_date=start_date.isoformat(),
+ end_date=end_date.isoformat(),
+ dataset_names=dataset_names,
+ ),
+ )
+
+ # This controls the batch size for the task - we'll backfill
+ # measurements 10 days at a time in this case. I picked this
+ # somewhat arbitrarily - we might need to tweak to see what's
+ # most appropriate.
+ delta = timedelta(days=10)
+
+ signatures = []
+
+ task_end_date = end_date
+ while task_end_date > start_date:
+ task_start_date = task_end_date - delta
+ if task_start_date < start_date:
+ task_start_date = start_date
+
+ kwargs = dict(
+ repoid=repository.pk,
+ start_date=task_start_date.isoformat(),
+ end_date=task_end_date.isoformat(),
+ )
+ if dataset_names is not None:
+ kwargs["dataset_names"] = dataset_names
+
+ signatures.append(
+ self._create_signature(
+ celery_config.timeseries_backfill_task_name,
+ kwargs=kwargs,
+ )
+ )
+
+ task_end_date = task_start_date
+
+ group(signatures).apply_async()
+
+ def backfill_dataset(
+ self,
+ dataset: Dataset,
+ start_date: datetime,
+ end_date: datetime,
+ ):
+ log.info(
+ "Triggering dataset backfill",
+ extra=dict(
+ dataset_id=dataset.pk,
+ start_date=start_date.isoformat(),
+ end_date=end_date.isoformat(),
+ ),
+ )
+
+ self._create_signature(
+ "app.tasks.timeseries.backfill_dataset",
+ kwargs=dict(
+ dataset_id=dataset.pk,
+ start_date=start_date.isoformat(),
+ end_date=end_date.isoformat(),
+ ),
+ ).apply_async()
+
+ def delete_timeseries(self, repository_id: int):
+ log.info(
+ "Delete repository timeseries data",
+ extra=dict(repository_id=repository_id),
+ )
+ self._create_signature(
+ celery_config.timeseries_delete_task_name,
+ kwargs=dict(repository_id=repository_id),
+ ).apply_async()
+
+ def update_commit(self, commitid, repoid):
+ self._create_signature(
+ "app.tasks.commit_update.CommitUpdate",
+ kwargs=dict(commitid=commitid, repoid=repoid),
+ ).apply_async()
+
+ def create_report_results(self, commitid, repoid, report_code, current_yaml=None):
+ self._create_signature(
+ "app.tasks.reports.save_report_results",
+ kwargs=dict(
+ commitid=commitid,
+ repoid=repoid,
+ report_code=report_code,
+ current_yaml=current_yaml,
+ ),
+ ).apply_async()
+
+ def http_request(self, url, method="POST", headers=None, data=None, timeout=None):
+ self._create_signature(
+ "app.tasks.http_request.HTTPRequest",
+ kwargs=dict(
+ url=url,
+ method=method,
+ headers=headers,
+ data=data,
+ timeout=timeout,
+ ),
+ ).apply_async()
+
+ def flush_repo(self, repository_id: int):
+ self._create_signature(
+ "app.tasks.flush_repo.FlushRepo",
+ kwargs=dict(repoid=repository_id),
+ ).apply_async()
+
+ def manual_upload_completion_trigger(
+ self, repoid, commitid, report_code=None, current_yaml=None
+ ):
+ self._create_signature(
+ "app.tasks.upload.ManualUploadCompletionTrigger",
+ kwargs=dict(
+ commitid=commitid,
+ repoid=repoid,
+ report_code=report_code,
+ current_yaml=current_yaml,
+ ),
+ ).apply_async()
+
+ def preprocess_upload(self, repoid, commitid, report_code):
+ self._create_signature(
+ "app.tasks.upload.PreProcessUpload",
+ kwargs=dict(
+ repoid=repoid,
+ commitid=commitid,
+ report_code=report_code,
+ ),
+ ).apply_async()
+
+ def send_email(
+ self,
+ to_addr: str,
+ subject: str,
+ template_name: str,
+ from_addr: str | None = None,
+ **kwargs,
+ ):
+ # Templates can be found in worker/templates
+ self._create_signature(
+ "app.tasks.send_email.SendEmail",
+ kwargs=dict(
+ to_addr=to_addr,
+ subject=subject,
+ template_name=template_name,
+ from_addr=from_addr,
+ **kwargs,
+ ),
+ ).apply_async()
+
+ def delete_component_measurements(self, repoid: int, component_id: str) -> None:
+ log.info(
+ "Delete component measurements data",
+ extra=dict(repository_id=repoid, component_id=component_id),
+ )
+ self._create_signature(
+ celery_config.timeseries_delete_task_name,
+ kwargs=dict(
+ repository_id=repoid,
+ measurement_only=True,
+ measurement_type=MeasurementName.COMPONENT_COVERAGE.value,
+ measurement_id=component_id,
+ ),
+ ).apply_async()
+
+ def cache_test_results_redis(self, repoid: int, branch: str) -> None:
+ self._create_signature(
+ celery_config.cache_test_rollups_redis_task_name,
+ kwargs=dict(repoid=repoid, branch=branch),
+ ).apply_async()
diff --git a/apps/codecov-api/services/task/task_router.py b/apps/codecov-api/services/task/task_router.py
new file mode 100644
index 0000000000..154f11956e
--- /dev/null
+++ b/apps/codecov-api/services/task/task_router.py
@@ -0,0 +1,104 @@
+import shared.celery_config as shared_celery_config
+from shared.celery_router import route_tasks_based_on_user_plan
+from shared.plan.constants import DEFAULT_FREE_PLAN
+
+from codecov_auth.models import Owner
+from compare.models import CommitComparison
+from core.models import Repository
+from labelanalysis.models import LabelAnalysisRequest
+from staticanalysis.models import StaticAnalysisSuite
+
+
+def _get_user_plan_from_ownerid(ownerid, *args, **kwargs) -> str:
+ owner = Owner.objects.filter(ownerid=ownerid).first()
+ if owner:
+ return owner.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_repoid(repoid, *args, **kwargs) -> str:
+ repo = Repository.objects.filter(repoid=repoid).first()
+ if repo and repo.author:
+ return repo.author.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_comparison_id(comparison_id, *args, **kwargs) -> str:
+ compare_commit = (
+ CommitComparison.objects.filter(id=comparison_id)
+ .select_related("compare_commit__repository__author")
+ .first()
+ )
+ if (
+ compare_commit
+ and compare_commit.compare_commit
+ and compare_commit.compare_commit.repository
+ and compare_commit.compare_commit.repository.author
+ ):
+ return compare_commit.compare_commit.repository.author.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_label_request_id(request_id, *args, **kwargs) -> str:
+ label_analysis_request = (
+ LabelAnalysisRequest.objects.filter(id=request_id)
+ .select_related("head_commit__repository__author")
+ .first()
+ )
+ if (
+ label_analysis_request
+ and label_analysis_request.head_commit
+ and label_analysis_request.head_commit.repository
+ and label_analysis_request.head_commit.repository.author
+ ):
+ return label_analysis_request.head_commit.repository.author.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_suite_id(suite_id, *args, **kwargs) -> str:
+ static_analysis_suite = (
+ StaticAnalysisSuite.objects.filter(id=suite_id)
+ .select_related("commit__repository__author")
+ .first()
+ )
+ if (
+ static_analysis_suite
+ and static_analysis_suite.commit
+ and static_analysis_suite.commit.repository
+ and static_analysis_suite.commit.repository.author
+ ):
+ return static_analysis_suite.commit.repository.author.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_task(task_name: str, task_kwargs: dict) -> str:
+ owner_plan_lookup_funcs = {
+ # from ownerid
+ shared_celery_config.delete_owner_task_name: _get_user_plan_from_ownerid,
+ shared_celery_config.sync_repos_task_name: _get_user_plan_from_ownerid,
+ shared_celery_config.sync_teams_task_name: _get_user_plan_from_ownerid,
+ # from repoid
+ shared_celery_config.upload_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.notify_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.status_set_error_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.status_set_pending_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.pulls_task_name: _get_user_plan_from_repoid,
+ # from comparison_id
+ shared_celery_config.compute_comparison_task_name: _get_user_plan_from_comparison_id,
+ # from label_request_id
+ shared_celery_config.label_analysis_task_name: _get_user_plan_from_label_request_id,
+ # from suite_id
+ shared_celery_config.static_analysis_task_name: _get_user_plan_from_suite_id,
+ }
+ func_to_use = owner_plan_lookup_funcs.get(
+ task_name, lambda *args, **kwargs: DEFAULT_FREE_PLAN
+ )
+ return func_to_use(**task_kwargs)
+
+
+def route_task(name, args, kwargs, options={}, task=None, **kw):
+ """Function to dynamically route tasks to the proper queue.
+ Docs: https://docs.celeryq.dev/en/stable/userguide/routing.html#routers
+ """
+ user_plan = _get_user_plan_from_task(name, kwargs)
+ return route_tasks_based_on_user_plan(name, user_plan)
diff --git a/apps/codecov-api/services/tests/__init__.py b/apps/codecov-api/services/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_get.yaml b/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_get.yaml
new file mode 100644
index 0000000000..e21aab3876
--- /dev/null
+++ b/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_get.yaml
@@ -0,0 +1,78 @@
+interactions:
+- request:
+ body: null
+ headers:
+ host:
+ - minio:9000
+ x-amz-content-sha256:
+ - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+ x-amz-date:
+ - 20220622T121514Z
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body:
+ string: '
+
+ '
+ headers:
+ Accept-Ranges:
+ - bytes
+ Content-Security-Policy:
+ - block-all-mixed-content
+ Content-Type:
+ - application/xml
+ Date:
+ - Wed, 22 Jun 2022 12:15:14 GMT
+ Server:
+ - Minio/RELEASE.2019-04-09T01-22-30Z
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Origin
+ X-Amz-Request-Id:
+ - 16FAF06173122EC0
+ X-Minio-Deployment-Id:
+ - eac53635-8dc9-48b8-a84e-755e2779a894
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ host:
+ - minio:9000
+ user-agent:
+ - MinIO (Linux; aarch64) minio-py/6.0.2
+ x-amz-content-sha256:
+ - e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+ x-amz-date:
+ - 20220622T121514Z
+ method: HEAD
+ uri: http://minio:9000/archive/
+ response:
+ body:
+ string: ''
+ headers:
+ Accept-Ranges:
+ - bytes
+ Content-Security-Policy:
+ - block-all-mixed-content
+ Date:
+ - Wed, 22 Jun 2022 12:15:14 GMT
+ Server:
+ - Minio/RELEASE.2019-04-09T01-22-30Z
+ Vary:
+ - Origin
+ X-Amz-Request-Id:
+ - 16FAF06173BDDBF8
+ X-Minio-Deployment-Id:
+ - eac53635-8dc9-48b8-a84e-755e2779a894
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_put.yaml b/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_put.yaml
new file mode 100644
index 0000000000..080ddd632d
--- /dev/null
+++ b/apps/codecov-api/services/tests/cassetes/test_archive/TestReport/test_create_raw_upload_presigned_put.yaml
@@ -0,0 +1,47 @@
+interactions:
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20210122T212511Z]
+ method: GET
+ uri: http://minio:9000/archive?location=
+ response:
+ body: {string: '
+
+ '}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Content-Type: [application/xml]
+ Date: ['Fri, 22 Jan 2021 21:25:11 GMT']
+ Server: [Minio/RELEASE.2019-04-09T01-22-30Z]
+ Transfer-Encoding: [chunked]
+ Vary: [Origin]
+ X-Amz-Request-Id: [165CAAEF6815ED00]
+ X-Minio-Deployment-Id: [65c76aa0-0170-423e-bf67-f8b33dd9bba8]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+- request:
+ body: null
+ headers:
+ host: ['minio:9000']
+ user-agent: [MinIO (Linux; aarch64) minio-py/6.0.0]
+ x-amz-content-sha256: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]
+ x-amz-date: [20210122T212511Z]
+ method: HEAD
+ uri: http://minio:9000/archive/
+ response:
+ body: {string: ''}
+ headers:
+ Accept-Ranges: [bytes]
+ Content-Security-Policy: [block-all-mixed-content]
+ Date: ['Fri, 22 Jan 2021 21:25:11 GMT']
+ Server: [Minio/RELEASE.2019-04-09T01-22-30Z]
+ Vary: [Origin]
+ X-Amz-Request-Id: [165CAAEF684CEB20]
+ X-Minio-Deployment-Id: [65c76aa0-0170-423e-bf67-f8b33dd9bba8]
+ X-Xss-Protection: [1; mode=block]
+ status: {code: 200, message: OK}
+version: 1
diff --git a/apps/codecov-api/services/tests/samples/base_bundle_report.sqlite b/apps/codecov-api/services/tests/samples/base_bundle_report.sqlite
new file mode 100644
index 0000000000..75ceaa0c3f
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/base_bundle_report.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/bundle_report.sqlite b/apps/codecov-api/services/tests/samples/bundle_report.sqlite
new file mode 100644
index 0000000000..dfaf104b00
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/bundle_report.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/bundle_with_asset_routes.sqlite b/apps/codecov-api/services/tests/samples/bundle_with_asset_routes.sqlite
new file mode 100644
index 0000000000..3ca73ee44d
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/bundle_with_asset_routes.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/bundle_with_assets_and_modules.sqlite b/apps/codecov-api/services/tests/samples/bundle_with_assets_and_modules.sqlite
new file mode 100644
index 0000000000..f7e1467ac4
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/bundle_with_assets_and_modules.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/bundle_with_uuid.sqlite b/apps/codecov-api/services/tests/samples/bundle_with_uuid.sqlite
new file mode 100644
index 0000000000..26c8b7285c
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/bundle_with_uuid.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/chunks.txt b/apps/codecov-api/services/tests/samples/chunks.txt
new file mode 100644
index 0000000000..3ad04429d0
--- /dev/null
+++ b/apps/codecov-api/services/tests/samples/chunks.txt
@@ -0,0 +1,39 @@
+{}
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 0]]]
+[0, null, [[0, 0], [1, 0]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 1]]]
+[1, null, [[0, 1], [1, 1]]]
+<<<<< end_of_chunk >>>>>
+{}
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 1]]]
+[1, null, [[0, 0], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+[1, null, [[0, 1], [1, 0]]]
+
+
+[1, null, [[0, 1], [1, 0]]]
+[0, null, [[0, 0], [1, 0]]]
diff --git a/apps/codecov-api/services/tests/samples/head_bundle_report.sqlite b/apps/codecov-api/services/tests/samples/head_bundle_report.sqlite
new file mode 100644
index 0000000000..787dc78853
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/head_bundle_report.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/head_bundle_report_with_compare_sha_6ca727b0142bf5625bb82af2555d308862063222.sqlite b/apps/codecov-api/services/tests/samples/head_bundle_report_with_compare_sha_6ca727b0142bf5625bb82af2555d308862063222.sqlite
new file mode 100644
index 0000000000..d17d6789bf
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/head_bundle_report_with_compare_sha_6ca727b0142bf5625bb82af2555d308862063222.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/head_bundle_report_with_gzip_size.sqlite b/apps/codecov-api/services/tests/samples/head_bundle_report_with_gzip_size.sqlite
new file mode 100644
index 0000000000..1c28a8a4f3
Binary files /dev/null and b/apps/codecov-api/services/tests/samples/head_bundle_report_with_gzip_size.sqlite differ
diff --git a/apps/codecov-api/services/tests/samples/stripe_invoice.json b/apps/codecov-api/services/tests/samples/stripe_invoice.json
new file mode 100644
index 0000000000..289f59d5e3
--- /dev/null
+++ b/apps/codecov-api/services/tests/samples/stripe_invoice.json
@@ -0,0 +1,401 @@
+{
+ "object": "list",
+ "url": "/v1/invoices",
+ "has_more": false,
+ "data": [
+ {
+ "id": "in_19yTU92eZvKYlo2C7uDjvu6v",
+ "object": "invoice",
+ "account_country": "US",
+ "account_name": "Stripe.com",
+ "amount_due": 999,
+ "amount_paid": 999,
+ "amount_remaining": 0,
+ "application_fee_amount": null,
+ "attempt_count": 1,
+ "attempted": true,
+ "auto_advance": false,
+ "billing_reason": null,
+ "charge": "ch_19yUQN2eZvKYlo2CQf7aWpSX",
+ "collection_method": "charge_automatically",
+ "created": 1489789429,
+ "currency": "usd",
+ "custom_fields": null,
+ "customer": "cus_HF6p8Zx7JdRS7A",
+ "customer_address": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "customer_email": "olivia.williams.03@example.com",
+ "customer_name": "Peer Company",
+ "customer_phone": null,
+ "customer_shipping": null,
+ "customer_tax_exempt": "none",
+ "customer_tax_ids": [],
+ "default_payment_method": null,
+ "default_source": null,
+ "default_tax_rates": [],
+ "description": null,
+ "discount": null,
+ "due_date": null,
+ "ending_balance": 0,
+ "footer": null,
+ "hosted_invoice_url": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ",
+ "invoice_pdf": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ/pdf",
+ "lines": {
+ "data": [
+ {
+ "id": "il_tmp_06bab3ae5b3624",
+ "object": "line_item",
+ "amount": 120,
+ "currency": "usd",
+ "description": "(10) users-pr-inappm",
+ "discountable": true,
+ "livemode": false,
+ "metadata": {},
+ "period": {
+ "end": 1521326190,
+ "start": 1518906990
+ },
+ "plan": {
+ "id": "ivory-freelance-040",
+ "name": "users-pr-inappm",
+ "object": "plan",
+ "active": true,
+ "aggregate_usage": null,
+ "amount": 999,
+ "amount_decimal": "999",
+ "billing_scheme": "per_unit",
+ "created": 1466202980,
+ "currency": "usd",
+ "interval": "month",
+ "interval_count": 1,
+ "livemode": false,
+ "metadata": {},
+ "nickname": null,
+ "product": "prod_BUthVRQ7KdFfa7",
+ "tiers": null,
+ "tiers_mode": null,
+ "transform_usage": null,
+ "trial_period_days": null,
+ "usage_type": "licensed"
+ },
+ "proration": false,
+ "quantity": 1,
+ "subscription": "sub_8epEF0PuRhmltU",
+ "subscription_item": "si_18NVZi2eZvKYlo2CUtBNGL9x",
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "subscription"
+ }
+ ],
+ "has_more": false,
+ "object": "list",
+ "url": "/v1/invoices/in_19yTU92eZvKYlo2C7uDjvu6v/lines"
+ },
+ "livemode": false,
+ "metadata": {
+ "order_id": "6735"
+ },
+ "next_payment_attempt": null,
+ "number": "EF0A41E-0001",
+ "paid": true,
+ "payment_intent": {
+ "id": "pi_3P4567890123456789012345",
+ "status": "completed"
+ },
+ "period_end": 1489789420,
+ "period_start": 1487370220,
+ "post_payment_credit_notes_amount": 0,
+ "pre_payment_credit_notes_amount": 0,
+ "receipt_number": "2277-9887",
+ "starting_balance": 0,
+ "statement_descriptor": null,
+ "status": "paid",
+ "status_transitions": {
+ "finalized_at": 1489793039,
+ "marked_uncollectible_at": null,
+ "paid_at": 1489793039,
+ "voided_at": null
+ },
+ "subscription": "sub_9lNL2lSXI8nYEQ",
+ "subtotal": 999,
+ "tax": null,
+ "tax_percent": null,
+ "total": 999,
+ "total_tax_amounts": [],
+ "webhooks_delivered_at": 1489789437
+ },
+ {
+ "id": "in_19yTU92eZvKYlo2C7uDjvu69",
+ "object": "invoice",
+ "account_country": "US",
+ "account_name": "Stripe.com",
+ "amount_due": 999,
+ "amount_paid": 999,
+ "amount_remaining": 0,
+ "application_fee_amount": null,
+ "attempt_count": 1,
+ "attempted": true,
+ "auto_advance": false,
+ "billing_reason": null,
+ "charge": "ch_19yUQN2eZvKYlo2CQf7aWpSX",
+ "collection_method": "charge_automatically",
+ "created": 1489789429,
+ "currency": "usd",
+ "custom_fields": null,
+ "customer": "cus_HF6p8Zx7JdRS7A",
+ "customer_address": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "customer_email": "olivia.williams.03@example.com",
+ "customer_name": "Peer Company",
+ "customer_phone": null,
+ "customer_shipping": null,
+ "customer_tax_exempt": "none",
+ "customer_tax_ids": [],
+ "default_payment_method": null,
+ "default_source": null,
+ "default_tax_rates": [],
+ "description": null,
+ "discount": null,
+ "due_date": null,
+ "ending_balance": 0,
+ "footer": null,
+ "hosted_invoice_url": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ",
+ "invoice_pdf": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ/pdf",
+ "lines": {
+ "data": [
+ {
+ "id": "il_tmp_06bab3ae5b3624",
+ "object": "line_item",
+ "amount": 120,
+ "currency": "usd",
+ "description": "(10) users-pr-inappm",
+ "discountable": true,
+ "livemode": false,
+ "metadata": {},
+ "period": {
+ "end": 1521326190,
+ "start": 1518906990
+ },
+ "plan": {
+ "id": "ivory-freelance-040",
+ "name": "users-pr-inappm",
+ "object": "plan",
+ "active": true,
+ "aggregate_usage": null,
+ "amount": 999,
+ "amount_decimal": "999",
+ "billing_scheme": "per_unit",
+ "created": 1466202980,
+ "currency": "usd",
+ "interval": "month",
+ "interval_count": 1,
+ "livemode": false,
+ "metadata": {},
+ "nickname": null,
+ "product": "prod_BUthVRQ7KdFfa7",
+ "tiers": null,
+ "tiers_mode": null,
+ "transform_usage": null,
+ "trial_period_days": null,
+ "usage_type": "licensed"
+ },
+ "proration": false,
+ "quantity": 1,
+ "subscription": "sub_8epEF0PuRhmltU",
+ "subscription_item": "si_18NVZi2eZvKYlo2CUtBNGL9x",
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "subscription"
+ }
+ ],
+ "has_more": false,
+ "object": "list",
+ "url": "/v1/invoices/in_19yTU92eZvKYlo2C7uDjvu6v/lines"
+ },
+ "livemode": false,
+ "metadata": {
+ "order_id": "6735"
+ },
+ "next_payment_attempt": null,
+ "number": "EF0A41E-0001",
+ "paid": true,
+ "payment_intent": {
+ "id": "pi_3P4567890123456789012345",
+ "status": "completed"
+ },
+ "period_end": 1489789420,
+ "period_start": 1487370220,
+ "post_payment_credit_notes_amount": 0,
+ "pre_payment_credit_notes_amount": 0,
+ "receipt_number": "2277-9887",
+ "starting_balance": 0,
+ "statement_descriptor": null,
+ "status": "paid",
+ "status_transitions": {
+ "finalized_at": 1489793039,
+ "marked_uncollectible_at": null,
+ "paid_at": 1489793039,
+ "voided_at": null
+ },
+ "subscription": "sub_9lNL2lSXI8nYEQ",
+ "subtotal": 999,
+ "tax": null,
+ "tax_percent": null,
+ "total": 0,
+ "total_tax_amounts": [],
+ "webhooks_delivered_at": 1489789437
+ },
+ {
+ "id": "in_1MCimz2eZvKYlo2CvRbpaFFM",
+ "object": "invoice",
+ "account_country": "US",
+ "account_name": "Stripe.com",
+ "account_tax_ids": null,
+ "amount_due": 11000,
+ "amount_paid": 0,
+ "amount_remaining": 11000,
+ "application": null,
+ "application_fee_amount": null,
+ "attempt_count": 0,
+ "attempted": false,
+ "auto_advance": false,
+ "automatic_tax": {
+ "enabled": false,
+ "status": null
+ },
+ "billing_reason": "manual",
+ "charge": null,
+ "collection_method": "charge_automatically",
+ "created": 1670500881,
+ "currency": "usd",
+ "custom_fields": null,
+ "customer": "cus_4QFOF3xrvBT2nU",
+ "customer_address": null,
+ "customer_email": null,
+ "customer_name": null,
+ "customer_phone": null,
+ "customer_shipping": null,
+ "customer_tax_exempt": "none",
+ "customer_tax_ids": [
+ {
+ "type": "eu_vat",
+ "value": "DE123456789"
+ }
+ ],
+ "default_payment_method": null,
+ "default_source": null,
+ "default_tax_rates": [],
+ "description": null,
+ "discount": null,
+ "discounts": [],
+ "due_date": null,
+ "ending_balance": null,
+ "footer": null,
+ "from_invoice": null,
+ "hosted_invoice_url": null,
+ "invoice_pdf": null,
+ "last_finalization_error": null,
+ "latest_revision": null,
+ "lines": {
+ "object": "list",
+ "data": [
+ {
+ "id": "il_1MCimy2eZvKYlo2CNV9viA8Z",
+ "object": "line_item",
+ "amount": 11000,
+ "amount_excluding_tax": 11000,
+ "currency": "usd",
+ "description": "My First Invoice Item (created for API docs)",
+ "discount_amounts": [],
+ "discountable": true,
+ "discounts": [],
+ "invoice_item": "ii_1MCimy2eZvKYlo2CXHo2fIwW",
+ "livemode": false,
+ "metadata": {},
+ "period": {
+ "end": 1670500880,
+ "start": 1670500880
+ },
+ "price": {
+ "id": "price_1MCicx2eZvKYlo2CXoWVQnO4",
+ "object": "price",
+ "active": true,
+ "billing_scheme": "per_unit",
+ "created": 1670500259,
+ "currency": "usd",
+ "custom_unit_amount": null,
+ "livemode": false,
+ "lookup_key": null,
+ "metadata": {},
+ "nickname": null,
+ "product": "prod_MwbqfRCnGFA9As",
+ "recurring": null,
+ "tax_behavior": "unspecified",
+ "tiers_mode": null,
+ "transform_quantity": null,
+ "type": "one_time",
+ "unit_amount": 11000,
+ "unit_amount_decimal": "11000"
+ },
+ "proration": false,
+ "proration_details": {
+ "credited_items": null
+ },
+ "quantity": 1,
+ "subscription": null,
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "invoiceitem",
+ "unit_amount_excluding_tax": "11000"
+ }
+ ],
+ "has_more": false,
+ "url": "/v1/invoices/in_1MCimz2eZvKYlo2CvRbpaFFM/lines"
+ },
+ "livemode": false,
+ "metadata": {},
+ "next_payment_attempt": 1670504481,
+ "number": null,
+ "on_behalf_of": null,
+ "paid": false,
+ "paid_out_of_band": false,
+ "payment_intent": {
+ "id": "pi_3P4567890123456789012345",
+ "status": "completed"
+ },
+ "payment_settings": {
+ "default_mandate": null,
+ "payment_method_options": null,
+ "payment_method_types": null
+ },
+ "period_end": 1673097820,
+ "period_start": 1670419420,
+ "post_payment_credit_notes_amount": 0,
+ "pre_payment_credit_notes_amount": 0,
+ "quote": null,
+ "receipt_number": null,
+ "redaction": null,
+ "rendering_options": null,
+ "starting_balance": 0,
+ "statement_descriptor": null,
+ "status": "void",
+ "status_transitions": {
+ "finalized_at": null,
+ "marked_uncollectible_at": null,
+ "paid_at": null,
+ "voided_at": null
+ },
+ "subscription": null,
+ "subtotal": 11000,
+ "subtotal_excluding_tax": 11000,
+ "tax": null,
+ "test_clock": null,
+ "total": 0,
+ "total_discount_amounts": [],
+ "total_excluding_tax": 11000,
+ "total_tax_amounts": [],
+ "transfer_data": null,
+ "webhooks_delivered_at": null,
+ "closed": true,
+ "forgiven": false
+ }
+ ]
+}
diff --git a/apps/codecov-api/services/tests/test_activation.py b/apps/codecov-api/services/tests/test_activation.py
new file mode 100644
index 0000000000..f2a2a83c06
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_activation.py
@@ -0,0 +1,32 @@
+import pytest
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from services.activation import _get_activator
+
+
+@pytest.mark.django_db
+def test_get_activator():
+ org = OwnerFactory()
+ owner = OwnerFactory()
+ org.plan_activated_users = [owner.pk]
+ activator = _get_activator(org, owner)
+
+ assert activator.org == org
+ assert activator.owner == owner
+ assert activator.is_autoactivation_enabled() == True
+ assert activator.can_activate_user() == False
+ assert activator.is_activated() == True
+
+
+@pytest.mark.django_db
+def test_get_activator_no_activated_users():
+ org = OwnerFactory()
+ org.plan_activated_users = None
+ owner = OwnerFactory()
+ activator = _get_activator(org, owner)
+
+ assert activator.org == org
+ assert activator.owner == owner
+ assert activator.is_autoactivation_enabled() == True
+ assert activator.can_activate_user() == True
+ assert activator.is_activated() == False
diff --git a/apps/codecov-api/services/tests/test_analytics.py b/apps/codecov-api/services/tests/test_analytics.py
new file mode 100644
index 0000000000..6fb37586af
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_analytics.py
@@ -0,0 +1,222 @@
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from django.utils import timezone
+from shared.analytics_tracking.events import Events
+from shared.django_apps.codecov_auth.tests.factories import UserFactory
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov_auth.models import PlanProviders
+from services.analytics import AnalyticsOwner, AnalyticsRepository, AnalyticsService
+
+
+class AnalyticsOwnerTests(TestCase):
+ def setUp(self):
+ self.analytics_owner = AnalyticsOwner(
+ OwnerFactory(
+ private_access=True,
+ plan_provider=PlanProviders.GITHUB.value,
+ plan_user_count=10,
+ delinquent=True,
+ trial_start_date=timezone.now(),
+ trial_end_date=timezone.now() + timedelta(days=14),
+ student=True,
+ bot=OwnerFactory(),
+ email="user@codecov.io",
+ student_created_at=datetime(2017, 1, 1, 12, 0, 0, 5000),
+ student_updated_at=datetime(2018, 1, 1, 12, 0, 0, 5000),
+ )
+ )
+
+ def test_traits(self):
+ expected_traits = {
+ "email": self.analytics_owner.owner.email,
+ "username": self.analytics_owner.owner.username,
+ "service": self.analytics_owner.owner.service,
+ "service_id": self.analytics_owner.owner.service_id,
+ "plan": self.analytics_owner.owner.plan,
+ "owner_id": self.analytics_owner.owner.ownerid,
+ "user_id": self.analytics_owner.owner.ownerid,
+ }
+
+ assert self.analytics_owner.traits == expected_traits
+
+ @pytest.mark.skip
+ def test_traits_defaults(self):
+ analytics_owner_missing_traits = AnalyticsOwner(
+ OwnerFactory(
+ email=None,
+ name=None,
+ username=None,
+ private_access=None,
+ plan_provider=None,
+ plan_user_count=None,
+ delinquent=None,
+ trial_start_date=None,
+ trial_end_date=None,
+ student=0,
+ bot=None,
+ student_created_at=None,
+ student_updated_at=None,
+ )
+ )
+
+ expected_traits = {
+ "email": "unknown@codecov.io",
+ "name": "unknown",
+ "username": "unknown",
+ "avatar": analytics_owner_missing_traits.owner.avatar_url,
+ "createdAt": datetime(2014, 1, 1, 12, 0, 0),
+ "updatedAt": self.analytics_owner.owner.updatestamp.replace(microsecond=0),
+ "service": analytics_owner_missing_traits.owner.service,
+ "service_id": analytics_owner_missing_traits.owner.service_id,
+ "private_access": False,
+ "plan": analytics_owner_missing_traits.owner.plan,
+ "plan_provider": "",
+ "plan_user_count": 5,
+ "delinquent": False,
+ "trial_start_date": None,
+ "trial_end_date": None,
+ "student": False,
+ "student_created_at": datetime(2014, 1, 1, 12, 0, 0),
+ "student_updated_at": datetime(2014, 1, 1, 12, 0, 0),
+ "staff": analytics_owner_missing_traits.owner.staff,
+ "bot": False,
+ "has_yaml": analytics_owner_missing_traits.owner.yaml is not None,
+ }
+
+ assert analytics_owner_missing_traits.traits == expected_traits
+
+ def test_context(self):
+ marketo_cookie, ga_cookie = "foo", "GA1.2.1429057651.1605972584"
+ cookies = {"_mkto_trk": marketo_cookie, "_ga": ga_cookie}
+
+ self.analytics_owner = AnalyticsOwner(
+ OwnerFactory(stripe_customer_id=684), cookies=cookies
+ )
+
+ expected_context = {
+ "externalIds": [
+ {
+ "id": self.analytics_owner.owner.service_id,
+ "type": f"{self.analytics_owner.owner.service}_id",
+ "collection": "users",
+ "encoding": "none",
+ },
+ {
+ "id": self.analytics_owner.owner.stripe_customer_id,
+ "type": "stripe_customer_id",
+ "collection": "users",
+ "encoding": "none",
+ },
+ {
+ "id": marketo_cookie,
+ "type": "marketo_cookie",
+ "collection": "users",
+ "encoding": "none",
+ },
+ {
+ "id": "1429057651.1605972584",
+ "type": "ga_client_id",
+ "collection": "users",
+ "encoding": "none",
+ },
+ ],
+ "Marketo": {"marketo_cookie": marketo_cookie},
+ }
+
+ assert self.analytics_owner.context == expected_context
+
+
+class AnalyticsServiceTests(TestCase):
+ def setUp(self):
+ self.analytics_service = AnalyticsService()
+ self.owner = OwnerFactory()
+ self.analytics_owner = AnalyticsOwner(self.owner)
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_user_signed_up(self, track_mock):
+ with self.settings(IS_ENTERPRISE=True):
+ self.analytics_service.user_signed_up(self.owner)
+ track_mock.assert_called_once_with(
+ Events.USER_SIGNED_UP.value,
+ is_enterprise=True,
+ event_data=self.analytics_owner.traits,
+ )
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_user_signed_in(self, track_mock):
+ with self.settings(IS_ENTERPRISE=False):
+ self.analytics_service.user_signed_in(self.owner)
+ track_mock.assert_called_once_with(
+ Events.USER_SIGNED_IN.value,
+ is_enterprise=False,
+ event_data=self.analytics_owner.traits,
+ )
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_account_activated_repository(self, track_mock):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ with self.settings(IS_ENTERPRISE=False):
+ self.analytics_service.account_activated_repository(owner.ownerid, repo)
+ event_data = {
+ **AnalyticsRepository(repo).traits,
+ "user_id": owner.ownerid,
+ }
+ track_mock.assert_called_once_with(
+ Events.ACCOUNT_ACTIVATED_REPOSITORY.value,
+ is_enterprise=False,
+ event_data=event_data,
+ context={"groupId": repo.author.ownerid},
+ )
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_account_activated_repository_on_upload(self, track_mock):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ with self.settings(IS_ENTERPRISE=False):
+ self.analytics_service.account_activated_repository_on_upload(
+ owner.ownerid, repo
+ )
+ track_mock.assert_called_once_with(
+ Events.ACCOUNT_ACTIVATED_REPOSITORY_ON_UPLOAD.value,
+ is_enterprise=False,
+ event_data={
+ **AnalyticsRepository(repo).traits,
+ "user_id": owner.ownerid,
+ },
+ context={"groupId": repo.author.ownerid},
+ )
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_account_uploaded_coverage_report(self, track_mock):
+ owner = OwnerFactory()
+ upload_details = {"some": "dict", "user_id": owner.ownerid}
+ with self.settings(IS_ENTERPRISE=False):
+ self.analytics_service.account_uploaded_coverage_report(
+ owner.ownerid, upload_details
+ )
+ track_mock.assert_called_once_with(
+ Events.ACCOUNT_UPLOADED_COVERAGE_REPORT.value,
+ is_enterprise=False,
+ event_data=upload_details,
+ context={"groupId": owner.ownerid},
+ )
+
+ @patch("shared.analytics_tracking.analytics_manager.track_event")
+ def test_opt_in_email(self, track_mock):
+ user = UserFactory()
+ data = {
+ "email": user.email,
+ }
+
+ with self.settings(IS_ENTERPRISE=False):
+ self.analytics_service.opt_in_email(user.id, data)
+ track_mock.assert_called_once_with(
+ Events.GDPR_OPT_IN.value,
+ is_enterprise=False,
+ event_data={**data, "user_id": user.id},
+ )
diff --git a/apps/codecov-api/services/tests/test_billing.py b/apps/codecov-api/services/tests/test_billing.py
new file mode 100644
index 0000000000..be880a0fba
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_billing.py
@@ -0,0 +1,2537 @@
+import json
+from unittest.mock import MagicMock, call, patch
+
+import requests
+import stripe
+from django.conf import settings
+from django.test import TestCase
+from freezegun import freeze_time
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName
+from stripe import InvalidRequestError
+from stripe.api_resources import PaymentIntent, SetupIntent
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.models import Plan, Service
+from services.billing import AbstractPaymentService, BillingService, StripeService
+
+SCHEDULE_RELEASE_OFFSET = 10
+
+expected_invoices = [
+ {
+ "id": "in_19yTU92eZvKYlo2C7uDjvu6v",
+ "object": "invoice",
+ "account_country": "US",
+ "account_name": "Stripe.com",
+ "amount_due": 999,
+ "amount_paid": 999,
+ "amount_remaining": 0,
+ "application_fee_amount": None,
+ "attempt_count": 1,
+ "attempted": True,
+ "auto_advance": False,
+ "billing_reason": None,
+ "charge": "ch_19yUQN2eZvKYlo2CQf7aWpSX",
+ "collection_method": "charge_automatically",
+ "created": 1489789429,
+ "currency": "usd",
+ "custom_fields": None,
+ "customer": "cus_HF6p8Zx7JdRS7A",
+ "customer_address": "6639 Boulevard Dr, Westwood FL 34202 USA",
+ "customer_email": "olivia.williams.03@example.com",
+ "customer_name": "Peer Company",
+ "customer_phone": None,
+ "customer_shipping": None,
+ "customer_tax_exempt": "none",
+ "customer_tax_ids": [],
+ "default_payment_method": None,
+ "default_source": None,
+ "default_tax_rates": [],
+ "description": None,
+ "discount": None,
+ "due_date": None,
+ "ending_balance": 0,
+ "footer": None,
+ "hosted_invoice_url": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ",
+ "invoice_pdf": "https://pay.stripe.com/invoice/acct_1032D82eZvKYlo2C/invst_a7KV10HpLw2QxrihgVyuOkOjMZ/pdf",
+ "lines": {
+ "data": [
+ {
+ "id": "il_tmp_06bab3ae5b3624",
+ "object": "line_item",
+ "amount": 120,
+ "currency": "usd",
+ "description": "(10) users-pr-inappm",
+ "discountable": True,
+ "livemode": False,
+ "metadata": {},
+ "period": {"end": 1521326190, "start": 1518906990},
+ "plan": {
+ "id": "ivory-freelance-040",
+ "name": "users-pr-inappm",
+ "object": "plan",
+ "active": True,
+ "aggregate_usage": None,
+ "amount": 999,
+ "amount_decimal": "999",
+ "billing_scheme": "per_unit",
+ "created": 1466202980,
+ "currency": "usd",
+ "interval": "month",
+ "interval_count": 1,
+ "livemode": False,
+ "metadata": {},
+ "nickname": None,
+ "product": "prod_BUthVRQ7KdFfa7",
+ "tiers": None,
+ "tiers_mode": None,
+ "transform_usage": None,
+ "trial_period_days": None,
+ "usage_type": "licensed",
+ },
+ "proration": False,
+ "quantity": 1,
+ "subscription": "sub_8epEF0PuRhmltU",
+ "subscription_item": "si_18NVZi2eZvKYlo2CUtBNGL9x",
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "subscription",
+ }
+ ],
+ "has_more": False,
+ "object": "list",
+ "url": "/v1/invoices/in_19yTU92eZvKYlo2C7uDjvu6v/lines",
+ },
+ "livemode": False,
+ "metadata": {"order_id": "6735"},
+ "next_payment_attempt": None,
+ "number": "EF0A41E-0001",
+ "paid": True,
+ "payment_intent": {"id": "pi_3P4567890123456789012345", "status": "completed"},
+ "period_end": 1489789420,
+ "period_start": 1487370220,
+ "post_payment_credit_notes_amount": 0,
+ "pre_payment_credit_notes_amount": 0,
+ "receipt_number": "2277-9887",
+ "starting_balance": 0,
+ "statement_descriptor": None,
+ "status": "paid",
+ "status_transitions": {
+ "finalized_at": 1489793039,
+ "marked_uncollectible_at": None,
+ "paid_at": 1489793039,
+ "voided_at": None,
+ },
+ "subscription": "sub_9lNL2lSXI8nYEQ",
+ "subtotal": 999,
+ "tax": None,
+ "tax_percent": None,
+ "total": 999,
+ "total_tax_amounts": [],
+ "webhooks_delivered_at": 1489789437,
+ }
+]
+
+
+class MockSubscriptionPlan(object):
+ def __init__(self, params):
+ self.id = params["new_plan"]
+ self.interval = "year"
+
+
+class MockSubscription(object):
+ def __init__(self, subscription_params):
+ self.schedule = subscription_params["schedule_id"]
+ self.current_period_start = subscription_params["start_date"]
+ self.current_period_end = subscription_params["end_date"]
+ self.plan = (
+ MockSubscriptionPlan(subscription_params["plan"])
+ if subscription_params.get("plan") is not None
+ else None
+ )
+ self.items = {
+ "data": [
+ {
+ "quantity": subscription_params["quantity"],
+ "id": subscription_params["id"],
+ "plan": {
+ "id": subscription_params["name"],
+ "interval": subscription_params.get("plan", {}).get(
+ "interval", "month"
+ ),
+ },
+ }
+ ]
+ }
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class MockFailedSubscriptionUpgrade(object):
+ def __init__(self, subscription_params):
+ self.id = subscription_params["id"]
+ self.object = subscription_params["object"]
+ self.pending_update = subscription_params["pending_update"]
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class StripeServiceTests(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ def setUp(self):
+ self.user = OwnerFactory()
+ self.stripe = StripeService(requesting_user=self.user)
+
+ def test_stripe_service_requires_requesting_user_to_be_owner_instance(self):
+ with self.assertRaises(Exception):
+ StripeService(None)
+
+ def _assert_subscription_modify(
+ self, subscription_modify_mock, owner, subscription_params, desired_plan
+ ):
+ plan = Plan.objects.get(name=desired_plan["value"])
+ subscription_modify_mock.assert_called_once_with(
+ owner.stripe_subscription_id,
+ cancel_at_period_end=False,
+ items=[
+ {
+ "id": subscription_params["id"],
+ "plan": plan.stripe_id,
+ "quantity": desired_plan["quantity"],
+ }
+ ],
+ metadata={
+ "service": owner.service,
+ "obo_organization": owner.ownerid,
+ "username": owner.username,
+ "obo_name": self.user.name,
+ "obo_email": self.user.email,
+ "obo": self.user.ownerid,
+ },
+ proration_behavior="always_invoice",
+ # payment_behavior="pending_if_incomplete",
+ )
+
+ def _assert_schedule_modify(
+ self,
+ schedule_modify_mock,
+ owner,
+ subscription_params,
+ desired_plan,
+ schedule_id,
+ ):
+ plan = Plan.objects.get(name=desired_plan["value"])
+ schedule_modify_mock.assert_called_once_with(
+ schedule_id,
+ end_behavior="release",
+ phases=[
+ {
+ "start_date": subscription_params["start_date"],
+ "end_date": subscription_params["end_date"],
+ "items": [
+ {
+ "plan": subscription_params["name"],
+ "price": subscription_params["name"],
+ "quantity": subscription_params["quantity"],
+ }
+ ],
+ "proration_behavior": "none",
+ },
+ {
+ "start_date": subscription_params["end_date"],
+ "end_date": subscription_params["end_date"]
+ + SCHEDULE_RELEASE_OFFSET,
+ "items": [
+ {
+ "plan": plan.stripe_id,
+ "price": plan.stripe_id,
+ "quantity": desired_plan["quantity"],
+ }
+ ],
+ "proration_behavior": "none",
+ },
+ ],
+ metadata={
+ "service": owner.service,
+ "obo_organization": owner.ownerid,
+ "username": owner.username,
+ "obo_name": self.user.name,
+ "obo_email": self.user.email,
+ "obo": self.user.ownerid,
+ },
+ )
+
+ @patch("services.billing.stripe.Invoice.list")
+ def test_list_filtered_invoices_calls_stripe_invoice_list_with_customer_stripe_id(
+ self, invoice_list_mock
+ ):
+ owner = OwnerFactory(stripe_customer_id=-1)
+ self.stripe.list_filtered_invoices(owner)
+ invoice_list_mock.assert_called_once_with(
+ customer=owner.stripe_customer_id, limit=10
+ )
+
+ @patch("services.billing.stripe.Invoice.list")
+ def test_list_filtered_invoices_returns_expected_invoices(self, invoice_list_mock):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ invoice_list_mock.return_value = stripe_invoice_response
+ owner = OwnerFactory(stripe_customer_id=-1)
+ invoices = self.stripe.list_filtered_invoices(owner)
+ assert invoices == expected_invoices
+ assert len(invoices) == 1
+
+ @patch("stripe.Invoice.list")
+ def test_list_filtered_invoices_returns_emptylist_if_stripe_customer_id_is_None(
+ self, invoice_list_mock
+ ):
+ owner = OwnerFactory()
+ invoices = self.stripe.list_filtered_invoices(owner)
+
+ invoice_list_mock.assert_not_called()
+ assert invoices == []
+
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ def test_delete_subscription_without_schedule_modifies_subscription_to_delete_at_end_of_billing_cycle_if_valid_plan(
+ self, modify_mock, retrieve_subscription_mock, retrieve_customer_mock
+ ):
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = None
+ customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ stripe_customer_id=customer_id,
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_123456789",
+ "email": "test@example.com",
+ "name": "Test User",
+ "metadata": {},
+ }
+ self.stripe.delete_subscription(owner)
+ modify_mock.assert_called_once_with(
+ stripe_subscription_id,
+ cancel_at_period_end=True,
+ proration_behavior="none",
+ )
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @freeze_time("2017-03-22T00:00:00")
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.Refund.create")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_at_end_of_billing_cycle_if_valid_plan(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ modify_mock,
+ create_refund_mock,
+ retrieve_customer_mock,
+ ):
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
+ # customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ # stripe_customer_id=customer_id
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_123456789",
+ "email": "test@example.com",
+ "name": "Test User",
+ "metadata": {},
+ }
+ self.stripe.delete_subscription(owner)
+ schedule_release_mock.assert_called_once_with(stripe_schedule_id)
+ modify_mock.assert_called_once_with(
+ stripe_subscription_id,
+ cancel_at_period_end=True,
+ proration_behavior="none",
+ )
+ create_refund_mock.assert_not_called()
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @freeze_time("2017-03-18T00:00:00")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Refund.create")
+ @patch("services.billing.stripe.Invoice.list")
+ @patch("services.billing.stripe.Subscription.cancel")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ @patch("services.billing.stripe.Customer.retrieve")
+ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan(
+ self,
+ retrieve_customer_mock,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ cancel_sub_mock,
+ list_invoice_mock,
+ create_refund_mock,
+ modify_customer_mock,
+ modify_sub_mock,
+ ):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ list_invoice_mock.return_value = stripe_invoice_response
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
+ customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ stripe_customer_id=customer_id,
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ "plan": {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ "interval": "month",
+ },
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_HF6p8Zx7JdRS7A",
+ "metadata": {},
+ }
+ self.stripe.delete_subscription(owner)
+ schedule_release_mock.assert_called_once_with(stripe_schedule_id)
+ retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id)
+ cancel_sub_mock.assert_called_once_with(stripe_subscription_id)
+ list_invoice_mock.assert_called_once_with(
+ subscription=stripe_subscription_id,
+ status="paid",
+ created={"gte": 1458263420, "lt": 1489799420},
+ )
+ self.assertEqual(create_refund_mock.call_count, 2)
+ modify_customer_mock.assert_called_once_with(
+ owner.stripe_customer_id, balance=0, metadata={"autorefunds_remaining": "1"}
+ )
+ modify_sub_mock.assert_not_called()
+
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @freeze_time("2017-03-19T00:00:00")
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Refund.create")
+ @patch("services.billing.stripe.Invoice.list")
+ @patch("services.billing.stripe.Subscription.cancel")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ cancel_sub_mock,
+ list_invoice_mock,
+ create_refund_mock,
+ modify_customer_mock,
+ modify_sub_mock,
+ retrieve_customer_mock,
+ ):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ list_invoice_mock.return_value = stripe_invoice_response
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
+ customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ stripe_customer_id=customer_id,
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ "plan": {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ "interval": "year",
+ },
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_HF6p8Zx7JdRS7A",
+ "metadata": {"autorefunds_remaining": "1"},
+ }
+ self.stripe.delete_subscription(owner)
+ schedule_release_mock.assert_called_once_with(stripe_schedule_id)
+ retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id)
+ cancel_sub_mock.assert_called_once_with(stripe_subscription_id)
+ list_invoice_mock.assert_called_once_with(
+ subscription=stripe_subscription_id,
+ status="paid",
+ created={"gte": 1458263420, "lt": 1489799420},
+ )
+ self.assertEqual(create_refund_mock.call_count, 2)
+ modify_customer_mock.assert_called_once_with(
+ owner.stripe_customer_id, balance=0, metadata={"autorefunds_remaining": "0"}
+ )
+ modify_sub_mock.assert_not_called()
+
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @freeze_time("2017-03-19T00:00:00")
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Refund.create")
+ @patch("services.billing.stripe.Invoice.list")
+ @patch("services.billing.stripe.Subscription.cancel")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ cancel_sub_mock,
+ list_invoice_mock,
+ create_refund_mock,
+ modify_customer_mock,
+ modify_sub_mock,
+ retrieve_customer_mock,
+ ):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ for invoice in stripe_invoice_response["data"]:
+ invoice["charge"] = None
+ list_invoice_mock.return_value = stripe_invoice_response
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
+ customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ stripe_customer_id=customer_id,
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ "plan": {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ "interval": "year",
+ },
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_HF6p8Zx7JdRS7A",
+ "metadata": {"autorefunds_remaining": "1"},
+ }
+ self.stripe.delete_subscription(owner)
+ schedule_release_mock.assert_called_once_with(stripe_schedule_id)
+ retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id)
+ cancel_sub_mock.assert_called_once_with(stripe_subscription_id)
+ list_invoice_mock.assert_called_once_with(
+ subscription=stripe_subscription_id,
+ status="paid",
+ created={"gte": 1458263420, "lt": 1489799420},
+ )
+ create_refund_mock.assert_not_called()
+ modify_customer_mock.assert_not_called()
+ modify_sub_mock.assert_not_called()
+
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @freeze_time("2017-03-19T00:00:00")
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Refund.create")
+ @patch("services.billing.stripe.Invoice.list")
+ @patch("services.billing.stripe.Subscription.cancel")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_at_end_of_billing_cycle_as_no_more_autorefunds_available(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ cancel_sub_mock,
+ list_invoice_mock,
+ create_refund_mock,
+ modify_customer_mock,
+ modify_sub_mock,
+ retrieve_customer_mock,
+ ):
+ with open("./services/tests/samples/stripe_invoice.json") as f:
+ stripe_invoice_response = json.load(f)
+ list_invoice_mock.return_value = stripe_invoice_response
+ plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
+ stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
+ customer_id = "cus_HF6p8Zx7JdRS7A"
+ owner = OwnerFactory(
+ stripe_subscription_id=stripe_subscription_id,
+ plan=plan,
+ plan_activated_users=[4, 6, 3],
+ plan_user_count=9,
+ stripe_customer_id=customer_id,
+ )
+ subscription_params = {
+ "schedule_id": stripe_schedule_id,
+ "start_date": 1489799420,
+ "end_date": 1492477820,
+ "quantity": 10,
+ "name": plan,
+ "id": 215,
+ "plan": {
+ "new_plan": "plan_pro_yearly",
+ "new_quantity": 7,
+ "subscription_id": "sub_123",
+ "interval": "year",
+ },
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ retrieve_customer_mock.return_value = {
+ "id": "cus_HF6p8Zx7JdRS7A",
+ "metadata": {"autorefunds_remaining": "0"},
+ }
+ self.stripe.delete_subscription(owner)
+ schedule_release_mock.assert_called_once_with(stripe_schedule_id)
+ retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id)
+ cancel_sub_mock.assert_not_called()
+ create_refund_mock.assert_not_called()
+ modify_customer_mock.assert_not_called()
+ modify_sub_mock.assert_called_once_with(
+ stripe_subscription_id,
+ cancel_at_period_end=True,
+ proration_behavior="none",
+ )
+
+ owner.refresh_from_db()
+ assert owner.stripe_subscription_id == stripe_subscription_id
+ assert owner.plan == plan
+ assert owner.plan_activated_users == [4, 6, 3]
+ assert owner.plan_user_count == 9
+
+ @patch("logging.Logger.error")
+ def test_modify_subscription_no_plan_found(
+ self,
+ log_error_mock,
+ ):
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ original_user_count = 10
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ )
+
+ desired_plan_name = "invalid plan"
+ desired_user_count = 10
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+ log_error_mock.assert_has_calls(
+ [
+ call(
+ f"Plan {desired_plan_name} not found",
+ extra=dict(owner_id=owner.ownerid),
+ ),
+ ]
+ )
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_increases_user_count_immediately(
+ self,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 10
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 105,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 20
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_upgrades_plan_immediately(
+ self,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ original_user_count = 10
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 101,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 10
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_payment_failure(
+ self,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 10
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ delinquent=False,
+ )
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 105,
+ }
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ subscription_response = {
+ "id": 105,
+ "object": "subscription",
+ "application_fee_percent": None,
+ "pending_update": {
+ "expires_at": 1571194285,
+ "subscription_items": [
+ {
+ "id": "si_09IkI4u3ZypJUk5onGUZpe8O",
+ "price": "price_CBb6IXqvTLXp3f",
+ }
+ ],
+ },
+ }
+ subscription_modify_mock.return_value = MockFailedSubscriptionUpgrade(
+ subscription_response
+ )
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 20
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ # changes to plan are rejected, owner becomes delinquent
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == original_user_count
+ assert owner.delinquent == True
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_payment_no_false_positives(
+ self,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 10
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ delinquent=False,
+ )
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 105,
+ }
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ subscription_response = {
+ "id": 105,
+ "object": "subscription",
+ "application_fee_percent": None,
+ "pending_update": {},
+ }
+ subscription_modify_mock.return_value = MockFailedSubscriptionUpgrade(
+ subscription_response
+ )
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 20
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ # plan is updated, owner is not delinquent
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+ assert owner.delinquent == False
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_but_stripe_is_broken(
+ self, retrieve_subscription_mock, subscription_modify_mock
+ ):
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_YEARLY.value,
+ plan_user_count=10,
+ stripe_subscription_id="33043sdf",
+ delinquent=False,
+ )
+ subscription_params = {
+ "schedule_id": None,
+ "start_date": 1639628096,
+ "end_date": 1644107871,
+ "quantity": 10,
+ "name": PlanName.CODECOV_PRO_YEARLY.value,
+ "id": 105,
+ }
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ subscription_modify_mock.side_effect = requests.exceptions.Timeout
+
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 100}
+ with self.assertRaises(requests.exceptions.Timeout):
+ # if stripe is erroring, it will pop up on sentry
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ owner.refresh_from_db()
+ assert owner.plan == PlanName.CODECOV_PRO_YEARLY.value
+ assert owner.plan_user_count == 10
+ assert owner.delinquent == False
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_upgrades_plan_and_users_immediately(
+ self,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 10
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id="33043sdf",
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 102,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 15
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.SubscriptionSchedule.create")
+ @patch("services.billing.stripe.SubscriptionSchedule.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_adds_schedule_when_user_count_decreases(
+ self, retrieve_subscription_mock, schedule_modify_mock, create_mock
+ ):
+ original_user_count = 14
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 104,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 8
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ create_mock.assert_called_once_with(from_subscription=stripe_subscription_id)
+ schedule_id = create_mock.return_value._mock_children["id"]
+
+ self._assert_schedule_modify(
+ schedule_modify_mock, owner, subscription_params, desired_plan, schedule_id
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+
+ @patch("services.billing.stripe.SubscriptionSchedule.create")
+ @patch("services.billing.stripe.SubscriptionSchedule.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_adds_schedule_when_plan_downgrades(
+ self, retrieve_subscription_mock, schedule_modify_mock, create_mock
+ ):
+ original_user_count = 20
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 106,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 20
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ create_mock.assert_called_once_with(from_subscription=stripe_subscription_id)
+ schedule_id = create_mock.return_value._mock_children["id"]
+
+ self._assert_schedule_modify(
+ schedule_modify_mock, owner, subscription_params, desired_plan, schedule_id
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+
+ @patch("services.billing.stripe.SubscriptionSchedule.create")
+ @patch("services.billing.stripe.SubscriptionSchedule.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_without_schedule_adds_schedule_when_plan_and_count_downgrades(
+ self, retrieve_subscription_mock, schedule_modify_mock, create_mock
+ ):
+ original_user_count = 16
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = None
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 107,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 7
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ create_mock.assert_called_once_with(from_subscription=stripe_subscription_id)
+ schedule_id = create_mock.return_value._mock_children["id"]
+
+ self._assert_schedule_modify(
+ schedule_modify_mock, owner, subscription_params, desired_plan, schedule_id
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+
+ @patch("services.billing.stripe.SubscriptionSchedule.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_with_schedule_modifies_schedule_when_user_count_decreases(
+ self, retrieve_subscription_mock, schedule_modify_mock
+ ):
+ original_user_count = 13
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRnne"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 108,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 9
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_schedule_modify(
+ schedule_modify_mock, owner, subscription_params, desired_plan, schedule_id
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_modify_subscription_with_schedule_modifies_schedule_when_user_count_increases(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 17
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 26
+
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRnne"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 109,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 26
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+ schedule_release_mock.assert_called_once_with(schedule_id)
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.SubscriptionSchedule.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_modify_subscription_with_schedule_modifies_schedule_when_plan_downgrades(
+ self, retrieve_subscription_mock, schedule_modify_mock
+ ):
+ original_user_count = 15
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRn2e"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": Plan.objects.get(name=original_plan).stripe_id,
+ "id": 110,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 15
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+
+ self._assert_schedule_modify(
+ schedule_modify_mock, owner, subscription_params, desired_plan, schedule_id
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == original_plan
+ assert owner.plan_user_count == original_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_modify_subscription_with_schedule_releases_schedule_when_plan_upgrades(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 15
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRn2e"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 111,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 15
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+ schedule_release_mock.assert_called_once_with(schedule_id)
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_modify_subscription_with_schedule_releases_schedule_when_plan_upgrades_and_count_decreases(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 15
+ original_plan = PlanName.CODECOV_PRO_MONTHLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRn2e"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 112,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_YEARLY.value
+ desired_user_count = 10
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+ self.stripe.modify_subscription(owner, desired_plan)
+ schedule_release_mock.assert_called_once_with(schedule_id)
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.billing.stripe.SubscriptionSchedule.release")
+ def test_modify_subscription_with_schedule_releases_schedule_when_plan_downgrades_and_count_increases(
+ self,
+ schedule_release_mock,
+ retrieve_subscription_mock,
+ subscription_modify_mock,
+ ):
+ original_user_count = 15
+ original_plan = PlanName.CODECOV_PRO_YEARLY.value
+ stripe_subscription_id = "33043sdf"
+ owner = OwnerFactory(
+ plan=original_plan,
+ plan_user_count=original_user_count,
+ stripe_subscription_id=stripe_subscription_id,
+ )
+
+ schedule_id = "sub_sched_1K77Y5GlVGuVgOrkJrLjRn2e"
+ current_subscription_start_date = 1639628096
+ current_subscription_end_date = 1644107871
+ subscription_params = {
+ "schedule_id": schedule_id,
+ "start_date": current_subscription_start_date,
+ "end_date": current_subscription_end_date,
+ "quantity": original_user_count,
+ "name": original_plan,
+ "id": 113,
+ }
+
+ retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
+ subscription_modify_mock.return_value = MockSubscription(subscription_params)
+
+ desired_plan_name = PlanName.CODECOV_PRO_MONTHLY.value
+ desired_user_count = 20
+ desired_plan = {"value": desired_plan_name, "quantity": desired_user_count}
+
+ self.stripe.modify_subscription(owner, desired_plan)
+ schedule_release_mock.assert_called_once_with(schedule_id)
+ self._assert_subscription_modify(
+ subscription_modify_mock, owner, subscription_params, desired_plan
+ )
+
+ owner.refresh_from_db()
+ assert owner.plan == desired_plan_name
+ assert owner.plan_user_count == desired_user_count
+
+ def test_get_proration_params(self):
+ # Test same plan, increased users
+ owner = OwnerFactory(plan=PlanName.CODECOV_PRO_YEARLY.value, plan_user_count=10)
+ plan = Plan.objects.get(name=PlanName.CODECOV_PRO_YEARLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=14
+ )
+ == "always_invoice"
+ )
+
+ # Test same plan, decrease users
+ owner = OwnerFactory(plan=PlanName.CODECOV_PRO_YEARLY.value, plan_user_count=20)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=14
+ )
+ == "none"
+ )
+
+ # Test going from monthly to yearly
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_MONTHLY.value, plan_user_count=20
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=14
+ )
+ == "always_invoice"
+ )
+
+ # monthly to Sentry monthly plan
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_MONTHLY.value, plan_user_count=20
+ )
+ plan = Plan.objects.get(name=PlanName.SENTRY_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=19
+ )
+ == "none"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=20
+ )
+ == "always_invoice"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=21
+ )
+ == "always_invoice"
+ )
+
+ # yearly to Sentry monthly plan
+ owner = OwnerFactory(plan=PlanName.CODECOV_PRO_YEARLY.value, plan_user_count=20)
+ plan = Plan.objects.get(name=PlanName.SENTRY_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=19
+ )
+ == "none"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=20
+ )
+ == "none"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=21
+ )
+ == "always_invoice"
+ )
+
+ # monthly to Sentry monthly plan
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_MONTHLY.value, plan_user_count=20
+ )
+ plan = Plan.objects.get(name=PlanName.SENTRY_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=19
+ )
+ == "none"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=20
+ )
+ == "always_invoice"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=21
+ )
+ == "always_invoice"
+ )
+
+ # yearly to Sentry yearly plan
+ owner = OwnerFactory(plan=PlanName.CODECOV_PRO_YEARLY.value, plan_user_count=20)
+ plan = Plan.objects.get(name=PlanName.SENTRY_YEARLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=19
+ )
+ == "none"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=20
+ )
+ == "always_invoice"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=21
+ )
+ == "always_invoice"
+ )
+
+ # monthly to Sentry yearly plan
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_MONTHLY.value, plan_user_count=20
+ )
+ plan = Plan.objects.get(name=PlanName.SENTRY_YEARLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=19
+ )
+ == "always_invoice"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=20
+ )
+ == "always_invoice"
+ )
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=21
+ )
+ == "always_invoice"
+ )
+
+ # Team to Sentry
+ owner = OwnerFactory(plan=PlanName.TEAM_MONTHLY.value, plan_user_count=10)
+ plan = Plan.objects.get(name=PlanName.SENTRY_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=10
+ )
+ == "always_invoice"
+ )
+
+ # Team to Pro
+ owner = OwnerFactory(plan=PlanName.TEAM_MONTHLY.value, plan_user_count=10)
+ plan = Plan.objects.get(name=PlanName.CODECOV_PRO_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=10
+ )
+ == "always_invoice"
+ )
+
+ # Sentry to Team
+ owner = OwnerFactory(plan=PlanName.SENTRY_MONTHLY.value, plan_user_count=10)
+ plan = Plan.objects.get(name=PlanName.TEAM_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=10
+ )
+ == "none"
+ )
+
+ # Sentry to Pro
+ owner = OwnerFactory(
+ plan=PlanName.CODECOV_PRO_MONTHLY.value, plan_user_count=10
+ )
+ plan = Plan.objects.get(name=PlanName.TEAM_MONTHLY.value)
+ assert (
+ self.stripe._get_proration_params(
+ owner=owner, desired_plan_info=plan, desired_quantity=10
+ )
+ == "none"
+ )
+
+ @patch("services.billing.stripe.checkout.Session.create")
+ def test_create_checkout_session_with_no_stripe_customer_id(
+ self, create_checkout_session_mock
+ ):
+ stripe_customer_id = None
+ owner = OwnerFactory(
+ service=Service.GITHUB.value,
+ stripe_customer_id=stripe_customer_id,
+ )
+ expected_id = "fkkgosd"
+ create_checkout_session_mock.return_value = {"id": expected_id}
+ desired_quantity = 25
+ desired_plan = {
+ "value": PlanName.CODECOV_PRO_MONTHLY.value,
+ "quantity": desired_quantity,
+ }
+ plan = Plan.objects.get(name=desired_plan["value"])
+
+ assert self.stripe.create_checkout_session(owner, desired_plan) == expected_id
+
+ create_checkout_session_mock.assert_called_once_with(
+ billing_address_collection="required",
+ payment_method_configuration=settings.STRIPE_PAYMENT_METHOD_CONFIGURATION_ID,
+ payment_method_collection="if_required",
+ client_reference_id=str(owner.ownerid),
+ customer=None,
+ success_url=f"{settings.CODECOV_DASHBOARD_URL}/plan/gh/{owner.username}?success",
+ cancel_url=f"{settings.CODECOV_DASHBOARD_URL}/plan/gh/{owner.username}?cancel",
+ mode="subscription",
+ line_items=[
+ {
+ "price": plan.stripe_id,
+ "quantity": desired_quantity,
+ }
+ ],
+ subscription_data={
+ "metadata": {
+ "service": owner.service,
+ "obo_organization": owner.ownerid,
+ "username": owner.username,
+ "obo_name": self.user.name,
+ "obo_email": self.user.email,
+ "obo": self.user.ownerid,
+ },
+ },
+ tax_id_collection={"enabled": True},
+ customer_update=None,
+ )
+
+ @patch("services.billing.stripe.checkout.Session.create")
+ def test_create_checkout_session_with_stripe_customer_id(
+ self, create_checkout_session_mock
+ ):
+ stripe_customer_id = "test-cusa78723hb4@"
+ owner = OwnerFactory(
+ service=Service.GITHUB.value,
+ stripe_customer_id=stripe_customer_id,
+ )
+ expected_id = "fkkgosd"
+ create_checkout_session_mock.return_value = {"id": expected_id}
+ desired_quantity = 25
+ desired_plan = {
+ "value": PlanName.CODECOV_PRO_MONTHLY.value,
+ "quantity": desired_quantity,
+ }
+
+ assert self.stripe.create_checkout_session(owner, desired_plan) == expected_id
+
+ plan = Plan.objects.get(name=desired_plan["value"])
+
+ create_checkout_session_mock.assert_called_once_with(
+ billing_address_collection="required",
+ payment_method_configuration=settings.STRIPE_PAYMENT_METHOD_CONFIGURATION_ID,
+ payment_method_collection="if_required",
+ client_reference_id=str(owner.ownerid),
+ customer=owner.stripe_customer_id,
+ success_url=f"{settings.CODECOV_DASHBOARD_URL}/plan/gh/{owner.username}?success",
+ cancel_url=f"{settings.CODECOV_DASHBOARD_URL}/plan/gh/{owner.username}?cancel",
+ mode="subscription",
+ line_items=[
+ {
+ "price": plan.stripe_id,
+ "quantity": desired_quantity,
+ }
+ ],
+ subscription_data={
+ "metadata": {
+ "service": owner.service,
+ "obo_organization": owner.ownerid,
+ "username": owner.username,
+ "obo_name": self.user.name,
+ "obo_email": self.user.email,
+ "obo": self.user.ownerid,
+ },
+ },
+ tax_id_collection={"enabled": True},
+ customer_update={"name": "auto", "address": "auto"},
+ )
+
+ @patch("logging.Logger.error")
+ @patch("services.billing.stripe.checkout.Session.create")
+ def test_create_checkout_session_with_invalid_plan(
+ self, create_checkout_session_mock, logger_error_mock
+ ):
+ stripe_customer_id = "test-cusa78723hb4@"
+ owner = OwnerFactory(
+ service=Service.GITHUB.value,
+ stripe_customer_id=stripe_customer_id,
+ )
+ desired_quantity = 25
+ desired_plan = {
+ "value": "invalid_plan",
+ "quantity": desired_quantity,
+ }
+
+ self.stripe.create_checkout_session(owner, desired_plan)
+
+ create_checkout_session_mock.assert_not_called()
+ logger_error_mock.assert_called_once_with(
+ f"Plan {desired_plan['value']} not found",
+ extra=dict(
+ owner_id=owner.ownerid,
+ ),
+ )
+
+ def test_get_subscription_when_no_subscription(self):
+ owner = OwnerFactory(stripe_subscription_id=None)
+ assert self.stripe.get_subscription(owner) is None
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ def test_get_subscription_returns_stripe_data(self, subscription_retrieve_mock):
+ owner = OwnerFactory(stripe_subscription_id="abc")
+ # only including fields relevant to implementation
+ stripe_data_subscription = {"doesnt": "matter"}
+ subscription_retrieve_mock.return_value = stripe_data_subscription
+ assert self.stripe.get_subscription(owner) == stripe_data_subscription
+ subscription_retrieve_mock.assert_called_once_with(
+ owner.stripe_subscription_id,
+ expand=[
+ "latest_invoice",
+ "customer",
+ "customer.invoice_settings.default_payment_method",
+ "customer.tax_ids",
+ ],
+ )
+
+ def test_update_payment_method_when_no_subscription(self):
+ owner = OwnerFactory(stripe_subscription_id=None)
+ assert self.stripe.update_payment_method(owner, "abc") is None
+
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.StripeService._is_unverified_payment_method")
+ def test_update_payment_method(
+ self,
+ is_unverified_mock,
+ modify_sub_mock,
+ modify_customer_mock,
+ attach_payment_mock,
+ ):
+ payment_method_id = "pm_1234567"
+ subscription_id = "sub_abc"
+ customer_id = "cus_abc"
+ owner = OwnerFactory(
+ stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
+ )
+ is_unverified_mock.return_value = False
+ self.stripe.update_payment_method(owner, payment_method_id)
+ attach_payment_mock.assert_called_once_with(
+ payment_method_id, customer=customer_id
+ )
+ modify_customer_mock.assert_called_once_with(
+ customer_id, invoice_settings={"default_payment_method": payment_method_id}
+ )
+
+ modify_sub_mock.assert_called_once_with(
+ subscription_id, default_payment_method=payment_method_id
+ )
+
+ @patch("services.billing.stripe.PaymentMethod.attach")
+ @patch("services.billing.stripe.Customer.modify")
+ @patch("services.billing.stripe.Subscription.modify")
+ @patch("services.billing.stripe.PaymentMethod.retrieve")
+ @patch("services.billing.stripe.SetupIntent.list")
+ def test_update_payment_method_with_unverified_payment_method(
+ self,
+ setup_intent_list_mock,
+ payment_method_retrieve_mock,
+ modify_sub_mock,
+ modify_customer_mock,
+ attach_payment_mock,
+ ):
+ # Define the mock return values
+ setup_intent_list_mock.return_value = MagicMock(
+ data=[
+ MagicMock(
+ status="requires_action",
+ next_action=MagicMock(
+ type="verify_with_microdeposits",
+ verify_with_microdeposits=MagicMock(
+ hosted_verification_url="https://verify.stripe.com/1"
+ ),
+ ),
+ )
+ ]
+ )
+ payment_method_retrieve_mock.return_value = MagicMock(
+ type="us_bank_account",
+ us_bank_account=MagicMock(
+ status="requires_action",
+ next_action=MagicMock(
+ type="verify_with_microdeposits",
+ verify_with_microdeposits=MagicMock(
+ hosted_verification_url="https://verify.stripe.com/1"
+ ),
+ ),
+ ),
+ )
+ modify_sub_mock.return_value = MagicMock()
+ modify_customer_mock.return_value = MagicMock()
+ attach_payment_mock.return_value = MagicMock()
+
+ # Create a mock owner object
+ subscription_id = "sub_abc"
+ customer_id = "cus_abc"
+ owner = OwnerFactory(
+ stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
+ )
+
+ result = self.stripe.update_payment_method(owner, "abc")
+
+ assert result is None
+ assert payment_method_retrieve_mock.called
+ assert setup_intent_list_mock.called
+ assert not attach_payment_mock.called
+ assert not modify_customer_mock.called
+ assert not modify_sub_mock.called
+
+ def test_update_email_address_with_invalid_email(self):
+ owner = OwnerFactory(stripe_subscription_id=None)
+ assert self.stripe.update_email_address(owner, "not-an-email") is None
+
+ def test_update_email_address_when_no_subscription(self):
+ owner = OwnerFactory(stripe_subscription_id=None)
+ assert self.stripe.update_email_address(owner, "test@gmail.com") is None
+
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_email_address(self, modify_customer_mock):
+ subscription_id = "sub_abc"
+ customer_id = "cus_abc"
+ email = "test@gmail.com"
+ owner = OwnerFactory(
+ stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
+ )
+ self.stripe.update_email_address(owner, "test@gmail.com")
+ modify_customer_mock.assert_called_once_with(customer_id, email=email)
+
+ @patch("logging.Logger.error")
+ def test_update_billing_address_with_invalid_address(self, log_error_mock):
+ owner = OwnerFactory(stripe_customer_id="123", stripe_subscription_id="123")
+ assert self.stripe.update_billing_address(owner, "John Doe", "gabagool") is None
+ log_error_mock.assert_called_with(
+ "Unable to update billing address for customer",
+ extra={
+ "customer_id": "123",
+ "subscription_id": "123",
+ },
+ )
+
+ def test_update_billing_address_when_no_customer_id(self):
+ owner = OwnerFactory(stripe_customer_id=None)
+ assert (
+ self.stripe.update_billing_address(
+ owner,
+ name="John Doe",
+ billing_address={
+ "line1": "45 Fremont St.",
+ "line2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ },
+ )
+ is None
+ )
+
+ @patch("services.billing.stripe.Customer.retrieve")
+ @patch("services.billing.stripe.PaymentMethod.modify")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_update_billing_address(
+ self, modify_customer_mock, modify_payment_mock, retrieve_customer_mock
+ ):
+ subscription_id = "sub_abc"
+ customer_id = "cus_abc"
+ owner = OwnerFactory(
+ stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
+ )
+ billing_address = {
+ "line1": "45 Fremont St.",
+ "line2": "",
+ "city": "San Francisco",
+ "state": "CA",
+ "country": "US",
+ "postal_code": "94105",
+ }
+ self.stripe.update_billing_address(
+ owner,
+ name="John Doe",
+ billing_address=billing_address,
+ )
+
+ retrieve_customer_mock.assert_called_once()
+ modify_payment_mock.assert_called_once()
+ modify_customer_mock.assert_called_once_with(
+ customer_id, address=billing_address
+ )
+
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_get_invoice_not_found(self, retrieve_invoice_mock):
+ invoice_id = "abc"
+ retrieve_invoice_mock.side_effect = InvalidRequestError(
+ message="not found", param=invoice_id
+ )
+ assert self.stripe.get_invoice(OwnerFactory(), invoice_id) is None
+ retrieve_invoice_mock.assert_called_once_with(invoice_id)
+
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_get_invoice_customer_dont_match(self, retrieve_invoice_mock):
+ owner = OwnerFactory(stripe_customer_id="something_very_very_random_cus_abc")
+ invoice_id = "abc"
+ invoice = {"invoice_id": "abc", "customer": "cus_abc"}
+ retrieve_invoice_mock.return_value = invoice
+ assert self.stripe.get_invoice(owner, invoice_id) is None
+ retrieve_invoice_mock.assert_called_once_with(invoice_id)
+
+ @patch("services.billing.stripe.Invoice.retrieve")
+ def test_get_invoice(self, retrieve_invoice_mock):
+ customer_id = "cus_abc"
+ owner = OwnerFactory(stripe_customer_id=customer_id)
+ invoice_id = "abc"
+ invoice = {"invoice_id": "abc", "customer": customer_id}
+ retrieve_invoice_mock.return_value = invoice
+ assert self.stripe.get_invoice(owner, invoice_id) == invoice
+ retrieve_invoice_mock.assert_called_once_with(invoice_id)
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_apply_cancellation_discount(
+ self, customer_modify_mock, coupon_create_mock
+ ):
+ coupon_create_mock.return_value = MagicMock(id="test-coupon-id")
+
+ owner = OwnerFactory(
+ stripe_subscription_id="test-subscription-id",
+ stripe_customer_id="test-customer-id",
+ plan="users-pr-inappm",
+ )
+ self.stripe.apply_cancellation_discount(owner)
+
+ coupon_create_mock.assert_called_once_with(
+ percent_off=30.0,
+ duration="repeating",
+ duration_in_months=6,
+ name="30% off for 6 months",
+ max_redemptions=1,
+ metadata={
+ "ownerid": owner.ownerid,
+ "username": owner.username,
+ "email": owner.email,
+ "name": owner.name,
+ },
+ )
+ customer_modify_mock.assert_called_once_with(
+ "test-customer-id",
+ coupon="test-coupon-id",
+ )
+
+ owner.refresh_from_db()
+ assert owner.stripe_coupon_id == "test-coupon-id"
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_apply_cancellation_discount_yearly(
+ self, customer_modify_mock, coupon_create_mock
+ ):
+ owner = OwnerFactory(
+ stripe_customer_id="test-customer-id",
+ stripe_subscription_id=None,
+ plan="users-inappy",
+ )
+ self.stripe.apply_cancellation_discount(owner)
+
+ assert not customer_modify_mock.called
+ assert not coupon_create_mock.called
+ assert owner.stripe_coupon_id is None
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_apply_cancellation_discount_no_subscription(
+ self, customer_modify_mock, coupon_create_mock
+ ):
+ owner = OwnerFactory(
+ stripe_customer_id="test-customer-id",
+ stripe_subscription_id=None,
+ )
+ self.stripe.apply_cancellation_discount(owner)
+
+ assert not customer_modify_mock.called
+ assert not coupon_create_mock.called
+ assert owner.stripe_coupon_id is None
+
+ @patch("services.billing.stripe.Coupon.create")
+ @patch("services.billing.stripe.Customer.modify")
+ def test_apply_cancellation_discount_existing_coupon(
+ self, customer_modify_mock, coupon_create_mock
+ ):
+ owner = OwnerFactory(
+ stripe_customer_id="test-customer-id",
+ stripe_subscription_id="test-subscription-id",
+ stripe_coupon_id="test-coupon-id",
+ )
+ self.stripe.apply_cancellation_discount(owner)
+
+ assert not customer_modify_mock.called
+ assert not coupon_create_mock.called
+
+ @patch("services.billing.stripe.SetupIntent.create")
+ def test_create_setup_intent(self, setup_intent_create_mock):
+ owner = OwnerFactory(stripe_customer_id="test-customer-id")
+ setup_intent_create_mock.return_value = {"client_secret": "test-client-secret"}
+ resp = self.stripe.create_setup_intent(owner)
+ assert resp["client_secret"] == "test-client-secret"
+
+ @patch("services.billing.stripe.PaymentIntent.list")
+ @patch("services.billing.stripe.SetupIntent.list")
+ def test_get_unverified_payment_methods(
+ self, setup_intent_list_mock, payment_intent_list_mock
+ ):
+ owner = OwnerFactory(stripe_customer_id="test-customer-id")
+ payment_intent = PaymentIntent.construct_from(
+ {
+ "id": "pi_123",
+ "payment_method": "pm_123",
+ "next_action": {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": "https://verify.stripe.com/1"
+ },
+ },
+ },
+ "fake_api_key",
+ )
+
+ setup_intent = SetupIntent.construct_from(
+ {
+ "id": "si_123",
+ "payment_method": "pm_456",
+ "next_action": {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": "https://verify.stripe.com/2"
+ },
+ },
+ },
+ "fake_api_key",
+ )
+
+ payment_intent_list_mock.return_value.data = [payment_intent]
+ payment_intent_list_mock.return_value.has_more = False
+ setup_intent_list_mock.return_value.data = [setup_intent]
+ setup_intent_list_mock.return_value.has_more = False
+
+ expected = [
+ {
+ "payment_method_id": "pm_123",
+ "hosted_verification_url": "https://verify.stripe.com/1",
+ },
+ {
+ "payment_method_id": "pm_456",
+ "hosted_verification_url": "https://verify.stripe.com/2",
+ },
+ ]
+ assert self.stripe.get_unverified_payment_methods(owner) == expected
+
+ @patch("services.billing.stripe.PaymentIntent.list")
+ @patch("services.billing.stripe.SetupIntent.list")
+ def test_get_unverified_payment_methods_pagination(
+ self, setup_intent_list_mock, payment_intent_list_mock
+ ):
+ owner = OwnerFactory(stripe_customer_id="test-customer-id")
+
+ # Create 42 payment intents with only 2 having microdeposits verification
+ payment_intents = []
+ for i in range(42):
+ next_action = None
+ if i in [0, 41]: # First and last have verification
+ next_action = {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": f"https://verify.stripe.com/pi_{i}"
+ },
+ }
+ payment_intents.append(
+ PaymentIntent.construct_from(
+ {
+ "id": f"pi_{i}",
+ "payment_method": f"pm_pi_{i}",
+ "next_action": next_action,
+ },
+ "fake_api_key",
+ )
+ )
+
+ # Create 42 setup intents with only 2 having microdeposits verification
+ setup_intents = []
+ for i in range(42):
+ next_action = None
+ if i in [0, 41]: # First and last have verification
+ next_action = {
+ "type": "verify_with_microdeposits",
+ "verify_with_microdeposits": {
+ "hosted_verification_url": f"https://verify.stripe.com/si_{i}"
+ },
+ }
+ setup_intents.append(
+ SetupIntent.construct_from(
+ {
+ "id": f"si_{i}",
+ "payment_method": f"pm_si_{i}",
+ "next_action": next_action,
+ },
+ "fake_api_key",
+ )
+ )
+
+ # Split into pages of 20
+ payment_intent_pages = [
+ type(
+ "obj",
+ (object,),
+ {
+ "data": payment_intents[i : i + 20],
+ "has_more": i + 20 < len(payment_intents),
+ },
+ )
+ for i in range(0, len(payment_intents), 20)
+ ]
+
+ setup_intent_pages = [
+ type(
+ "obj",
+ (object,),
+ {
+ "data": setup_intents[i : i + 20],
+ "has_more": i + 20 < len(setup_intents),
+ },
+ )
+ for i in range(0, len(setup_intents), 20)
+ ]
+
+ payment_intent_list_mock.side_effect = payment_intent_pages
+ setup_intent_list_mock.side_effect = setup_intent_pages
+
+ expected = [
+ {
+ "payment_method_id": "pm_pi_0",
+ "hosted_verification_url": "https://verify.stripe.com/pi_0",
+ },
+ {
+ "payment_method_id": "pm_pi_41",
+ "hosted_verification_url": "https://verify.stripe.com/pi_41",
+ },
+ {
+ "payment_method_id": "pm_si_0",
+ "hosted_verification_url": "https://verify.stripe.com/si_0",
+ },
+ {
+ "payment_method_id": "pm_si_41",
+ "hosted_verification_url": "https://verify.stripe.com/si_41",
+ },
+ ]
+
+ result = self.stripe.get_unverified_payment_methods(owner)
+ assert result == expected
+ assert len(result) == 4 # Verify we got exactly 4 results
+
+ # Verify pagination calls
+ payment_intent_calls = [
+ call(customer="test-customer-id", limit=20, starting_after=None),
+ call(customer="test-customer-id", limit=20, starting_after="pi_19"),
+ call(customer="test-customer-id", limit=20, starting_after="pi_39"),
+ ]
+ setup_intent_calls = [
+ call(customer="test-customer-id", limit=20, starting_after=None),
+ call(customer="test-customer-id", limit=20, starting_after="si_19"),
+ call(customer="test-customer-id", limit=20, starting_after="si_39"),
+ ]
+
+ payment_intent_list_mock.assert_has_calls(payment_intent_calls)
+ setup_intent_list_mock.assert_has_calls(setup_intent_calls)
+
+
+class MockPaymentService(AbstractPaymentService):
+ def list_filtered_invoices(self, owner, limit=10):
+ return f"{owner.ownerid} {limit}"
+
+ def get_invoice(self, owner, id):
+ pass
+
+ def delete_subscription(self, owner):
+ pass
+
+ def modify_subscription(self, owner, plan):
+ pass
+
+ def create_checkout_session(self, owner, plan):
+ pass
+
+ def get_subscription(self, owner, plan):
+ pass
+
+ def update_payment_method(self, owner, plan):
+ pass
+
+ def update_email_address(self, owner, email_address):
+ pass
+
+ def update_billing_address(self, owner, name, billing_address):
+ pass
+
+ def get_schedule(self, owner):
+ pass
+
+ def apply_cancellation_discount(self, owner):
+ pass
+
+ def create_setup_intent(self, owner):
+ pass
+
+
+class BillingServiceTests(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+
+ def setUp(self):
+ self.mock_payment_service = MockPaymentService()
+ self.billing_service = BillingService(payment_service=self.mock_payment_service)
+
+ def test_default_payment_service_is_stripe(self):
+ requesting_user = OwnerFactory()
+ assert isinstance(
+ BillingService(requesting_user=requesting_user).payment_service,
+ StripeService,
+ )
+
+ def test_list_filtered_invoices_calls_payment_service_list_filtered_invoices_with_limit(
+ self,
+ ):
+ owner = OwnerFactory()
+ assert self.billing_service.list_filtered_invoices(
+ owner
+ ) == self.mock_payment_service.list_filtered_invoices(owner)
+
+ @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
+ def test_update_plan_to_users_developer_deletes_subscription_if_user_has_stripe_subscription(
+ self, delete_subscription_mock
+ ):
+ owner = OwnerFactory(stripe_subscription_id="tor_dsoe")
+ self.billing_service.update_plan(owner, {"value": DEFAULT_FREE_PLAN})
+ delete_subscription_mock.assert_called_once_with(owner)
+
+ @patch("shared.plan.service.PlanService.set_default_plan_data")
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
+ def test_update_plan_to_users_developer_sets_plan_if_no_subscription_id(
+ self,
+ delete_subscription_mock,
+ modify_subscription_mock,
+ create_checkout_session_mock,
+ set_default_plan_data,
+ ):
+ owner = OwnerFactory()
+ self.billing_service.update_plan(owner, {"value": DEFAULT_FREE_PLAN})
+
+ set_default_plan_data.assert_called_once()
+
+ delete_subscription_mock.assert_not_called()
+ modify_subscription_mock.assert_not_called()
+ create_checkout_session_mock.assert_not_called()
+
+ @patch("shared.plan.service.PlanService.set_default_plan_data")
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.get_subscription")
+ def test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists(
+ self,
+ get_subscription_mock,
+ delete_subscription_mock,
+ modify_subscription_mock,
+ create_checkout_session_mock,
+ set_default_plan_data,
+ ):
+ owner = OwnerFactory(stripe_subscription_id=10)
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
+
+ get_subscription_mock.return_value = stripe.util.convert_to_stripe_object(
+ {
+ "schedule": None,
+ "current_period_start": 1489799420,
+ "current_period_end": 1492477820,
+ "quantity": 10,
+ "name": PlanName.CODECOV_PRO_YEARLY.value,
+ "id": 215,
+ "status": "active",
+ }
+ )
+
+ self.billing_service.update_plan(owner, desired_plan)
+ modify_subscription_mock.assert_called_once_with(owner, desired_plan)
+
+ set_default_plan_data.assert_not_called()
+ delete_subscription_mock.assert_not_called()
+ create_checkout_session_mock.assert_not_called()
+
+ @patch("shared.plan.service.PlanService.set_default_plan_data")
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
+ def test_update_plan_creates_checkout_session_if_user_plan_and_no_subscription(
+ self,
+ delete_subscription_mock,
+ modify_subscription_mock,
+ create_checkout_session_mock,
+ set_default_plan_data,
+ ):
+ owner = OwnerFactory(stripe_subscription_id=None)
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
+ self.billing_service.update_plan(owner, desired_plan)
+
+ create_checkout_session_mock.assert_called_once_with(owner, desired_plan)
+
+ set_default_plan_data.assert_not_called()
+ delete_subscription_mock.assert_not_called()
+ modify_subscription_mock.assert_not_called()
+
+ @patch("services.tests.test_billing.MockPaymentService.get_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ @patch("services.billing.BillingService._cleanup_incomplete_subscription")
+ def test_update_plan_cleans_up_incomplete_subscription_and_creates_new_checkout(
+ self,
+ cleanup_incomplete_mock,
+ modify_subscription_mock,
+ create_checkout_session_mock,
+ get_subscription_mock,
+ ):
+ owner = OwnerFactory(stripe_subscription_id="sub_123")
+ desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
+
+ subscription = stripe.Subscription.construct_from(
+ {"status": "incomplete"}, "fake_api_key"
+ )
+ get_subscription_mock.return_value = subscription
+
+ self.billing_service.update_plan(owner, desired_plan)
+
+ cleanup_incomplete_mock.assert_called_once_with(subscription, owner)
+ create_checkout_session_mock.assert_called_once_with(owner, desired_plan)
+ modify_subscription_mock.assert_not_called()
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Subscription.delete")
+ def test_cleanup_incomplete_subscription(self, delete_mock, retrieve_mock):
+ owner = OwnerFactory(stripe_subscription_id="sub_123")
+
+ payment_intent = stripe.PaymentIntent.construct_from(
+ {"id": "pi_123", "status": "requires_action"}, "fake_api_key"
+ )
+ subscription = stripe.Subscription.construct_from(
+ {"id": "abcd", "latest_invoice": {"payment_intent": "pi_123"}},
+ "fake_api_key",
+ )
+ retrieve_mock.return_value = payment_intent
+
+ self.billing_service._cleanup_incomplete_subscription(subscription, owner)
+
+ retrieve_mock.assert_called_once_with("pi_123")
+ delete_mock.assert_called_once_with(subscription)
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Subscription.delete")
+ def test_cleanup_incomplete_subscription_no_latest_invoice(
+ self, delete_mock, retrieve_mock
+ ):
+ owner = OwnerFactory(stripe_subscription_id="sub_123")
+
+ subscription = stripe.Subscription.construct_from(
+ {"id": "sub_123"}, "fake_api_key"
+ )
+
+ result = self.billing_service._cleanup_incomplete_subscription(
+ subscription, owner
+ )
+
+ assert result is None
+ delete_mock.assert_not_called()
+ retrieve_mock.assert_not_called()
+ assert owner.stripe_subscription_id == "sub_123"
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Subscription.delete")
+ def test_cleanup_incomplete_subscription_no_payment_intent(
+ self, delete_mock, retrieve_mock
+ ):
+ owner = OwnerFactory(stripe_subscription_id="sub_123")
+
+ class MockSubscription:
+ id = "sub_123"
+
+ def get(self, key):
+ if key == "latest_invoice":
+ return {"payment_intent": None}
+ return None
+
+ subscription = MockSubscription()
+
+ result = self.billing_service._cleanup_incomplete_subscription(
+ subscription, owner
+ )
+
+ assert result is None
+ delete_mock.assert_not_called()
+ retrieve_mock.assert_not_called()
+ assert owner.stripe_subscription_id == "sub_123"
+
+ @patch("services.billing.stripe.PaymentIntent.retrieve")
+ @patch("services.billing.stripe.Subscription.delete")
+ def test_cleanup_incomplete_subscription_delete_fails(
+ self, delete_mock, retrieve_mock
+ ):
+ owner = OwnerFactory(stripe_subscription_id="sub_123")
+
+ payment_intent = stripe.PaymentIntent.construct_from(
+ {"id": "pi_123", "status": "requires_action"}, "fake_api_key"
+ )
+ subscription = stripe.Subscription.construct_from(
+ {"id": "abcd", "latest_invoice": {"payment_intent": "pi_123"}},
+ "fake_api_key",
+ )
+ retrieve_mock.return_value = payment_intent
+ delete_mock.side_effect = Exception("Delete failed")
+
+ result = self.billing_service._cleanup_incomplete_subscription(
+ subscription, owner
+ )
+
+ assert result is None
+ retrieve_mock.assert_called_once_with("pi_123")
+ delete_mock.assert_called_once_with(subscription)
+ assert owner.stripe_subscription_id == "sub_123"
+
+ @patch("shared.plan.service.PlanService.set_default_plan_data")
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
+ def test_update_plan_does_nothing_if_not_switching_to_user_plan(
+ self,
+ delete_subscription_mock,
+ modify_subscription_mock,
+ create_checkout_session_mock,
+ set_default_plan_data,
+ ):
+ owner = OwnerFactory()
+ desired_plan = {"value": "v4-50m"}
+ self.billing_service.update_plan(owner, desired_plan)
+
+ set_default_plan_data.assert_not_called()
+ delete_subscription_mock.assert_not_called()
+ modify_subscription_mock.assert_not_called()
+ create_checkout_session_mock.assert_not_called()
+
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ def test_update_plan_sentry_user_sentrym(
+ self, modify_subscription_mock, create_checkout_session_mock
+ ):
+ owner = OwnerFactory(sentry_user_id="sentry-user")
+ desired_plan = {"value": PlanName.SENTRY_MONTHLY.value}
+ self.billing_service.update_plan(owner, desired_plan)
+
+ modify_subscription_mock.assert_not_called()
+ create_checkout_session_mock.assert_called_once_with(owner, desired_plan)
+
+ @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
+ @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
+ def test_update_plan_sentry_user_sentryy(
+ self, modify_subscription_mock, create_checkout_session_mock
+ ):
+ owner = OwnerFactory(sentry_user_id="sentry-user")
+ desired_plan = {"value": PlanName.SENTRY_YEARLY.value}
+ self.billing_service.update_plan(owner, desired_plan)
+
+ modify_subscription_mock.assert_not_called()
+ create_checkout_session_mock.assert_called_once_with(owner, desired_plan)
+
+ @patch("services.tests.test_billing.MockPaymentService.get_subscription")
+ def test_get_subscription(self, get_subscription_mock):
+ owner = OwnerFactory()
+ self.billing_service.get_subscription(owner)
+ get_subscription_mock.assert_called_once_with(owner)
+
+ @patch("services.tests.test_billing.MockPaymentService.update_payment_method")
+ def test_update_payment_method(self, get_subscription_mock):
+ owner = OwnerFactory()
+ self.billing_service.update_payment_method(owner, "abc")
+ get_subscription_mock.assert_called_once_with(owner, "abc")
+
+ @patch("services.tests.test_billing.MockPaymentService.update_email_address")
+ def test_email_address(self, get_subscription_mock):
+ owner = OwnerFactory()
+ self.billing_service.update_email_address(owner, "test@gmail.com", False)
+ get_subscription_mock.assert_called_once_with(owner, "test@gmail.com", False)
+
+ @patch("services.tests.test_billing.MockPaymentService.get_invoice")
+ def test_get_invoice(self, get_invoice_mock):
+ owner = OwnerFactory()
+ self.billing_service.get_invoice(owner, "abc")
+ get_invoice_mock.assert_called_once_with(owner, "abc")
diff --git a/apps/codecov-api/services/tests/test_bundle_analysis.py b/apps/codecov-api/services/tests/test_bundle_analysis.py
new file mode 100644
index 0000000000..7240b09880
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_bundle_analysis.py
@@ -0,0 +1,178 @@
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis import BundleAnalysisReport as SharedBundleAnalysisReport
+from shared.bundle_analysis import (
+ BundleAnalysisReportLoader,
+ BundleChange,
+ StoragePaths,
+)
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+from shared.storage.memory import MemoryStorageService
+
+from reports.models import CommitReport
+from reports.tests.factories import CommitReportFactory
+from services.bundle_analysis import (
+ BundleAnalysisComparison,
+ BundleAnalysisReport,
+ BundleComparison,
+ BundleReport,
+ load_report,
+)
+
+
+@pytest.mark.django_db
+@patch("services.bundle_analysis.get_appropriate_storage_service")
+def test_load_report(get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ repo = RepositoryFactory()
+ commit = CommitFactory(repository=repo)
+
+ # no commit report record
+ assert load_report(commit) is None
+
+ commit_report = CommitReportFactory(
+ commit=commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(repo),
+ report_key=commit_report.external_id,
+ )
+
+ # nothing in storage
+ assert load_report(commit) is None
+
+ with open("./services/tests/samples/bundle_report.sqlite", "rb") as f:
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ report = load_report(commit)
+ assert report is not None
+ assert isinstance(report, SharedBundleAnalysisReport)
+
+
+class TestBundleComparison(TestCase):
+ @patch("services.bundle_analysis.SharedBundleChange")
+ def test_bundle_comparison(self, mock_shared_bundle_change):
+ mock_shared_bundle_change = BundleChange(
+ bundle_name="bundle1",
+ change_type=BundleChange.ChangeType.ADDED,
+ size_delta=1000000,
+ percentage_delta=0.0,
+ )
+
+ bundle_comparison = BundleComparison(
+ mock_shared_bundle_change,
+ 7654321,
+ )
+
+ assert bundle_comparison.bundle_name == "bundle1"
+ assert bundle_comparison.change_type == "added"
+ assert bundle_comparison.size_delta == 1000000
+ assert bundle_comparison.size_total == 7654321
+
+
+class TestBundleAnalysisComparison(TestCase):
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ self.base_commit = CommitFactory(repository=self.repo)
+ self.base_commit_report = CommitReportFactory(
+ commit=self.base_commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ self.head_commit = CommitFactory(repository=self.repo)
+ self.head_commit_report = CommitReportFactory(
+ commit=self.head_commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ @patch("services.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_comparison(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/base_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.base_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.head_commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ loader = BundleAnalysisReportLoader(
+ storage_service=storage,
+ repo_key=ArchiveService.get_archive_hash(self.head_commit.repository),
+ )
+
+ bac = BundleAnalysisComparison(
+ loader,
+ self.base_commit_report.external_id,
+ self.head_commit_report.external_id,
+ self.repo,
+ )
+
+ assert len(bac.bundles) == 5
+ assert bac.size_delta == 36555
+ assert bac.size_total == 201720
+
+
+class TestBundleReport(TestCase):
+ def test_bundle_comparison(self):
+ class MockSharedBundleReport:
+ def __init__(self, db_path, bundle_name):
+ self.bundle_name = bundle_name
+
+ def total_size(self):
+ return 7654321
+
+ @property
+ def name(self):
+ return self.bundle_name
+
+ bundle_comparison = BundleReport(MockSharedBundleReport("123abc", "bundle1"))
+
+ assert bundle_comparison.name == "bundle1"
+ assert bundle_comparison.size_total == 7654321
+
+
+class TestBundleAnalysisReport(TestCase):
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ self.commit = CommitFactory(repository=self.repo)
+ self.commit_report = CommitReportFactory(
+ commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
+ )
+
+ @patch("services.bundle_analysis.get_appropriate_storage_service")
+ def test_bundle_analysis_report(self, get_storage_service):
+ storage = MemoryStorageService({})
+ get_storage_service.return_value = storage
+
+ with open("./services/tests/samples/head_bundle_report.sqlite", "rb") as f:
+ storage_path = StoragePaths.bundle_report.path(
+ repo_key=ArchiveService.get_archive_hash(self.repo),
+ report_key=self.commit_report.external_id,
+ )
+ storage.write_file(get_bucket_name(), storage_path, f)
+
+ loader = BundleAnalysisReportLoader(
+ storage_service=storage,
+ repo_key=ArchiveService.get_archive_hash(self.commit.repository),
+ )
+
+ bar = BundleAnalysisReport(loader.load(self.commit_report.external_id))
+
+ assert len(bar.bundles) == 4
+ assert bar.size_total == 201720
diff --git a/apps/codecov-api/services/tests/test_comparison.py b/apps/codecov-api/services/tests/test_comparison.py
new file mode 100644
index 0000000000..de037ab55e
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_comparison.py
@@ -0,0 +1,1854 @@
+import asyncio
+import enum
+import json
+from collections import Counter
+from datetime import datetime
+from unittest.mock import PropertyMock, patch
+
+import minio
+import pytest
+import pytz
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.reports.api_report_service import SerializableReport
+from shared.reports.resources import ReportFile
+from shared.reports.types import ReportTotals
+from shared.utils.merge import LineType
+
+from compare.models import CommitComparison
+from compare.tests.factories import CommitComparisonFactory
+from core.models import Commit
+from reports.tests.factories import CommitReportFactory
+from services.comparison import (
+ CommitComparisonService,
+ Comparison,
+ ComparisonReport,
+ CreateChangeSummaryVisitor,
+ CreateLineComparisonVisitor,
+ FileComparison,
+ FileComparisonTraverseManager,
+ ImpactedFile,
+ LineComparison,
+ MissingComparisonReport,
+ PullRequestComparison,
+ Segment,
+)
+
+# Pulled from shared.django_apps.core.tests.factories.CommitFactory files.
+# Contents don't actually matter, it's just for providing a format
+# compatible with what SerializableReport expects. Used in
+# ComparisonTests.
+file_data = [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+]
+
+
+class MockOrderValue(object):
+ def __init__(self, value):
+ self.value = value
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class OrderingDirection(enum.Enum):
+ ASC = "ascending"
+ DESC = "descending"
+
+
+class LineNumberCollector:
+ """
+ A visitor for testing line traversal.
+ """
+
+ def __init__(self):
+ self.line_numbers = []
+
+ def __call__(self, base_ln, head_ln, value, is_diff):
+ self.line_numbers.append((base_ln, head_ln))
+
+
+class FileComparisonTraverseManagerTests(TestCase):
+ def test_no_diff_results_in_no_line_number_adjustments(self):
+ manager = FileComparisonTraverseManager(head_file_eof=3, base_file_eof=3)
+
+ expected_result = [(1, 1), (2, 2)]
+
+ visitor = LineNumberCollector()
+ manager.apply(visitors=[visitor])
+
+ assert visitor.line_numbers == expected_result
+
+ def test_diff_with_added_lines_adjusts_lines(self):
+ # A line added at line 1 -- note header is string values, that's how
+ # torngit returns it
+ segments = [{"header": ["1", "1", "1", "2"], "lines": ["+"]}]
+
+ manager = FileComparisonTraverseManager(
+ head_file_eof=4, base_file_eof=3, segments=segments
+ )
+
+ expected_result = [(None, 1), (1, 2), (2, 3)]
+
+ visitor = LineNumberCollector()
+ manager.apply(visitors=[visitor])
+
+ assert visitor.line_numbers == expected_result
+
+ def test_diff_with_removed_lines_adjusts_lines(self):
+ # A line removed at line 1
+ segments = [{"header": ["1", "1", "1", "2"], "lines": ["-"]}]
+ manager = FileComparisonTraverseManager(
+ head_file_eof=3, base_file_eof=4, segments=segments
+ )
+
+ expected_result = [(1, None), (2, 1), (3, 2)]
+
+ visitor = LineNumberCollector()
+ manager.apply(visitors=[visitor])
+
+ assert visitor.line_numbers == expected_result
+
+ def test_diff_with_1_line_added_file_adjusts_lines(self):
+ segments = [{"header": ["0", "0", "1", ""], "lines": ["+"]}]
+ manager = FileComparisonTraverseManager(
+ head_file_eof=2, base_file_eof=0, segments=segments
+ )
+
+ expected_result = [(None, 1)]
+
+ visitor = LineNumberCollector()
+ manager.apply(visitors=[visitor])
+
+ assert visitor.line_numbers == expected_result
+
+ def test_diff_with_1_line_removed_file_adjusts_lines(self):
+ segments = [{"header": ["1", "1", "0", ""], "lines": ["-"]}]
+ manager = FileComparisonTraverseManager(
+ head_file_eof=0, base_file_eof=2, segments=segments
+ )
+
+ expected_result = [(1, None)]
+
+ visitor = LineNumberCollector()
+ manager.apply(visitors=[visitor])
+
+ assert visitor.line_numbers == expected_result
+
+ def test_pop_line_returns_none_if_no_diff_or_src(self):
+ manager = FileComparisonTraverseManager()
+ assert manager.pop_line() is None
+
+ def test_pop_line_pops_first_line_in_segment_if_traversing_that_segment(self):
+ expected_line_value = "+this is a line!"
+ segments = [
+ {
+ "header": [1, 2, 1, 3],
+ "lines": [expected_line_value, "this is another line"],
+ }
+ ]
+ manager = FileComparisonTraverseManager(segments=segments)
+ assert manager.pop_line() == expected_line_value
+
+ def test_pop_line_returns_line_at_head_ln_index_in_src_if_not_in_segment(self):
+ expected_line_value = "a line from src!"
+ manager = FileComparisonTraverseManager(
+ head_file_eof=2, src=[expected_line_value]
+ )
+ assert manager.pop_line() == expected_line_value
+
+ def test_traversing_diff_returns_true_if_head_ln_within_segment_at_position_0(self):
+ manager = FileComparisonTraverseManager(
+ segments=[{"header": ["1", "6", "12", "4"]}]
+ )
+ manager.head_ln = 14
+ manager.base_ln = 1000
+ assert manager.traversing_diff() is True
+
+ manager.head_ln = 11
+ assert manager.traversing_diff() is False
+
+ def test_traversing_diff_handles_added_one_line_file_segment_header(self):
+ segment = {"header": ["0", "0", "1", ""], "lines": ["+"]}
+ manager = FileComparisonTraverseManager(segments=[segment])
+
+ assert manager.traversing_diff() is True
+
+ def test_traversing_diff_handles_removed_one_line_file_segment_header(self):
+ segment = {"header": ["1", "1", "0", ""], "lines": ["-"]}
+ manager = FileComparisonTraverseManager(segments=[segment])
+
+ assert manager.traversing_diff() is True
+
+ def test_traversing_diff_returns_true_if_base_ln_within_segment_at_position_0(self):
+ manager = FileComparisonTraverseManager(
+ segments=[{"header": ["4", "43", "4", "3"]}]
+ )
+ manager.head_ln = 7
+ manager.base_ln = 44
+ assert manager.traversing_diff() is True
+
+ def test_traverse_finished_returns_false_even_both_line_counters_at_eof_and_traversing_diff(
+ self,
+ ):
+ # This accounts for an edge case wherein you remove a multi-line expression
+ # (which codecov counts as a single line, coverage-wise) at the
+ # end of a file. The git-diff counts these as multiple lines, so
+ # in order to not truncate the diff we need to continue popping
+ # lines off the segment even of line counters are technically both
+ # at EOF.
+ manager = FileComparisonTraverseManager(
+ head_file_eof=7,
+ base_file_eof=43,
+ segments=[{"header": ["4", "43", "4", "3"]}],
+ )
+
+ manager.base_ln = 45 # higher than eof, but still traversing_diff
+ manager.head_ln = 7 # highest it can go in this segment
+
+ assert manager.traverse_finished() is False
+
+ def test_no_indexerror_if_basefile_longer_than_headfile_and_src_provided(self):
+ manager = FileComparisonTraverseManager(
+ head_file_eof=3,
+ base_file_eof=4,
+ src=["hey"] * 2, # head file eof minus 1, which is the typical case
+ )
+
+ # No indexerror should occur
+ manager.apply([lambda a, b, c, d: None])
+
+ def test_can_traverse_diff_with_line_numbers_greater_than_file_eof(self):
+ # This can happen when we have a file ending in a large multi-line
+ # expression, and a diff is made somewhere within that expression,
+ # but the start of the diff occurs after the start of the expression.
+ # The previous implementation of "traverse_finished" would end the traverse
+ # on account of not "traversing_diff", and having the line indices be
+ # greater than the respective files' EOF. The fix for this bug is stronger
+ # than that of the above comment and should handle both cases.
+ segments = [
+ {
+ "header": ["3", "4", "3", "4"],
+ "lines": ["-Pro Team (billed monthly)", "+Pro Team"],
+ }
+ ]
+ manager = FileComparisonTraverseManager(
+ head_file_eof=2, base_file_eof=2, segments=segments
+ )
+
+ visitor = LineNumberCollector()
+ manager.apply([visitor])
+ assert visitor.line_numbers == [(1, 1), (2, 2), (3, None), (None, 3)]
+
+ def test_can_traverse_diff_with_diff_like_lines(self):
+ src = [
+ "- line 1", # not part of diff
+ "- line 2", # not part of diff
+ "line 3",
+ ]
+
+ # this is the diff
+ segments = [
+ {
+ "header": ["3", "4", "3", "4"],
+ "lines": ["-line 3", "+line 3"],
+ }
+ ]
+ manager = FileComparisonTraverseManager(
+ head_file_eof=3, base_file_eof=3, segments=segments, src=src
+ )
+
+ visitor = LineNumberCollector()
+ manager.apply([visitor])
+ assert visitor.line_numbers == [(1, 1), (2, 2), (3, None), (None, 3)]
+
+
+class CreateLineComparisonVisitorTests(TestCase):
+ def setUp(self):
+ self.head_file = ReportFile(
+ "file1", lines=[[0, "", [], 0, 0], None, [0, "", [], 0, 0]]
+ )
+ self.base_file = ReportFile(
+ "file1", lines=[None, [0, "", [], 0, 0], None, [0, "", [], 0, 0]]
+ )
+
+ def test_skips_if_line_value_is_none(self):
+ visitor = CreateLineComparisonVisitor(self.base_file, self.head_file)
+ visitor(0, 0, None, False)
+ assert visitor.lines == []
+
+ def test_appends_line_comparison_with_relevant_fields_if_line_value_not_none(self):
+ base_ln = 2
+ head_ln = 1
+ base_line = self.base_file._lines[base_ln - 1]
+ head_line = self.head_file._lines[head_ln - 1]
+ value = "sup dood"
+ is_diff = True
+
+ visitor = CreateLineComparisonVisitor(self.base_file, self.head_file)
+ visitor(base_ln, head_ln, value, is_diff)
+
+ line = visitor.lines[0]
+ assert line.head_ln == head_ln
+ assert line.base_ln == base_ln
+ assert line.head_line == head_line
+ assert line.base_line == base_line
+ assert line.value == value
+ assert line.is_diff == is_diff
+
+ def test_appends_line_comparison_with_no_base_line_if_no_base_file_or_line_not_in_base_file(
+ self,
+ ):
+ visitor = CreateLineComparisonVisitor(self.base_file, self.head_file)
+ visitor(100, 1, "", False) # 100 is not a line in the base file
+ assert visitor.lines[0].base_line is None
+
+ visitor.base_file = None
+ visitor(
+ 2, 1, "", False
+ ) # all valid line numbers, but still expect none without base_file
+ assert visitor.lines[1].base_line is None
+
+ def test_appends_line_comparison_with_no_head_line_if_no_head_file_or_line_not_in_head_file(
+ self,
+ ):
+ visitor = CreateLineComparisonVisitor(self.base_file, self.head_file)
+ visitor(2, 100, "", False)
+ assert visitor.lines[0].head_line is None
+
+ visitor.head_file = None
+ visitor(1, 2, "", False)
+ assert visitor.lines[1].head_line is None
+
+
+class CreateChangeSummaryVisitorTests(TestCase):
+ def setUp(self):
+ self.head_file = ReportFile("file1", lines=[[1, "", [], 0, 0]])
+ self.base_file = ReportFile("file1", lines=[[0, "", [], 0, 0]])
+
+ def test_changed_lines_in_diff_do_not_affect_change_summary(self):
+ visitor = CreateChangeSummaryVisitor(self.base_file, self.head_file)
+ visitor(1, 1, "+", False)
+ assert visitor.summary == {}
+
+ visitor(1, 1, "-", False)
+ assert visitor.summary == {}
+
+ def test_summary_with_one_less_miss_and_one_more_hit(self):
+ visitor = CreateChangeSummaryVisitor(self.base_file, self.head_file)
+ visitor(1, 1, "", True)
+ assert visitor.summary == {"misses": -1, "hits": 1}
+
+ def test_summary_with_one_less_hit_and_one_more_partial(self):
+ self.base_file._lines[0][0] = 1
+ self.head_file._lines[0][0] = "1/2"
+ visitor = CreateChangeSummaryVisitor(self.base_file, self.head_file)
+ visitor(1, 1, "", True)
+ assert visitor.summary == {"hits": -1, "partials": 1}
+
+
+class LineComparisonTests(TestCase):
+ def test_number_shows_number_from_base_and_head(self):
+ base_ln = 3
+ head_ln = 4
+ lc = LineComparison(
+ [0, "", [], 0, 0], [0, "", [], 0, 0], base_ln, head_ln, "", False
+ )
+ assert lc.number == {"base": base_ln, "head": head_ln}
+
+ def test_number_shows_none_for_base_if_added(self):
+ head_ln = 4
+ lc = LineComparison(None, [0, "", [], 0, 0], 0, head_ln, "+", True)
+ assert lc.number == {"base": None, "head": head_ln}
+
+ def test_number_shows_none_for_base_if_plus_not_part_of_diff(self):
+ base_ln = 3
+ head_ln = 4
+ lc = LineComparison(None, [0, "", [], 0, 0], base_ln, head_ln, "+", False)
+ assert lc.number == {"base": base_ln, "head": head_ln}
+
+ def test_number_shows_none_for_base_if_minus_not_part_of_diff(self):
+ base_ln = 3
+ head_ln = 4
+ lc = LineComparison(None, [0, "", [], 0, 0], base_ln, head_ln, "-", False)
+ assert lc.number == {"base": base_ln, "head": head_ln}
+
+ def test_number_shows_none_for_head_if_removed(self):
+ base_ln = 3
+ lc = LineComparison([0, "", [], 0, 0], None, base_ln, 0, "-", True)
+ assert lc.number == {"base": base_ln, "head": None}
+
+ def test_coverage_shows_coverage_for_base_and_head(self):
+ base_cov, head_cov = 0, 1
+ lc = LineComparison(
+ [base_cov, "", [], 0, 0], [head_cov, "", [], 0, 0], 0, 0, "", False
+ )
+ assert lc.coverage == {"base": LineType.miss, "head": LineType.hit}
+
+ def test_coverage_shows_none_for_base_if_added(self):
+ head_cov = 1
+ lc = LineComparison(None, [head_cov, "", [], 0, 0], 0, 0, "+", False)
+ assert lc.coverage == {"base": None, "head": LineType.hit}
+
+ def test_coverage_shows_none_for_head_if_removed(self):
+ base_cov = 0
+ lc = LineComparison([base_cov, "", [], 0, 0], None, 0, 0, "-", False)
+ assert lc.coverage == {"base": LineType.miss, "head": None}
+
+ def test_hit_count_returns_sessions_hit_in_head(self):
+ lc = LineComparison(
+ None,
+ [
+ 1,
+ "",
+ [
+ [0, 1, 0, 0, 0],
+ [1, 2, 0, 0, 0],
+ [2, 0, 0, 0, 0],
+ [3, "2/2", 0, 0, 0],
+ ],
+ 0,
+ 0,
+ ],
+ 0,
+ 0,
+ "",
+ False,
+ )
+
+ assert lc.hit_count == 3
+
+ def test_hit_count_returns_none_if_no_coverage(self):
+ lc = LineComparison(None, [0, "", [[0, 0, 0, 0, 0]], 0, 0], 0, 0, "", False)
+ assert lc.hit_count is None
+
+ def test_hit_session_ids(self):
+ lc = LineComparison(
+ None,
+ [
+ 1,
+ "",
+ [
+ [0, 1, 0, 0, 0],
+ [1, 2, 0, 0, 0],
+ [2, 0, 0, 0, 0],
+ [3, "2/2", 0, 0, 0],
+ ],
+ 0,
+ 0,
+ ],
+ 0,
+ 0,
+ "",
+ False,
+ )
+
+ assert lc.hit_session_ids == [0, 1, 3]
+
+ def test_hit_session_ids_no_coverage(self):
+ lc = LineComparison(None, [0, "", [[0, 0, 0, 0, 0]], 0, 0], 0, 0, "", False)
+ assert lc.hit_session_ids is None
+
+ def test_hit_session_ids_no_head_line(self):
+ lc = LineComparison(None, None, 0, 0, "", False)
+ assert lc.hit_session_ids is None
+
+
+class FileComparisonConstructorTests(TestCase):
+ def test_constructor_no_keyError_if_diff_data_segements_is_missing(self):
+ FileComparison(
+ head_file=ReportFile("file1"), base_file=ReportFile("file1"), diff_data={}
+ )
+
+
+class FileComparisonTests(TestCase):
+ def setUp(self):
+ self.file_comparison = FileComparison(
+ head_file=ReportFile("file1"), base_file=ReportFile("file1")
+ )
+
+ def test_name_shows_name_for_base_and_head(self):
+ assert self.file_comparison.name == {
+ "base": self.file_comparison.base_file.name,
+ "head": self.file_comparison.head_file.name,
+ }
+
+ def test_name_none_if_base_or_head_if_files_none(self):
+ self.file_comparison.head_file = None
+ assert self.file_comparison.name == {
+ "base": self.file_comparison.base_file.name,
+ "head": None,
+ }
+
+ self.file_comparison.base_file = None
+ assert self.file_comparison.name == {"base": None, "head": None}
+
+ def test_totals_shows_totals_for_base_and_head(self):
+ assert self.file_comparison.totals == {
+ "base": self.file_comparison.base_file.totals,
+ "head": self.file_comparison.head_file.totals,
+ "diff": None,
+ }
+
+ def test_totals_shows_totals_for_base_head_and_diff(self):
+ diff_totals = ReportTotals.default_totals()
+ self.file_comparison.diff_data = {
+ "totals": diff_totals,
+ }
+
+ assert self.file_comparison.totals == {
+ "base": self.file_comparison.base_file.totals,
+ "head": self.file_comparison.head_file.totals,
+ "diff": diff_totals,
+ }
+
+ def test_totals_base_is_none_if_missing_basefile(self):
+ self.file_comparison.base_file = None
+ assert self.file_comparison.totals == {
+ "base": None,
+ "head": self.file_comparison.head_file.totals,
+ "diff": None,
+ }
+
+ def test_totals_head_is_none_if_missing_headfile(self):
+ self.file_comparison.head_file = None
+ assert self.file_comparison.totals == {
+ "base": self.file_comparison.base_file.totals,
+ "head": None,
+ "diff": None,
+ }
+
+ def test_totals_includes_diff_totals_if_diff(self):
+ totals = "these are the totals"
+ self.file_comparison.diff_data = {"totals": totals}
+ assert self.file_comparison.totals["head"].diff == totals
+
+ def test_has_diff_returns_true_iff_diff_data_not_none(self):
+ assert self.file_comparison.has_diff is False
+
+ self.file_comparison.diff_data = {}
+ assert self.file_comparison.has_diff is True
+
+ def test_stats_returns_none_if_no_diff_data(self):
+ assert self.file_comparison.has_diff is False
+ assert self.file_comparison.stats is None
+
+ def test_stats_returns_diff_stats_if_diff_data(self):
+ expected_stats = "yep"
+ self.file_comparison.diff_data = {"stats": expected_stats}
+ assert self.file_comparison.stats == expected_stats
+
+ def test_lines_returns_empty_list_if_no_diff_or_src(self):
+ assert self.file_comparison.lines == []
+
+ # essentially a smoke/integration test
+ def test_lines(self):
+ head_lines = [
+ [1, "", [], 0, None],
+ ["1/2", "", [], 0, None],
+ [1, "", [], 0, None],
+ ]
+ base_lines = [[0, "", [], 0, None], [1, "", [], 0, None], [0, "", [], 0, None]]
+
+ first_line_val = "unchanged line from src"
+ second_line_val = "+this is an added line"
+ third_line_val = "-this is a removed line"
+ last_line_val = "this is the third line"
+
+ segment = {
+ "header": ["2", "2", "2", "2"],
+ "lines": [second_line_val, third_line_val],
+ }
+ src = [first_line_val, "this is an added line", last_line_val]
+
+ self.file_comparison.head_file._parsed_lines = head_lines
+ self.file_comparison.base_file._parsed_lines = base_lines
+ self.file_comparison.diff_data = {"segments": [segment]}
+ self.file_comparison.src = src
+
+ assert self.file_comparison.lines[0].value == first_line_val
+ assert self.file_comparison.lines[0].number == {"base": 1, "head": 1}
+ assert self.file_comparison.lines[0].coverage == {
+ "base": LineType.miss,
+ "head": LineType.hit,
+ }
+
+ assert self.file_comparison.lines[1].value == second_line_val
+ assert self.file_comparison.lines[1].number == {"base": None, "head": 2}
+ assert self.file_comparison.lines[1].coverage == {
+ "base": None,
+ "head": LineType.partial,
+ }
+
+ assert self.file_comparison.lines[2].value == third_line_val
+ assert self.file_comparison.lines[2].number == {"base": 2, "head": None}
+ assert self.file_comparison.lines[2].coverage == {
+ "base": LineType.hit,
+ "head": None,
+ }
+
+ assert self.file_comparison.lines[3].value == last_line_val
+ assert self.file_comparison.lines[3].number == {"base": 3, "head": 3}
+ assert self.file_comparison.lines[3].coverage == {
+ "base": LineType.miss,
+ "head": LineType.hit,
+ }
+
+ @patch("services.comparison.FileComparison.lines", new_callable=PropertyMock)
+ def test_segments_diff_only(self, lines):
+ lines.return_value = [
+ LineComparison([1], [1], 1, 1, "first line", False),
+ LineComparison(None, [1], None, 2, "+this is an added line", True),
+ LineComparison([1], None, 2, None, "-this is a removed line", True),
+ LineComparison([1], [1], 3, 3, "last line", False),
+ ]
+
+ segments = self.file_comparison.segments
+
+ assert len(segments) == 1
+ assert segments[0].lines == self.file_comparison.lines
+ assert segments[0].header == (1, 3, 1, 3)
+ assert segments[0].has_diff_changes == True
+ assert segments[0].has_unintended_changes == False
+
+ @patch("services.comparison.FileComparison.lines", new_callable=PropertyMock)
+ def test_segments_changes_only(self, lines):
+ lines.return_value = [
+ LineComparison([1], [1], 1, 1, "first line", False),
+ LineComparison([0], [1], 2, 2, "middle line", False), # coverage added
+ LineComparison([1], [1], 3, 3, "last line", False),
+ ]
+
+ segments = self.file_comparison.segments
+
+ assert len(segments) == 1
+ assert segments[0].lines == self.file_comparison.lines
+ assert segments[0].header == (1, 3, 1, 3)
+ assert segments[0].has_diff_changes == False
+ assert segments[0].has_unintended_changes
+
+ @patch("services.comparison.FileComparison.lines", new_callable=PropertyMock)
+ def test_segments_no_changes_no_diff(self, lines):
+ lines.return_value = [
+ LineComparison([1], [1], 1, 1, "first line", False),
+ LineComparison([1], [1], 2, 2, "middle line", False),
+ LineComparison([1], [1], 3, 3, "last line", False),
+ ]
+
+ segments = self.file_comparison.segments
+ assert len(segments) == 0
+
+ def test_change_summary(self):
+ head_lines = [
+ [1, "", [], 0, None],
+ ["3/4", "", [], 0, None],
+ [1, "", [], 0, None],
+ ]
+ base_lines = [[0, "", [], 0, None], [1, "", [], 0, None], [0, "", [], 0, None]]
+
+ first_line_val = "unchanged line from src"
+ second_line_val = "+this is an added line"
+ third_line_val = "-this is a removed line"
+ last_line_val = "this is the third line"
+
+ segment = {
+ "header": ["2", "2", "2", "2"],
+ "lines": [second_line_val, third_line_val],
+ }
+ src = [first_line_val, "this is an added line", last_line_val]
+
+ self.file_comparison.head_file._parsed_lines = head_lines
+ self.file_comparison.base_file._parsed_lines = base_lines
+ self.file_comparison.diff_data = {"segments": [segment]}
+ self.file_comparison.src = src
+
+ assert self.file_comparison.change_summary == {"hits": 2, "misses": -2}
+
+ @patch(
+ "services.comparison.FileComparison.change_summary", new_callable=PropertyMock
+ )
+ def test_has_changes(self, change_summary_mock):
+ change_summary_mock.return_value = Counter()
+ assert self.file_comparison.has_changes == False
+
+ change_summary_mock.return_value = Counter({"hits": 0, "misses": 0})
+ assert self.file_comparison.has_changes == False
+
+ change_summary_mock.return_value = Counter({"hits": 1, "misses": -1})
+ assert self.file_comparison.has_changes == True
+
+ @patch("services.comparison.FileComparisonTraverseManager.apply")
+ def test_does_not_calculate_changes_if_no_diff_and_should_search_for_changes_is_False(
+ self, mocked_apply_traverse
+ ):
+ self.file_comparison.should_search_for_changes = False
+ self.file_comparison._calculated_changes_and_lines
+ mocked_apply_traverse.assert_not_called()
+
+ @patch("services.comparison.FileComparisonTraverseManager.apply")
+ def test_calculates_changes_if_no_diff_and_should_search_for_changes_is_None(
+ self, mocked_apply_traverse
+ ):
+ self.file_comparison.should_search_for_changes = None
+ self.file_comparison._calculated_changes_and_lines
+ mocked_apply_traverse.assert_called_once()
+
+ @patch("services.comparison.FileComparisonTraverseManager.apply")
+ def test_calculates_changes_should_search_for_changes_is_True(
+ self, mocked_apply_traverse
+ ):
+ self.file_comparison.should_search_for_changes = True
+ self.file_comparison._calculated_changes_and_lines
+ mocked_apply_traverse.assert_called_once()
+
+ @patch("services.comparison.FileComparisonTraverseManager.apply")
+ def test_calculates_changes_if_traversing_src(self, mocked_apply_traverse):
+ self.file_comparison.should_search_for_changes = False
+ self.file_comparison.src = ["a truthy list"]
+ self.file_comparison._calculated_changes_and_lines
+ mocked_apply_traverse.assert_called_once()
+
+
+@patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+@patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+@patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+class ComparisonTests(TestCase):
+ def setUp(self):
+ owner = OwnerFactory()
+ base, head = CommitFactory(author=owner), CommitFactory(author=owner)
+ self.comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+
+ def test_files_gets_file_comparison_for_each_file_in_head_report(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ head_report_files = {"file1": file_data, "file2": file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+ base_report_mock.return_value = SerializableReport(files={})
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ assert sum(1 for x in self.comparison.files) == 2
+ for fc in self.comparison.files:
+ assert isinstance(fc, FileComparison)
+ assert fc.head_file.name in head_report_files
+ assert fc.base_file is None
+
+ def test_get_file_comparison_adds_in_file_from_base_report_if_exists(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ files = {"both.py": file_data}
+ base_report_mock.return_value = SerializableReport(files=files)
+ head_report_mock.return_value = SerializableReport(files=files)
+
+ fc = self.comparison.get_file_comparison("both.py")
+ assert fc.head_file.name == "both.py"
+ assert fc.base_file.name == "both.py"
+
+ def test_get_file_comparison_accounts_for_renamed_files(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ file_name = "myfile.py"
+ previous_name = "previous.py"
+
+ base_report_files = {previous_name: file_data}
+ base_report_mock.return_value = SerializableReport(files=base_report_files)
+
+ head_report_files = {file_name: file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+
+ git_comparison_mock.return_value = {
+ "diff": {"files": {file_name: {"before": previous_name, "segments": []}}},
+ "commits": [],
+ }
+
+ fc = self.comparison.get_file_comparison(file_name)
+ assert fc.head_file.name == file_name
+ assert fc.base_file.name == previous_name
+
+ def test_get_file_comparison_includes_diff_data_if_exists(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ file_name = "f"
+ diff_data = {
+ "segments": [{"header": ["4", "6", "7", "3"], "lines": []}],
+ "stats": {"added": 3, "removed": 2},
+ }
+
+ base_report_mock.return_value = SerializableReport(files={})
+
+ head_report_files = {file_name: file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+
+ git_comparison_mock.return_value = {"diff": {"files": {file_name: diff_data}}}
+
+ fc = self.comparison.get_file_comparison(file_name)
+ assert fc.diff_data == diff_data
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_get_file_comparison_includes_src_if_with_src_is_true(
+ self,
+ mocked_comparison_adapter,
+ base_report_mock,
+ head_report_mock,
+ git_comparison_mock,
+ ):
+ from api.internal.tests.views.test_compare_viewset import (
+ MockedComparisonAdapter,
+ )
+
+ src = b"two\nlines"
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ mocked_comparison_adapter.return_value = MockedComparisonAdapter(
+ {"diff": {"files": {}}}, test_lines=src
+ )
+
+ file_name = "f"
+
+ base_report_mock.return_value = SerializableReport(files={})
+ head_report_files = {file_name: file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+
+ fc = self.comparison.get_file_comparison(file_name, with_src=True)
+ assert fc.src == ["two", "lines"]
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_get_file_comparison_can_parse_string_src(
+ self,
+ mocked_comparison_adapter,
+ base_report_mock,
+ head_report_mock,
+ git_comparison_mock,
+ ):
+ from api.internal.tests.views.test_compare_viewset import (
+ MockedComparisonAdapter,
+ )
+
+ src = "two\nlines"
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ mocked_comparison_adapter.return_value = MockedComparisonAdapter(
+ {"diff": {"files": {}}}, test_lines=src
+ )
+
+ file_name = "f"
+
+ base_report_mock.return_value = SerializableReport(files={})
+ head_report_files = {file_name: file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+
+ fc = self.comparison.get_file_comparison(file_name, with_src=True)
+ assert fc.src == ["two", "lines"]
+
+ def test_get_file_comparison_with_no_base_report_doesnt_crash(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ files = {"both.py": file_data}
+ base_report_mock.return_value = None
+ head_report_mock.return_value = SerializableReport(files=files)
+
+ fc = self.comparison.get_file_comparison("both.py")
+ assert fc.head_file.name == "both.py"
+
+ @pytest.mark.xfail # TODO(pierce): investigate this feature
+ def test_files_adds_deleted_files_that_were_tracked_in_base_report(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ deleted_file_name = "deleted.py"
+ base_report_files = {deleted_file_name: file_data}
+ base_report_mock.return_value = SerializableReport(files=base_report_files)
+
+ head_report_files = {}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+
+ git_comparison_mock.return_value = {
+ "diff": {"files": {deleted_file_name: {"type": "deleted"}}},
+ "commits": [],
+ }
+
+ assert self.comparison.files[0].base_file.name == deleted_file_name
+ assert self.comparison.files[0].head_file is None
+ assert self.comparison.files[0].diff_data == {"type": "deleted"}
+
+ def test_totals_returns_head_totals_if_exists(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ base_report_mock.return_value = None
+ head_report_mock.return_value = SerializableReport()
+
+ assert self.comparison.totals["head"] == head_report_mock.return_value.totals
+ assert self.comparison.totals["base"] is None
+
+ def test_totals_returns_base_totals_if_exists(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ head_report_mock.return_value = None
+ base_report_mock.return_value = SerializableReport()
+
+ assert self.comparison.totals["base"] == base_report_mock.return_value.totals
+ assert self.comparison.totals["head"] is None
+
+ def test_totals_returns_diff_totals_if_exists(
+ self, base_report_mock, head_report_mock, git_comparison_mock
+ ):
+ head_report = SerializableReport()
+ head_report_mock.return_value = head_report
+ base_report_mock.return_value = None
+
+ diff_totals = ReportTotals.default_totals()
+ git_comparison_mock.return_value = {"diff": {"totals": diff_totals}}
+
+ assert self.comparison.totals["base"] is None
+ assert self.comparison.totals["head"] == head_report.totals
+ assert self.comparison.totals["diff"] is diff_totals
+
+ def test_head_and_base_reports_have_cff_sessions(
+ self, base_report_mock, head_report_mock, _
+ ):
+ # Only relevant files keys to the session object
+ head_report_sessions = {"0": {"st": "carriedforward"}}
+ head_report = SerializableReport(sessions=head_report_sessions)
+ head_report_mock.return_value = head_report
+ base_report_sessions = {"0": {"st": "carriedforward"}}
+ base_report = SerializableReport(sessions=base_report_sessions)
+ base_report_mock.return_value = base_report
+
+ fc = self.comparison.has_different_number_of_head_and_base_sessions
+ assert fc == False
+
+ @patch(
+ "services.comparison.Comparison.head_report_without_applied_diff",
+ new_callable=PropertyMock,
+ )
+ def test_head_and_base_reports_have_different_number_of_reports(
+ self, head_report_no_diff_mock, base_report_mock, head_report_mock, _
+ ):
+ # Only relevant files keys to the session object
+ head_report_sessions = {"0": {"st": "uploaded"}, "1": {"st": "uploaded"}}
+ head_report = SerializableReport(sessions=head_report_sessions)
+ head_report_no_diff_mock.return_value = head_report
+ base_report_sessions = {"0": {"st": "uploaded"}}
+ base_report = SerializableReport(sessions=base_report_sessions)
+ base_report_mock.return_value = base_report
+
+ fc = self.comparison.has_different_number_of_head_and_base_sessions
+ assert fc == True
+
+ def test_head_and_base_reports_have_same_number_of_reports(
+ self, base_report_mock, head_report_mock, _
+ ):
+ # Only relevant files keys to the session object
+ head_report_sessions = {"0": {"st": "uploaded"}}
+ head_report = SerializableReport(sessions=head_report_sessions)
+ head_report_mock.return_value = head_report
+ base_report_sessions = {"0": {"st": "uploaded"}}
+ base_report = SerializableReport(sessions=base_report_sessions)
+ base_report_mock.return_value = base_report
+
+ fc = self.comparison.has_different_number_of_head_and_base_sessions
+ assert fc == False
+
+
+class PullRequestComparisonTests(TestCase):
+ def setUp(self):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ base, head, compared_to = (
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ CommitFactory(repository=repo),
+ )
+
+ self.pull = PullFactory(
+ repository=repo,
+ base=base.commitid,
+ head=head.commitid,
+ compared_to=compared_to.commitid,
+ )
+ self.comparison = PullRequestComparison(user=owner, pull=self.pull)
+
+ def test_files_with_changes_hash_key(self):
+ assert self.comparison._files_with_changes_hash_key == "/".join(
+ (
+ "compare-changed-files",
+ self.pull.repository.author.service,
+ self.pull.repository.author.username,
+ self.pull.repository.name,
+ str(self.pull.pullid),
+ )
+ )
+
+ @patch("redis.Redis.get")
+ def test_files_with_changes_retrieves_from_redis(self, mocked_get):
+ filename = "something.py"
+ mocked_get.return_value = json.dumps([filename])
+ assert self.comparison._files_with_changes == [filename]
+
+ @patch("redis.Redis.get")
+ def test_files_with_changes_returns_none_if_no_files_with_changes(self, mocked_get):
+ mocked_get.return_value = None
+ assert self.comparison._files_with_changes is None
+
+ @patch("redis.Redis.get")
+ def test_files_with_changes_doesnt_crash_if_redis_connection_problem(
+ self, mocked_get
+ ):
+ def raise_oserror(*args, **kwargs):
+ raise OSError
+
+ mocked_get.side_effect = raise_oserror
+ self.comparison._files_with_changes
+
+ @patch("redis.Redis.set")
+ def test_set_files_with_changes_in_cache_stores_in_redis(self, mocked_set):
+ files_with_changes = ["file1", "file2"]
+ self.comparison._set_files_with_changes_in_cache(files_with_changes)
+ mocked_set.assert_called_once_with(
+ self.comparison._files_with_changes_hash_key,
+ json.dumps(files_with_changes),
+ ex=86400, # 1 day in seconds
+ )
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+ @patch("redis.Redis.set")
+ @patch("redis.Redis.get")
+ @patch(
+ "services.comparison.FileComparison.change_summary", new_callable=PropertyMock
+ )
+ def test_files_populates_files_with_changes_in_redis(
+ self,
+ mocked_change_summary,
+ mocked_get,
+ mocked_set,
+ base_report_mock,
+ head_report_mock,
+ git_comparison_mock,
+ ):
+ mocked_get.return_value = None
+ mocked_change_summary.return_value = {"hits": 1, "misses": -1}
+ head_report_files = {"file1": file_data, "file2": file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+ base_report_mock.return_value = SerializableReport(files={})
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ list(self.comparison.files)
+
+ mocked_set.assert_called_once_with(
+ self.comparison._files_with_changes_hash_key,
+ json.dumps(["file1", "file2"]),
+ ex=86400, # 1 day in seconds
+ )
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+ @patch(
+ "services.comparison.PullRequestComparison._files_with_changes",
+ new_callable=PropertyMock,
+ )
+ def test_get_file_comparison_sets_should_search_for_changes_correctly(
+ self,
+ files_with_changes_mock,
+ base_report_mock,
+ head_report_mock,
+ git_comparison_mock,
+ ):
+ head_report_files = {"file1": file_data, "file2": file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+ base_report_mock.return_value = SerializableReport(files={})
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ with self.subTest("it's None when nothing found in cache"):
+ files_with_changes_mock.return_value = None
+ fc = self.comparison.get_file_comparison("file1")
+ assert fc.should_search_for_changes is None
+
+ with self.subTest("it's True when file found in cached list"):
+ files_with_changes_mock.return_value = ["file1"]
+ fc = self.comparison.get_file_comparison("file1")
+ assert fc.should_search_for_changes is True
+
+ with self.subTest("it's False when file not found in list"):
+ files_with_changes_mock.return_value = ["file2"]
+ fc = self.comparison.get_file_comparison("file1")
+ assert fc.should_search_for_changes is False
+
+ @patch("services.comparison.get_config")
+ def test_is_pseudo_comparison(self, get_config_mock):
+ owner = OwnerFactory()
+ repository = RepositoryFactory(author=owner)
+ pull = PullFactory(
+ pullid=44,
+ repository=repository,
+ compared_to=CommitFactory(repository=repository).commitid,
+ head=CommitFactory(repository=repository).commitid,
+ base=CommitFactory(repository=repository).commitid,
+ )
+
+ with self.subTest("returns the result in the repo yaml if exists"):
+ repository.yaml = {"codecov": {"allow_pseudo_compare": True}}
+ repository.save()
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.is_pseudo_comparison is True
+
+ repository.yaml = {"codecov": {"allow_pseudo_compare": False}}
+ repository.save()
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.is_pseudo_comparison is False
+
+ with self.subTest(
+ "returns the result in app settings if repo yaml doesn't exist"
+ ):
+ repository.yaml = None
+ repository.save()
+ get_config_mock.return_value = True
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.is_pseudo_comparison is True
+
+ with self.subTest(
+ "returns the result in app settings if repo yaml doesn't exist"
+ ):
+ repository.yaml = None
+ repository.save()
+ get_config_mock.return_value = False
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.is_pseudo_comparison is False
+
+ with self.subTest("depends on the truthiness of the 'compared_to' commit"):
+ repository.yaml = {"codecov": {"allow_pseudo_compare": True}}
+ repository.save()
+ pull.compared_to = None
+ pull.save()
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.is_pseudo_comparison is False
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_pseudo_diff_returns_diff_between_base_and_compared_to(
+ self, get_adapter_mock
+ ):
+ expected_diff = "expected_diff"
+
+ class PseudoCompareAdapter:
+ async def get_compare(self, base, head):
+ self.base, self.head = base, head
+ return {"diff": expected_diff}
+
+ get_compare_adapter = PseudoCompareAdapter()
+ get_adapter_mock.return_value = get_compare_adapter
+
+ assert self.comparison.pseudo_diff == expected_diff
+ assert (
+ get_compare_adapter.base == self.pull.compared_to
+ and get_compare_adapter.head == self.pull.base
+ )
+
+ @patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ @patch(
+ "services.comparison.PullRequestComparison.pseudo_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("shared.reports.resources.Report.does_diff_adjust_tracked_lines")
+ @patch(
+ "services.comparison.PullRequestComparison.is_pseudo_comparison",
+ new_callable=PropertyMock,
+ )
+ def test_pseudo_diff_adjusts_tracked_lines(
+ self,
+ is_pseudo_comparison_mock,
+ does_diff_adjust_mock,
+ pseudo_diff_mock,
+ git_comparison_mock,
+ base_report_mock,
+ head_report_mock,
+ ):
+ owner = OwnerFactory()
+ repository = RepositoryFactory(author=owner)
+ pull = PullFactory(
+ pullid=44,
+ repository=repository,
+ compared_to=CommitFactory(repository=repository).commitid,
+ head=CommitFactory(repository=repository).commitid,
+ base=CommitFactory(repository=repository).commitid,
+ )
+
+ with self.subTest(
+ "returns True if reports exist and there is a diff that adjusts tracked lines"
+ ):
+ is_pseudo_comparison_mock.return_value = True
+ head_report_files = {"file1": file_data, "file2": file_data}
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+ base_report_mock.return_value = SerializableReport(files={})
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+ pseudo_diff_mock.return_value = {"files": {"file1": {}}}
+ does_diff_adjust_mock.return_value = True
+ comparison = PullRequestComparison(owner, pull)
+ assert comparison.pseudo_diff_adjusts_tracked_lines is True
+
+ with self.subTest("returns False if reports don't exist"):
+ head_report_mock.return_value = None
+ comparison = PullRequestComparison(owner, pull)
+ assert self.comparison.pseudo_diff_adjusts_tracked_lines is False
+
+ head_report_mock.return_value = SerializableReport(files=head_report_files)
+ base_report_mock.return_value = None
+ comparison = PullRequestComparison(owner, pull)
+ assert self.comparison.pseudo_diff_adjusts_tracked_lines is False
+
+ with self.subTest("returns False if compared to is same as base"):
+ self.comparison.pull.compared_to = self.comparison.pull.base
+ self.comparison.pull.save()
+ comparison = PullRequestComparison(owner, pull)
+ assert self.comparison.pseudo_diff_adjusts_tracked_lines is False
+
+ with self.subTest("returns False for non-pseudo comparisons"):
+ is_pseudo_comparison_mock.return_value = True
+ comparison = PullRequestComparison(owner, pull)
+ assert self.comparison.pseudo_diff_adjusts_tracked_lines is False
+
+ @patch(
+ "services.comparison.PullRequestComparison.pseudo_diff",
+ new_callable=PropertyMock,
+ )
+ @patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+ @patch("shared.reports.resources.Report.shift_lines_by_diff")
+ def test_update_base_report_with_pseudo_diff(
+ self, shift_lines_by_diff_mock, base_report_mock, pseudo_diff_mock
+ ):
+ pseudo_diff_mock.return_value = {"files": {}}
+ base_report_files = {"file1": file_data, "file2": file_data}
+ base_report_mock.return_value = SerializableReport(files=base_report_files)
+ self.comparison.update_base_report_with_pseudo_diff()
+ shift_lines_by_diff_mock.assert_called_once_with({"files": {}}, forward=True)
+
+
+@patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+@patch("shared.reports.api_report_service.build_report_from_commit")
+class ComparisonHeadReportTests(TestCase):
+ def setUp(self):
+ owner = OwnerFactory()
+ base, head = CommitFactory(author=owner), CommitFactory(author=owner)
+ self.comparison = Comparison(base, head, owner)
+
+ @patch("shared.reports.api_report_service.SerializableReport.apply_diff")
+ def test_head_report_calls_apply_diff(
+ self, apply_diff_mock, build_report_from_commit_mock, git_comparison_mock
+ ):
+ build_report_from_commit_mock.return_value = SerializableReport(
+ files={"f": file_data}
+ )
+ git_comparison_mock.return_value = {"diff": {"files": {}}}
+
+ # should be called when invoking this property
+ self.comparison.head_report
+
+ apply_diff_mock.assert_called_once_with(
+ git_comparison_mock.return_value["diff"]
+ )
+
+ def test_head_report_and_base_report_translates_nosuchkey_into_missingcomparisonreport(
+ self, build_report_from_commit_mock, git_comparison_mock
+ ):
+ build_report_from_commit_mock.side_effect = minio.error.S3Error(
+ code="NoSuchKey",
+ message=None,
+ resource=None,
+ request_id=None,
+ host_id=None,
+ response=None,
+ )
+ with self.assertRaises(MissingComparisonReport):
+ self.comparison.head_report
+
+ with self.assertRaises(MissingComparisonReport):
+ self.comparison.base_report
+
+
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+class ComparisonHasUnmergedBaseCommitsTests(TestCase):
+ class MockFetchDiffCoro:
+ def __init__(self, commits):
+ self.commits = commits
+
+ async def get_compare(self, base, head):
+ return {"commits": self.commits}
+
+ def setUp(self):
+ owner = OwnerFactory()
+ base, head = CommitFactory(author=owner), CommitFactory(author=owner)
+ self.comparison = Comparison(user=owner, base_commit=base, head_commit=head)
+ asyncio.set_event_loop(asyncio.new_event_loop())
+
+
+class SegmentTests(TestCase):
+ def _report_lines(self, hits):
+ return [
+ # from shared.reports.types.ReportLine
+ # values are [coverage, type, sessions, messages, complexity]
+ [hit, "", [], 0, None]
+ for hit in hits
+ ]
+
+ def _src(self, n):
+ return [f"line{i + 1}" for i in range(n)]
+
+ def setUp(self):
+ self.file_comparison = FileComparison(
+ base_file=ReportFile("file1"), head_file=ReportFile("file1")
+ )
+
+ def test_single_segment(self):
+ self.file_comparison.src = self._src(12)
+ self.file_comparison.head_file._parsed_lines = self._report_lines(
+ [1 for _ in range(12)]
+ )
+ self.file_comparison.base_file._parsed_lines = self._report_lines(
+ [
+ 1,
+ 1, # first line of segment
+ 1,
+ 1,
+ 0, # coverage changed
+ 0, # coverage changed
+ 1,
+ 0, # coverage changed
+ 1,
+ 1,
+ 1, # last line of segment
+ 1,
+ ]
+ )
+
+ segments = self.file_comparison.segments
+ assert len(segments) == 1
+
+ segment_lines = segments[0].lines
+ assert segment_lines[0].value == "line2"
+ assert segment_lines[-1].value == "line11"
+ assert len(segment_lines) == 10
+
+ def test_multiple_segments(self):
+ self.file_comparison.src = self._src(25)
+ self.file_comparison.head_file._parsed_lines = self._report_lines(
+ [1 for _ in range(25)]
+ )
+ self.file_comparison.base_file._parsed_lines = self._report_lines(
+ [
+ 1,
+ 1, # first line of segment 1
+ 1,
+ 1,
+ 0, # coverage changed
+ 0, # coverage changed
+ 1,
+ 0, # coverage changed
+ 1,
+ 1,
+ 1, # last line of segment 1
+ 1,
+ 1,
+ 1,
+ 1,
+ 1, # first line of segment 2
+ 1,
+ 1,
+ 0, # coverage changed
+ 1,
+ 1,
+ 1, # last line of segment 2
+ 1,
+ 1,
+ 1,
+ ]
+ )
+
+ segments = self.file_comparison.segments
+ assert len(segments) == 2
+
+ assert segments[0].lines[0].value == "line2"
+ assert segments[0].lines[-1].value == "line11"
+ assert len(segments[0].lines) == 10
+ assert segments[0].header == (2, 10, 2, 10)
+ assert segments[0].has_unintended_changes
+
+ assert segments[1].lines[0].value == "line16"
+ assert segments[1].lines[-1].value == "line22"
+ assert len(segments[1].lines) == 7
+ assert segments[1].header == (16, 7, 16, 7)
+ assert segments[1].has_unintended_changes
+
+ @patch("services.comparison.FileComparison.lines", new_callable=PropertyMock)
+ def test_header_new_file(self, lines):
+ lines.return_value = [
+ LineComparison(None, [1], None, 1, "+line1", True),
+ LineComparison(None, [1], None, 2, "+line2", True),
+ LineComparison(None, [1], None, 3, "+line3", True),
+ ]
+
+ file_comparison = FileComparison(base_file=None, head_file=ReportFile("file1"))
+
+ segments = file_comparison.segments
+ assert len(segments) == 1
+ assert segments[0].header == (0, 0, 1, 3)
+
+ @patch("services.comparison.FileComparison.lines", new_callable=PropertyMock)
+ def test_header_deleted_file(self, lines):
+ lines.return_value = [
+ LineComparison([1], None, 1, None, "-line1", True),
+ LineComparison([1], None, 2, None, "-line2", True),
+ LineComparison([1], None, 3, None, "-line3", True),
+ ]
+
+ file_comparison = FileComparison(base_file=ReportFile("file1"), head_file=None)
+
+ segments = file_comparison.segments
+ assert len(segments) == 1
+ assert segments[0].header == (1, 3, 0, 0)
+
+
+mock_data_from_archive = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [2,"m"],
+ [3,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": []
+ }]
+}
+"""
+
+mocked_files_with_direct_and_indirect_changes = """
+{
+ "files": [{
+ "head_name": "fileA",
+ "base_name": "fileA",
+ "head_coverage": {
+ "hits": 10,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [2,"m"],
+ [3,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [
+ [9,"h"],
+ [10,"m"],
+ [13,"p"],
+ [14,"h"],
+ [15,"h"],
+ [16,"h"],
+ [17,"h"]
+ ],
+ "unexpected_line_changes": []
+ },
+ {
+ "head_name": "fileC",
+ "base_name": "fileC",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]]
+ },
+ {
+ "head_name": "fileD",
+ "base_name": "fileD",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4
+ },
+ "added_diff_coverage": [],
+ "unexpected_line_changes": []
+ }]
+}
+"""
+
+
+class ComparisonReportTest(TestCase):
+ def setUp(self):
+ self.user = OwnerFactory(username="codecov-user")
+ self.parent_commit = CommitFactory()
+ self.commit = CommitFactory(
+ parent_commit_id=self.parent_commit.commitid,
+ repository=self.parent_commit.repository,
+ )
+ self.comparison = CommitComparisonFactory(
+ base_commit=self.parent_commit,
+ compare_commit=self.commit,
+ report_storage_path="v4/test.json",
+ )
+ self.comparison_without_storage = CommitComparisonFactory()
+ self.comparison_report = ComparisonReport(self.comparison)
+ self.comparison_report_without_storage = ComparisonReport(
+ self.comparison_without_storage
+ )
+
+ def test_empty_impacted_files(self):
+ impacted_files = self.comparison_report_without_storage.impacted_files
+ assert impacted_files == []
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_error_when_failing_to_get_file_from_storage(
+ self, mock_read_file
+ ):
+ mock_read_file.side_effect = Exception()
+ impacted_files = self.comparison_report.impacted_files
+ assert impacted_files == []
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_file(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ impacted_file = self.comparison_report.impacted_file("fileB")
+ assert impacted_file.head_name == "fileB"
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_indirect_changes(self, read_file):
+ read_file.return_value = mock_data_from_archive
+ impacted_files = self.comparison_report.impacted_files_with_unintended_changes
+ assert [file.head_name for file in impacted_files] == ["fileA"]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_impacted_files_filtered_by_direct_changes(self, read_file):
+ read_file.return_value = mocked_files_with_direct_and_indirect_changes
+ impacted_files = self.comparison_report.impacted_files_with_direct_changes
+ assert [file.head_name for file in impacted_files] == [
+ "fileA",
+ "fileB",
+ "fileD",
+ ]
+
+ def test_file_has_diff(self):
+ file = ImpactedFile(
+ **{
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5,
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4,
+ },
+ "added_diff_coverage": [
+ [9, "h"],
+ [10, "m"],
+ [13, "p"],
+ [14, "h"],
+ [15, "h"],
+ [16, "h"],
+ [17, "h"],
+ ],
+ "unexpected_line_changes": [],
+ }
+ )
+ assert file.has_diff is True
+
+ def test_file_has_diff_with_indirect_changes(self):
+ file = ImpactedFile(
+ **{
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5,
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4,
+ },
+ "added_diff_coverage": [
+ [9, "h"],
+ [10, "m"],
+ [13, "p"],
+ [14, "h"],
+ [15, "h"],
+ [16, "h"],
+ [17, "h"],
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]],
+ }
+ )
+ assert file.has_diff is True
+
+ def test_file_has_changes(self):
+ file = ImpactedFile(
+ **{
+ "head_name": "fileB",
+ "base_name": "fileB",
+ "head_coverage": {
+ "hits": 12,
+ "misses": 1,
+ "partials": 1,
+ "branches": 3,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 5,
+ },
+ "base_coverage": {
+ "hits": 5,
+ "misses": 6,
+ "partials": 1,
+ "branches": 2,
+ "sessions": 0,
+ "complexity": 0,
+ "complexity_total": 0,
+ "methods": 4,
+ },
+ "added_diff_coverage": [
+ [9, "h"],
+ [10, "m"],
+ [13, "p"],
+ [14, "h"],
+ [15, "h"],
+ [16, "h"],
+ [17, "h"],
+ ],
+ "unexpected_line_changes": [[[1, "h"], [1, "h"]]],
+ }
+ )
+ assert file.has_changes is True
+
+ def test_remove_unintended_changes(self):
+ lines = [
+ LineComparison([1, "", [], 0, 0], [1, "", [], 0, 0], 1, 1, "line1", False),
+ LineComparison([1, "", [], 0, 0], [0, "", [], 0, 0], 2, 2, "line2", False),
+ LineComparison(None, [0, "", [], 0, 0], None, 3, "+line3", True),
+ LineComparison([0, "", [], 0, 0], None, 4, None, "-line4", True),
+ LineComparison([1, "", [], 0, 0], [0, "", [], 0, 0], 5, 5, "line5", False),
+ ]
+
+ segment = Segment(lines)
+ segment.remove_unintended_changes()
+
+ assert len(segment.lines) == 3
+ assert [line.value for line in segment.lines] == ["line1", "+line3", "-line4"]
+
+
+class CommitComparisonTests(TestCase):
+ def setUp(self):
+ self.base_commit = CommitFactory(updatestamp=datetime(2023, 1, 1))
+ self.compare_commit = CommitFactory(updatestamp=datetime(2023, 1, 1))
+ self.base_commit_report = CommitReportFactory(commit=self.base_commit)
+ self.compare_commit_report = CommitReportFactory(commit=self.compare_commit)
+ self.commit_comparison = CommitComparisonFactory(
+ base_commit=self.base_commit,
+ compare_commit=self.compare_commit,
+ )
+
+ def test_needs_recompute(self):
+ commit_comparison = CommitComparison.objects.get(pk=self.commit_comparison.pk)
+ service = CommitComparisonService(commit_comparison)
+
+ assert service.needs_recompute() == False
+
+ def test_needs_recompute_missing_timestamp(self):
+ Commit.objects.filter(pk=self.base_commit.id).update(updatestamp=None)
+ commit_comparison = CommitComparison.objects.get(pk=self.commit_comparison.pk)
+ service = CommitComparisonService(commit_comparison)
+
+ assert service.needs_recompute() == False
+
+ def test_stale_base_commit(self):
+ # base_commit was updated after this comparison was made
+ self.commit_comparison.updated_at = datetime(2021, 1, 1, tzinfo=pytz.utc)
+ self.commit_comparison.save()
+ self.base_commit.updatestamp = datetime(2023, 1, 2)
+ self.base_commit.save()
+
+ commit_comparison = CommitComparison.objects.get(pk=self.commit_comparison.pk)
+ service = CommitComparisonService(commit_comparison)
+ assert service.needs_recompute() == True
+
+ def test_stale_compare_commit(self):
+ # compare_commit was updated after this comparison was made
+ self.commit_comparison.updated_at = datetime(2021, 1, 1, tzinfo=pytz.utc)
+ self.commit_comparison.save()
+
+ self.compare_commit.updatestamp = datetime(2023, 1, 2)
+ self.compare_commit.save()
+
+ commit_comparison = CommitComparison.objects.get(pk=self.commit_comparison.pk)
+ service = CommitComparisonService(commit_comparison)
+
+ assert service.needs_recompute() == True
diff --git a/apps/codecov-api/services/tests/test_components.py b/apps/codecov-api/services/tests/test_components.py
new file mode 100644
index 0000000000..0daf54caab
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_components.py
@@ -0,0 +1,285 @@
+from unittest.mock import PropertyMock, patch
+
+from django.contrib.auth.models import AnonymousUser
+from django.test import TestCase
+from shared.components import Component
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+from shared.yaml.user_yaml import UserYaml
+
+from services.comparison import Comparison
+from services.components import (
+ ComponentComparison,
+ commit_components,
+ component_filtered_report,
+ filter_components_by_name_or_id,
+)
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+class ComponentServiceTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.commit = CommitFactory(repository=self.repo)
+
+ @patch("services.components.final_commit_yaml")
+ def test_commit_components(self, mock_final_yaml):
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "component_management": {
+ "default_rules": {
+ "paths": [r".*\.py"],
+ "flag_regexes": [r"flag.*"],
+ },
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "rules_from_default"},
+ {
+ "component_id": "I have my flags",
+ "flag_regexes": [r"python-.*"],
+ },
+ {
+ "component_id": "required",
+ "name": "display",
+ "flag_regexes": [],
+ "paths": [r"src/.*"],
+ },
+ ],
+ }
+ }
+ )
+
+ user = AnonymousUser()
+ components = commit_components(self.commit, user)
+ assert components == [
+ Component(
+ component_id="go_files",
+ paths=[r".*\.go"],
+ name="",
+ flag_regexes=[r"flag.*"],
+ statuses=[],
+ ),
+ Component(
+ component_id="rules_from_default",
+ paths=[r".*\.py"],
+ name="",
+ flag_regexes=[r"flag.*"],
+ statuses=[],
+ ),
+ Component(
+ component_id="I have my flags",
+ paths=[r".*\.py"],
+ name="",
+ flag_regexes=[r"python-.*"],
+ statuses=[],
+ ),
+ Component(
+ component_id="required",
+ name="display",
+ paths=[r"src/.*"],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+
+ mock_final_yaml.assert_called_once_with(self.commit, user)
+
+ def test_component_filtered_report(self):
+ report = sample_report()
+
+ component_go = Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ )
+ report_go = component_filtered_report(report, [component_go])
+ assert report_go.files == ["file_1.go"]
+ assert report_go.totals.coverage == report.get("file_1.go").totals.coverage
+
+ component_py = Component.from_dict(
+ {
+ "component_id": "python",
+ "paths": [".*/*.py"],
+ }
+ )
+ report_py = component_filtered_report(report, [component_py])
+ assert report_py.files == ["file_2.py"]
+ assert report_py.totals.coverage == report.get("file_2.py").totals.coverage
+
+
+class ComponentComparisonTest(TestCase):
+ def setUp(self):
+ self.user = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.user)
+ self.base_commit = CommitFactory(repository=self.repo)
+ self.head_commit = CommitFactory(repository=self.repo)
+ self.comparison = Comparison(self.user, self.base_commit, self.head_commit)
+
+ @patch("services.comparison.Comparison.base_report", new_callable=PropertyMock)
+ def test_base_report(self, base_report_mock):
+ report = sample_report()
+ base_report_mock.return_value = report
+
+ component_go = Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ )
+ component_comparison = ComponentComparison(self.comparison, component_go)
+ assert component_comparison.base_report.files == ["file_1.go"]
+ assert (
+ component_comparison.base_report.totals.coverage
+ == report.get("file_1.go").totals.coverage
+ )
+
+ @patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+ def test_head_report(self, head_report_mock):
+ report = sample_report()
+ head_report_mock.return_value = report
+
+ component_go = Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ )
+ component_comparison = ComponentComparison(self.comparison, component_go)
+ assert component_comparison.head_report.files == ["file_1.go"]
+ assert (
+ component_comparison.head_report.totals.coverage
+ == report.get("file_1.go").totals.coverage
+ )
+
+ @patch("services.comparison.Comparison.git_comparison", new_callable=PropertyMock)
+ @patch("services.comparison.Comparison.head_report", new_callable=PropertyMock)
+ def test_patch_totals(self, head_report_mock, git_comparison_mock):
+ report = sample_report()
+ head_report_mock.return_value = report
+
+ git_comparison_mock.return_value = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "segments": [
+ {
+ "header": ["1", "2", "1", "1"],
+ "lines": ["-line", "+line", "+another line"],
+ }
+ ],
+ },
+ "file_2.py": {
+ "type": "modified",
+ "segments": [
+ {
+ "header": ["1", "1", "1", "1"],
+ "lines": ["-line", "+line"],
+ }
+ ],
+ },
+ }
+ }
+ }
+
+ component_go = Component.from_dict(
+ {
+ "component_id": "golang",
+ "paths": [".*/*.go"],
+ }
+ )
+ component_comparison = ComponentComparison(self.comparison, component_go)
+
+ # removed 1 tested line, added 1 tested and 1 untested line
+ assert component_comparison.patch_totals.coverage == "50.00000"
+
+ def test_filter_components_by_name_or_id(self):
+ components = [
+ Component(
+ name="ComponentA",
+ component_id="123",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ name="ComponentB",
+ component_id="456",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ name="ComponentC",
+ component_id="789",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+ terms = ["comPOnentA", "123", "456"]
+
+ filtered = filter_components_by_name_or_id(components, terms)
+ self.assertEqual(len(filtered), 2)
+ self.assertEqual(filtered[0].name, "ComponentA")
+ self.assertEqual(filtered[1].component_id, "456")
+
+ def test_filter_components_by_name_or_id_no_matches(self):
+ components = [
+ Component(
+ name="ComponentA",
+ component_id="123",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ name="ComponentB",
+ component_id="456",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ Component(
+ name="ComponentC",
+ component_id="789",
+ paths=[],
+ flag_regexes=[],
+ statuses=[],
+ ),
+ ]
+ terms = ["nonexistent", "000"]
+
+ filtered = filter_components_by_name_or_id(components, terms)
+ self.assertEqual(len(filtered), 0)
diff --git a/apps/codecov-api/services/tests/test_path.py b/apps/codecov-api/services/tests/test_path.py
new file mode 100644
index 0000000000..9e3e3552cb
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_path.py
@@ -0,0 +1,382 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import CommitFactory, OwnerFactory
+from shared.reports.api_report_service import SerializableReport
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine, ReportTotals
+from shared.torngit.exceptions import TorngitClientGeneralError
+from shared.utils.sessions import Session
+
+from services.path import (
+ Dir,
+ File,
+ PrefixedPath,
+ ReportPaths,
+ dashboard_commit_file_url,
+ provider_path_exists,
+)
+
+# mock data
+
+file_data1 = [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+]
+
+file_data2 = [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+]
+
+file_data3 = [
+ 2,
+ [0, 10, 3, 2, 0, "30.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 3, 2, 0, "30.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+]
+
+totals1 = ReportTotals(
+ files=0,
+ lines=10,
+ hits=8,
+ misses=2,
+ partials=0,
+ coverage="80.00000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+)
+
+totals2 = ReportTotals(
+ files=0,
+ lines=10,
+ hits=8,
+ misses=2,
+ partials=0,
+ coverage="80.00000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+)
+
+totals3 = ReportTotals(
+ files=0,
+ lines=10,
+ hits=3,
+ misses=2,
+ partials=0,
+ coverage="30.00000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+)
+
+# tests
+
+
+class TestPathNode(TestCase):
+ def setUp(self):
+ self.dir = Dir(
+ full_path="dir/subdir",
+ children=[
+ File(full_path="dir/subdir/file2.py", totals=totals1),
+ File(full_path="dir/subdir/file3.py", totals=totals1),
+ ],
+ )
+
+ def test_lines(self):
+ # 2 files, 10 lins each
+ assert self.dir.lines == 20
+
+ def test_hits(self):
+ # 2 files, 8 hits each
+ assert self.dir.hits == 16
+
+ def test_misses(self):
+ # 2 files, 2 misses each
+ assert self.dir.misses == 4
+
+ def test_partials(self):
+ assert self.dir.partials == 0
+
+ def test_coverage(self):
+ assert self.dir.coverage == 80.0
+
+ file = File(full_path="file1.py", totals=ReportTotals.default_totals())
+ assert file.coverage == 0
+
+ def test_name(self):
+ assert self.dir.name == "subdir"
+
+
+class TestPrefixedPath(TestCase):
+ def test_relative_path(self):
+ path = PrefixedPath("dir/file1.py", "")
+ assert path.relative_path == "dir/file1.py"
+
+ path = PrefixedPath("dir/file1.py", "dir")
+ assert path.relative_path == "file1.py"
+
+ def test_basename(self):
+ path = PrefixedPath("dir/file1.py", "")
+ assert path.basename == "dir"
+
+ path = PrefixedPath("file1.py", "")
+ assert path.basename == "file1.py"
+
+ path = PrefixedPath("dir/subdir/file1.py", "dir")
+ assert path.basename == "dir/subdir"
+
+ path = PrefixedPath("dir/subdir/file1.py", "dir/subdir")
+ assert path.basename == "dir/subdir/file1.py"
+
+
+class TestReportPaths(TestCase):
+ def setUp(self):
+ files = {
+ "dir/file1.py": file_data1,
+ "dir/subdir/file2.py": file_data2,
+ "dir/subdir/file3.py": file_data3,
+ }
+ self.report = SerializableReport(files=files)
+
+ def test_default_paths(self):
+ report_paths = ReportPaths(self.report)
+ assert report_paths.paths == [
+ PrefixedPath("dir/file1.py", ""),
+ PrefixedPath("dir/subdir/file2.py", ""),
+ PrefixedPath("dir/subdir/file3.py", ""),
+ ]
+
+ def test_prefix_paths(self):
+ report_paths = ReportPaths(self.report, path="dir")
+ assert report_paths.paths == [
+ PrefixedPath("dir/file1.py", "dir"),
+ PrefixedPath("dir/subdir/file2.py", "dir"),
+ PrefixedPath("dir/subdir/file3.py", "dir"),
+ ]
+
+ report_paths = ReportPaths(self.report, path="dir/subdir")
+ assert report_paths.paths == [
+ PrefixedPath("dir/subdir/file2.py", "dir/subdir"),
+ PrefixedPath("dir/subdir/file3.py", "dir/subdir"),
+ ]
+
+ def test_search_paths(self):
+ report_paths = ReportPaths(self.report, search_term="file")
+ assert report_paths.paths == [
+ PrefixedPath("dir/file1.py", ""),
+ PrefixedPath("dir/subdir/file2.py", ""),
+ PrefixedPath("dir/subdir/file3.py", ""),
+ ]
+
+ report_paths = ReportPaths(self.report, search_term="ile2")
+ assert report_paths.paths == [
+ PrefixedPath("dir/subdir/file2.py", ""),
+ ]
+
+ def test_full_filelist(self):
+ report_paths = ReportPaths(self.report)
+ assert report_paths.full_filelist() == [
+ File(full_path="dir/file1.py", totals=totals1),
+ File(full_path="dir/subdir/file2.py", totals=totals2),
+ File(full_path="dir/subdir/file3.py", totals=totals3),
+ ]
+
+ def test_single_directory(self):
+ report_paths = ReportPaths(self.report, path="dir")
+ assert report_paths.single_directory() == [
+ File(full_path="dir/file1.py", totals=totals1),
+ Dir(
+ full_path="dir/subdir",
+ children=[
+ File(full_path="dir/subdir/file2.py", totals=totals2),
+ File(full_path="dir/subdir/file3.py", totals=totals3),
+ ],
+ ),
+ ]
+
+ def test_invalid_path(self):
+ report_paths = ReportPaths(self.report, path="wrong")
+ assert report_paths.paths == []
+
+ def test_files(self):
+ flags = ["flag-123"]
+ report = Report()
+ session_a_id, _ = report.add_session(Session(flags=["flag-123"]))
+
+ file_a = ReportFile("foo/file1.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ report.append(file_a)
+
+ report_paths = ReportPaths(report=report, filter_flags=flags)
+ assert report_paths.files == ["foo/file1.py"]
+
+
+class TestReportPathsNested(TestCase):
+ def setUp(self):
+ files = {
+ "dir/file1.py": file_data1,
+ "dir/subdir/file2.py": file_data2,
+ "dir/subdir/dir1/file3.py": file_data3,
+ "dir/subdir/dir2/file3.py": file_data3,
+ "src/ui/A/A.js": file_data3,
+ "src/ui/Avatar/A.js": file_data3,
+ }
+ self.report = SerializableReport(files=files)
+
+ def test_single_directory(self):
+ report_paths = ReportPaths(self.report, path="dir")
+ assert report_paths.single_directory() == [
+ File(full_path="dir/file1.py", totals=totals1),
+ Dir(
+ full_path="dir/subdir",
+ children=[
+ File(full_path="dir/subdir/file2.py", totals=totals2),
+ Dir(
+ full_path="dir/subdir/dir1",
+ children=[
+ File(full_path="dir/subdir/dir1/file3.py", totals=totals3),
+ ],
+ ),
+ Dir(
+ full_path="dir/subdir/dir2",
+ children=[
+ File(full_path="dir/subdir/dir2/file3.py", totals=totals3),
+ ],
+ ),
+ ],
+ ),
+ ]
+
+ report_paths = ReportPaths(self.report, path="src/ui/A")
+ assert report_paths.single_directory() == [
+ File(full_path="src/ui/A/A.js", totals=totals3),
+ ]
+
+
+class MockedProviderAdapter:
+ async def list_files(self, *args, **kwargs):
+ return []
+
+
+class TestProviderPath(TestCase):
+ def setUp(self):
+ self.commit = CommitFactory()
+ self.owner = OwnerFactory()
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_provider_path(self, mock_provider_adapter):
+ mock_provider_adapter.return_value = MockedProviderAdapter()
+ assert provider_path_exists("foo/bar", self.commit, self.owner) == True
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_provider_path_not_found(self, mock_provider_adapter):
+ mock_provider_adapter.side_effect = TorngitClientGeneralError(404, None, None)
+ assert provider_path_exists("foo/bar", self.commit, self.owner) == False
+
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_provider_path_other_error(self, mock_provider_adapter):
+ mock_provider_adapter.side_effect = TorngitClientGeneralError(500, None, None)
+ assert provider_path_exists("foo/bar", self.commit, self.owner) is None
+
+
+@pytest.mark.usefixtures("sample_report")
+class TestPathMisc(TestCase):
+ def setUp(self):
+ self.service = "gh"
+ self.owner = "marquet"
+ self.repo = "yios"
+ self.commit_sha = "540feb1e8c5d39b714c43874d0aa9da02ad257b7"
+ self.commit = MagicMock(
+ commitid=self.commit_sha, full_report=self.sample_report
+ )
+
+ def test_dashboard_commit_file_url_path_none(self):
+ path = None
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=self.service,
+ owner=self.owner,
+ repo=self.repo,
+ commit=self.commit,
+ )
+ assert (
+ commit_file_url
+ == f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.owner}/{self.repo}/commit/{self.commit_sha}/tree/"
+ )
+
+ def test_dashboard_commit_file_url_empty_path(self):
+ path = ""
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=self.service,
+ owner=self.owner,
+ repo=self.repo,
+ commit=self.commit,
+ )
+ assert (
+ commit_file_url
+ == f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.owner}/{self.repo}/commit/{self.commit_sha}/tree/"
+ )
+
+ def test_dashboard_commit_file_nonexistent_path(self):
+ path = "path/not/in/report"
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=self.service,
+ owner=self.owner,
+ repo=self.repo,
+ commit=self.commit,
+ )
+ assert (
+ commit_file_url
+ == f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.owner}/{self.repo}/commit/{self.commit_sha}/tree/{path}"
+ )
+
+ def test_dashboard_commit_file_not_in_report(self):
+ path = "foo/file2.py"
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=self.service,
+ owner=self.owner,
+ repo=self.repo,
+ commit=self.commit,
+ )
+ assert (
+ commit_file_url
+ == f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.owner}/{self.repo}/commit/{self.commit_sha}/tree/{path}"
+ )
+
+ def test_dashboard_commit_file_in_report(self):
+ path = "foo/file1.py"
+ commit_file_url = dashboard_commit_file_url(
+ path=path,
+ service=self.service,
+ owner=self.owner,
+ repo=self.repo,
+ commit=self.commit,
+ )
+ assert (
+ commit_file_url
+ == f"{settings.CODECOV_DASHBOARD_URL}/{self.service}/{self.owner}/{self.repo}/commit/{self.commit_sha}/blob/{path}"
+ )
diff --git a/apps/codecov-api/services/tests/test_redis_configuration.py b/apps/codecov-api/services/tests/test_redis_configuration.py
new file mode 100644
index 0000000000..1b48fbf37c
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_redis_configuration.py
@@ -0,0 +1,9 @@
+from shared.helpers.redis import get_redis_connection
+
+
+def test_get_redis_connection(mocker):
+ mocker.patch("shared.helpers.redis.get_config", return_value=None)
+ mocked = mocker.patch("shared.helpers.redis.Redis.from_url")
+ res = get_redis_connection()
+ assert res is not None
+ mocked.assert_called_with("redis://redis:6379")
diff --git a/apps/codecov-api/services/tests/test_refresh.py b/apps/codecov-api/services/tests/test_refresh.py
new file mode 100644
index 0000000000..bfc21a7666
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_refresh.py
@@ -0,0 +1,107 @@
+import pytest
+
+from services.refresh import RefreshService
+
+celery_task_data = {"random": "data"}
+
+
+class AsyncReturnMock:
+ parent = None
+
+ def as_tuple(self):
+ return celery_task_data
+
+ def successful(self):
+ return False
+
+ def failed(self):
+ return False
+
+
+class AsyncReturnMockSuccessful(AsyncReturnMock):
+ def successful(self):
+ return True
+
+
+class AsyncReturnMockFailed(AsyncReturnMock):
+ def failed(self):
+ return True
+
+
+class AsyncReturnMockParentFailed(AsyncReturnMock):
+ @property
+ def parent(self):
+ return AsyncReturnMockFailed()
+
+
+@pytest.fixture
+def mock_refresh(mocker):
+ mock_task_service = mocker.patch("services.task.TaskService.refresh")
+ mock_task_service.return_value = AsyncReturnMock()
+ yield mock_task_service
+
+
+@pytest.fixture
+def mock_result_from_tuple(mocker):
+ mock_result_from_tuple = mocker.patch("services.refresh.result_from_tuple")
+ mock_result_from_tuple.return_value = AsyncReturnMock()
+ yield mock_result_from_tuple
+
+
+def test_is_refreshing_true_after_trigger(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ assert RefreshService().is_refreshing(5) is False
+ RefreshService().trigger_refresh(5, "codecov")
+ assert RefreshService().is_refreshing(5) is True
+
+
+def test_refresh_makes_proper_redis_calls(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ assert mock_redis.get("refresh_5") == b'{"random": "data"}'
+
+
+def test_dont_refresh_is_already_refreshing(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ mock_refresh.assert_called()
+ mock_refresh.reset_mock()
+ RefreshService().trigger_refresh(5, "codecov")
+ mock_refresh.assert_not_called()
+
+
+def test_is_refreshing_false_when_task_is_successful(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ assert mock_redis.get("refresh_5") == b'{"random": "data"}'
+ mock_result_from_tuple.return_value = AsyncReturnMockSuccessful()
+ assert RefreshService().is_refreshing(5) is False
+ assert mock_redis.get("refresh_5") is None
+
+
+def test_is_refreshing_false_when_task_is_failed(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ mock_result_from_tuple.return_value = AsyncReturnMockFailed()
+ assert RefreshService().is_refreshing(5) is False
+
+
+def test_is_refreshing_false_when_parent_task_is_failed(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ mock_result_from_tuple.return_value = AsyncReturnMockParentFailed()
+ assert RefreshService().is_refreshing(5) is False
+
+
+def test_is_refreshing_false_when_result_from_tuple_raise(
+ mock_result_from_tuple, mock_refresh, mock_redis
+):
+ RefreshService().trigger_refresh(5, "codecov")
+ mock_result_from_tuple.side_effect = ValueError
+ assert RefreshService().is_refreshing(5) is False
diff --git a/apps/codecov-api/services/tests/test_repo_providers.py b/apps/codecov-api/services/tests/test_repo_providers.py
new file mode 100644
index 0000000000..5e301abb2f
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_repo_providers.py
@@ -0,0 +1,378 @@
+import inspect
+from unittest.mock import patch
+
+import pytest
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.torngit import Bitbucket, Github, Gitlab
+
+from codecov_auth.models import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+ Owner,
+ Service,
+)
+from services.repo_providers import RepoProviderService, get_token_refresh_callback
+from utils.encryption import encryptor
+
+
+def mock_get_config_verify_ssl_true(*args):
+ if args == ("github", "verify_ssl"):
+ return True
+ if args == ("github", "ssl_pem"):
+ return "ssl_pem"
+
+
+def mock_get_config_verify_ssl_false(*args):
+ if args == ("github", "verify_ssl"):
+ return False
+ if args == ("github", "ssl_pem"):
+ return "ssl_pem"
+
+
+def mock_get_env_ca_bundle(*args):
+ if args == ("REQUESTS_CA_BUNDLE",):
+ return "REQUESTS_CA_BUNDLE"
+
+
+@pytest.mark.parametrize("using_integration", [True, False])
+def test__is_using_integration_deprecated_flow(using_integration, db):
+ repo = RepositoryFactory.create(using_integration=using_integration)
+ assert RepoProviderService()._is_using_integration(None, repo) == using_integration
+
+
+def test__is_using_integration_ghapp_covers_all_repos(db):
+ owner = OwnerFactory.create(service="github")
+ repo = RepositoryFactory.create(author=owner)
+ other_repo_same_owner = RepositoryFactory.create(author=owner)
+ repo_different_owner = RepositoryFactory.create()
+ assert repo.author != repo_different_owner.author
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ owner=owner,
+ repository_service_ids=None,
+ installation_id=12345,
+ )
+ ghapp_installation.save()
+ assert RepoProviderService()._is_using_integration(ghapp_installation, repo) == True
+ assert (
+ RepoProviderService()._is_using_integration(
+ ghapp_installation, other_repo_same_owner
+ )
+ == True
+ )
+ assert (
+ RepoProviderService()._is_using_integration(
+ ghapp_installation, repo_different_owner
+ )
+ == False
+ )
+
+
+def test__is_using_integration_ghapp_covers_some_repos(db):
+ owner = OwnerFactory.create(service="github")
+ repo = RepositoryFactory.create(author=owner)
+ other_repo_same_owner = RepositoryFactory.create(author=owner)
+ repo_different_owner = RepositoryFactory.create()
+ assert repo.author != repo_different_owner.author
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ owner=owner,
+ repository_service_ids=[repo.service_id],
+ installation_id=12345,
+ )
+ ghapp_installation.save()
+ assert RepoProviderService()._is_using_integration(ghapp_installation, repo) == True
+ assert (
+ RepoProviderService()._is_using_integration(
+ ghapp_installation, other_repo_same_owner
+ )
+ == False
+ )
+ assert (
+ RepoProviderService()._is_using_integration(
+ ghapp_installation, repo_different_owner
+ )
+ == False
+ )
+
+
+@pytest.mark.parametrize(
+ "should_have_owner,service",
+ [
+ (False, Service.GITHUB.value),
+ (True, Service.BITBUCKET.value),
+ (True, Service.BITBUCKET_SERVER.value),
+ ],
+)
+def test_token_refresh_callback_none_cases(should_have_owner, service, db):
+ owner = None
+ if should_have_owner:
+ owner = OwnerFactory(service=service)
+ assert get_token_refresh_callback(owner, service) is None
+
+
+class TestRepoProviderService(TestCase):
+ def setUp(self):
+ self.repo_gh = RepositoryFactory.create(
+ author__unencrypted_oauth_token="testaaft3ituvli790m1yajovjv5eg0r4j0264iw",
+ author__username="ThiagoCodecov",
+ author__service="github",
+ )
+ self.repo_gl = RepositoryFactory.create(
+ author__unencrypted_oauth_token="testaaft3ituvli790m1yajovjv5eg0r4j0264iw",
+ author__username="ThiagoCodecov",
+ author__service="gitlab",
+ )
+
+ @sync_to_async
+ def get_owner_gl(self):
+ return Owner.objects.filter(ownerid=self.repo_gl.author.ownerid).first()
+
+ @sync_to_async
+ def get_owner_gh(self):
+ return Owner.objects.filter(ownerid=self.repo_gh.author.ownerid).first()
+
+ def test_get_torngit_with_names_github(self):
+ provider = RepoProviderService().get_by_name(
+ self.repo_gh.author,
+ self.repo_gh.name,
+ self.repo_gh.author,
+ self.repo_gh.author.service,
+ )
+ assert isinstance(Github(), type(provider))
+
+ def test_get_torngit_with_names_gitlab(self):
+ provider = RepoProviderService().get_by_name(
+ self.repo_gl.author,
+ self.repo_gl.name,
+ self.repo_gl.author,
+ self.repo_gl.author.service,
+ )
+ assert isinstance(provider, Gitlab)
+ assert provider._on_token_refresh is not None
+
+ @pytest.mark.asyncio
+ async def test_refresh_callback(self):
+ provider = RepoProviderService().get_by_name(
+ self.repo_gl.author,
+ self.repo_gl.name,
+ self.repo_gl.author,
+ self.repo_gl.author.service,
+ )
+ assert isinstance(Gitlab(), type(provider))
+ assert provider._on_token_refresh is not None
+ assert inspect.isawaitable(provider._on_token_refresh())
+ owner = await self.get_owner_gl()
+ saved_token = encryptor.decrypt_token(owner.oauth_token)
+ assert saved_token["key"] == "testaaft3ituvli790m1yajovjv5eg0r4j0264iw"
+ assert "refresh_token" not in saved_token
+
+ new_token = {"key": "00001023102301", "refresh_token": "20349230952"}
+ await provider._on_token_refresh(new_token)
+ owner = await self.get_owner_gl()
+ assert owner.username == "ThiagoCodecov"
+ saved_token = encryptor.decrypt_token(owner.oauth_token)
+ assert saved_token["key"] == "00001023102301"
+ assert saved_token["refresh_token"] == "20349230952"
+
+ @pytest.mark.asyncio
+ async def test_refresh_callback_github(self):
+ provider = RepoProviderService().get_by_name(
+ self.repo_gh.author,
+ self.repo_gh.name,
+ self.repo_gh.author,
+ self.repo_gh.author.service,
+ )
+ assert isinstance(Github(), type(provider))
+ assert provider._on_token_refresh is not None
+ assert inspect.isawaitable(provider._on_token_refresh())
+ owner = await self.get_owner_gh()
+ saved_token = encryptor.decrypt_token(owner.oauth_token)
+ assert saved_token["key"] == "testaaft3ituvli790m1yajovjv5eg0r4j0264iw"
+ assert "refresh_token" not in saved_token
+
+ new_token = {"key": "00001023102301", "refresh_token": "20349230952"}
+ await provider._on_token_refresh(new_token)
+ owner = await self.get_owner_gh()
+ assert owner.username == "ThiagoCodecov"
+ saved_token = encryptor.decrypt_token(owner.oauth_token)
+ assert saved_token["key"] == "00001023102301"
+ assert saved_token["refresh_token"] == "20349230952"
+
+ def test_get_adapter_returns_adapter_for_repo_authors_service(self):
+ some_other_user = OwnerFactory(service="github")
+ repo = RepositoryFactory.create(
+ author__username="ThiagoCodecov", author__service="bitbucket"
+ )
+ provider = RepoProviderService().get_adapter(some_other_user, repo)
+ assert isinstance(Bitbucket(), type(provider))
+
+ def test_get_by_name_returns_adapter_for_repo_owner_service(self):
+ some_other_user = OwnerFactory(service="bitbucket")
+ repo_name = "gl-repo"
+ repo_owner_username = "me"
+ repo_owner_service = "gitlab"
+
+ provider = RepoProviderService().get_by_name(
+ owner=some_other_user,
+ repo_name=repo_name,
+ repo_owner_username=repo_owner_username,
+ repo_owner_service=repo_owner_service,
+ )
+
+ assert isinstance(Gitlab(), type(provider))
+
+ def test_get_by_name_submits_consumer_oauth_token(self):
+ user = OwnerFactory(service="bitbucket")
+ repo_name = "bb-repo"
+ repo_owner_username = "me"
+ repo_owner_service = "bitbucket"
+
+ provider = RepoProviderService().get_by_name(
+ owner=user,
+ repo_name=repo_name,
+ repo_owner_username=repo_owner_username,
+ repo_owner_service=repo_owner_service,
+ )
+
+ assert provider._oauth_consumer_token() is not None
+
+ @patch("services.repo_providers.get_provider")
+ @patch("services.repo_providers.get_config")
+ def test_get_adapter_verify_ssl_true(self, mock_get_config, mock_get_provider):
+ mock_get_config.side_effect = mock_get_config_verify_ssl_true
+ bot = OwnerFactory()
+ user = OwnerFactory(service="github")
+ repo = RepositoryFactory.create(author=user, bot=bot)
+
+ RepoProviderService().get_adapter(
+ user, repo, use_ssl=True, token=repo.bot.oauth_token
+ )
+ mock_get_provider.call_args == (
+ (
+ "github",
+ dict(
+ repo=dict(
+ name=repo.name,
+ using_integration=repo.using_integration,
+ service_id=repo.service_id,
+ private=repo.private,
+ ),
+ owner=dict(username=repo.author.username),
+ token=encryptor.decrypt_token(repo.bot.oauth_token),
+ verify_ssl="ssl_pem",
+ oauth_consumer_token=dict(
+ key=getattr(settings, "GITHUB_CLIENT_ID", "unknown"),
+ secret=getattr(settings, "GITHUB_CLIENT_SECRET", "unknown"),
+ ),
+ ),
+ ),
+ )
+
+ @patch("services.repo_providers.get_provider")
+ @patch("services.repo_providers.get_config")
+ @patch("services.repo_providers.getenv")
+ def test_get_adapter_for_uploads_verify_ssl_false(
+ self, mock_get_env, mock_get_config, mock_get_provider
+ ):
+ mock_get_config.side_effect = mock_get_config_verify_ssl_false
+ mock_get_env.side_effect = mock_get_env_ca_bundle
+ bot = OwnerFactory()
+ user = OwnerFactory(service="github")
+ repo = RepositoryFactory.create(author=user, bot=bot)
+
+ RepoProviderService().get_adapter(
+ user, repo, use_ssl=True, token=repo.bot.oauth_token
+ )
+ mock_get_provider.call_args == (
+ (
+ "github",
+ dict(
+ repo=dict(
+ name=repo.name,
+ using_integration=repo.using_integration,
+ service_id=repo.service_id,
+ private=repo.private,
+ ),
+ owner=dict(username=repo.author.username),
+ token=encryptor.decrypt_token(repo.bot.oauth_token),
+ verify_ssl="REQUESTS_CA_BUNDLE",
+ oauth_consumer_token=dict(
+ key=getattr(settings, "GITHUB_CLIENT_ID", "unknown"),
+ secret=getattr(settings, "GITHUB_CLIENT_SECRET", "unknown"),
+ ),
+ ),
+ ),
+ )
+
+ def test_get_adapter_sets_token_to_bot_when_user_not_authenticated(self):
+ repo_owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(author=repo_owner)
+ adapter = RepoProviderService().get_adapter(None, repo)
+ assert adapter.token["key"] == settings.GITHUB_BOT_KEY
+
+ def test_get_by_name_sets_token_to_bot_when_user_not_authenticated(self):
+ repo_name = "gh-repo"
+ repo_owner_username = "me"
+ repo_owner_service = "github"
+
+ adapter = RepoProviderService().get_by_name(
+ owner=None,
+ repo_name=repo_name,
+ repo_owner_username=repo_owner_username,
+ repo_owner_service=repo_owner_service,
+ )
+
+ assert adapter.token["key"] == settings.GITHUB_BOT_KEY
+
+ def test_get_adapter_sets_owner_service_id(self):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(author=owner)
+ user = OwnerFactory()
+ adapter = RepoProviderService().get_adapter(owner=user, repo=repo)
+ assert adapter.data["owner"]["service_id"] == owner.service_id
+
+ @pytest.mark.asyncio
+ @patch(
+ "services.repo_providers.RepoProviderService._get_adapter",
+ return_value="torngit_adapter",
+ )
+ async def test_async_get_adapter(self, mock__get_adapter):
+ owner = await self.get_owner_gh()
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=1234,
+ owner=owner,
+ repository_service_ids=None,
+ )
+ await ghapp_installation.asave()
+ fetched = await RepoProviderService().async_get_adapter(owner, self.repo_gh)
+ assert fetched == "torngit_adapter"
+ mock__get_adapter.assert_called_with(
+ owner, self.repo_gh, ghapp=ghapp_installation
+ )
+
+ @pytest.mark.asyncio
+ @patch(
+ "services.repo_providers.RepoProviderService._get_adapter",
+ return_value="torngit_adapter",
+ )
+ async def test_async_get_adapter_owner_not_github(self, mock__get_adapter):
+ owner = await self.get_owner_gl()
+ fetched = await RepoProviderService().async_get_adapter(owner, self.repo_gl)
+ assert fetched == "torngit_adapter"
+ mock__get_adapter.assert_called_with(owner, self.repo_gl, ghapp=None)
+
+ @pytest.mark.asyncio
+ @patch(
+ "services.repo_providers.RepoProviderService._get_adapter",
+ return_value="torngit_adapter",
+ )
+ async def test_async_get_adapter_no_installation(self, mock__get_adapter):
+ owner = await self.get_owner_gh()
+ fetched = await RepoProviderService().async_get_adapter(owner, self.repo_gh)
+ assert fetched == "torngit_adapter"
+ mock__get_adapter.assert_called_with(owner, self.repo_gh, ghapp=None)
diff --git a/apps/codecov-api/services/tests/test_report.py b/apps/codecov-api/services/tests/test_report.py
new file mode 100644
index 0000000000..e93fca7bf5
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_report.py
@@ -0,0 +1,333 @@
+from pathlib import Path
+from unittest.mock import patch
+
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ CommitWithReportFactory,
+)
+from shared.reports.api_report_service import build_report_from_commit
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.storage.exceptions import FileNotInStorageError
+from shared.utils.sessions import Session
+
+from reports.tests.factories import UploadFactory, UploadFlagMembershipFactory
+from services.report import files_belonging_to_flags
+
+current_file = Path(__file__)
+
+
+def flags_report():
+ report = Report()
+ session_a_id, _ = report.add_session(Session(flags=["flag-a"]))
+ session_b_id, _ = report.add_session(Session(flags=["flag-b"]))
+ session_c_id, _ = report.add_session(Session(flags=["flag-c"]))
+
+ file_a = ReportFile("foo/file1.py")
+ file_a.append(1, ReportLine.create(coverage=1, sessions=[[session_a_id, 1]]))
+ report.append(file_a)
+
+ file_b = ReportFile("bar/file2.py")
+ file_b.append(12, ReportLine.create(coverage=1, sessions=[[session_b_id, 1]]))
+ report.append(file_b)
+
+ file_c = ReportFile("another/file3.py")
+ file_c.append(12, ReportLine.create(coverage=1, sessions=[[session_c_id, 1]]))
+ report.append(file_c)
+
+ return report
+
+
+def sorted_files(report: Report) -> list[ReportFile]:
+ files = [report.get(file) for file in report.files]
+ return sorted(files, key=lambda x: x.name)
+
+
+class ReportServiceTest(TestCase):
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit(self, read_chunks_mock):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+ commit = CommitWithReportFactory.create(message="aaaaa", commitid="abf6d4d")
+ commit_report = commit.reports.first()
+
+ # this will be ignored
+ UploadFactory(
+ report=commit_report,
+ order_number=None,
+ storage_path="v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ state="error",
+ )
+
+ res = build_report_from_commit(commit)
+ assert len(res.files) == 3
+ file_1, file_2, file_3 = sorted_files(res)
+ assert file_1.name == "awesome/__init__.py"
+ assert tuple(file_1.totals) == (0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0)
+ assert file_2.name == "tests/__init__.py"
+ assert tuple(file_2.totals) == (0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0)
+ assert file_3.name == "tests/test_sample.py"
+ assert tuple(file_3.totals) == (0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0)
+ read_chunks_mock.assert_called_with("abf6d4d")
+ assert list(res.totals) == [
+ 3,
+ 20,
+ 17,
+ 3,
+ 0,
+ "85.00000",
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_file_not_in_storage(self, read_chunks_mock):
+ read_chunks_mock.side_effect = FileNotInStorageError()
+ commit = CommitWithReportFactory.create(message="aaaaa", commitid="abf6d4d")
+ assert build_report_from_commit(commit) is None
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_cff_and_direct_uploads(self, read_chunks_mock):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+
+ commit = CommitWithReportFactory.create()
+ commit_report = commit.reports.first()
+
+ # this upload will be ignored since there's another direct
+ # upload with the same flag
+ upload = UploadFactory(
+ report=commit_report,
+ order_number=2,
+ storage_path="v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ upload_type="carriedforward",
+ )
+ UploadFlagMembershipFactory(
+ report_session=upload,
+ flag=commit.repository.flags.get(flag_name="unittests"),
+ )
+
+ res = build_report_from_commit(commit)
+ assert len(res.sessions) == 2
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_null_session_totals(self, read_chunks_mock):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+ commit = CommitWithReportFactory.create(message="aaaaa", commitid="abf6d4d")
+ upload = commit.reports.first().sessions.first()
+ upload.uploadleveltotals.delete()
+ res = build_report_from_commit(commit)
+ assert len(res.files) == 3
+ file_1, file_2, file_3 = sorted_files(res)
+ assert file_1.name == "awesome/__init__.py"
+ assert tuple(file_1.totals) == (0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0)
+ assert file_2.name == "tests/__init__.py"
+ assert tuple(file_2.totals) == (0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0)
+ assert file_3.name == "tests/test_sample.py"
+ assert tuple(file_3.totals) == (0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0)
+ read_chunks_mock.assert_called_with("abf6d4d")
+ assert list(res.totals) == [
+ 3,
+ 20,
+ 17,
+ 3,
+ 0,
+ "85.00000",
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_with_flags(self, read_chunks_mock):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+ commit = CommitWithReportFactory.create(message="aaaaa", commitid="abf6d4d")
+ report = build_report_from_commit(commit)
+ res = report.flags["integrations"].report
+ assert len(res.files) == 3
+ file_1, file_2, file_3 = sorted_files(res)
+ assert file_1.name == "awesome/__init__.py"
+ assert tuple(file_1.totals) == (0, 10, 1, 9, 0, "10.00000", 0, 0, 0, 0, 0, 0, 0)
+ assert file_2.name == "tests/__init__.py"
+ assert tuple(file_2.totals) == (0, 3, 0, 3, 0, "0", 0, 0, 0, 0, 0, 0, 0)
+ assert file_3.name == "tests/test_sample.py"
+ assert tuple(file_3.totals) == (0, 7, 2, 5, 0, "28.57143", 0, 0, 0, 0, 0, 0, 0)
+ read_chunks_mock.assert_called_with("abf6d4d")
+ assert list(res.totals) == [3, 20, 3, 17, 0, "15.00000", 0, 0, 0, 1, 0, 0, 0]
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_with_non_carried_forward_flags(
+ self, read_chunks_mock
+ ):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+ commit = CommitWithReportFactory.create(
+ message="another test",
+ commitid="asdfbhasdf89",
+ )
+
+ report = commit._report
+ report["sessions"]["1"].update(
+ st="carriedforward",
+ se={"carriedforward_from": "56e05fced214c44a37759efa2dfc25a65d8ae98d"},
+ )
+ commit.save()
+
+ commit_report = commit.reports.first()
+ session = commit_report.sessions.filter(order_number=1).first()
+ session.upload_type = "carriedforward"
+ session.upload_extras = {
+ "carriedforward_from": "56e05fced214c44a37759efa2dfc25a65d8ae98d"
+ }
+ session.save()
+
+ report = build_report_from_commit(commit)
+ res = report.flags["integrations"].report
+ assert len(res.files) == 3
+ file_1, file_2, file_3 = sorted_files(res)
+ assert file_1.name == "awesome/__init__.py"
+ assert tuple(file_1.totals) == (0, 10, 1, 9, 0, "10.00000", 0, 0, 0, 0, 0, 0, 0)
+ assert file_2.name == "tests/__init__.py"
+ assert tuple(file_2.totals) == (0, 3, 0, 3, 0, "0", 0, 0, 0, 0, 0, 0, 0)
+ assert file_3.name == "tests/test_sample.py"
+ assert tuple(file_3.totals) == (0, 7, 2, 5, 0, "28.57143", 0, 0, 0, 0, 0, 0, 0)
+ read_chunks_mock.assert_called_with("asdfbhasdf89")
+ assert list(res.totals) == [3, 20, 3, 17, 0, "15.00000", 0, 0, 0, 1, 0, 0, 0]
+ cff_session = res.report.sessions[1]
+ assert cff_session.session_type.value == "carriedforward"
+ assert (
+ cff_session.session_extras["carriedforward_from"]
+ == "56e05fced214c44a37759efa2dfc25a65d8ae98d"
+ )
+
+ def test_build_report_from_commit_no_report(self):
+ commit = CommitFactory()
+ report = build_report_from_commit(commit)
+ assert report is None
+
+ @patch("shared.api_archive.archive.ArchiveService.read_chunks")
+ def test_build_report_from_commit_fallback(self, read_chunks_mock):
+ f = open(current_file.parent / "samples" / "chunks.txt", "r")
+ read_chunks_mock.return_value = f.read()
+
+ report = {
+ "files": {
+ "awesome/__init__.py": [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ],
+ "tests/__init__.py": [
+ 0,
+ [0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "tests/test_sample.py": [
+ 1,
+ [0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ },
+ "sessions": {
+ "0": {
+ "N": None,
+ "a": "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ "c": None,
+ "d": 1547084427,
+ "e": None,
+ "f": ["unittests"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "t": [3, 20, 17, 3, 0, "85.00000", 0, 0, 0, 0, 0, 0, 0],
+ "": None,
+ },
+ "1": {
+ "N": None,
+ "a": "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ "c": None,
+ "d": 1547084427,
+ "e": None,
+ "f": ["integrations"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "t": [3, 20, 17, 3, 0, "85.00000", 0, 0, 0, 0, 0, 0, 0],
+ "": None,
+ },
+ },
+ }
+
+ # there are no associated `reports_*` records but we have `commits.report` populated
+ commit = CommitFactory.create(
+ message="aaaaa", commitid="abf6d4d", _report=report
+ )
+ res = build_report_from_commit(commit)
+
+ assert len(res.files) == 3
+ file_1, file_2, file_3 = sorted_files(res)
+ assert file_1.name == "awesome/__init__.py"
+ assert tuple(file_1.totals) == (0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0)
+ assert file_2.name == "tests/__init__.py"
+ assert tuple(file_2.totals) == (0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0)
+ assert file_3.name == "tests/test_sample.py"
+ assert tuple(file_3.totals) == (0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0)
+ read_chunks_mock.assert_called_with("abf6d4d")
+ assert list(res.totals) == [
+ 3,
+ 20,
+ 17,
+ 3,
+ 0,
+ "85.00000",
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ]
+
+ def test_files_belonging_to_flags_with_one_flag(self):
+ commit_report = flags_report()
+ flags = ["flag-a"]
+ files = files_belonging_to_flags(commit_report=commit_report, flags=flags)
+ assert len(files) == 1
+ assert files == ["foo/file1.py"]
+
+ def test_files_belonging_to_flags_with_all_flags(self):
+ commit_report = flags_report()
+ flags = ["flag-a", "flag-b", "flag-c"]
+ files = files_belonging_to_flags(commit_report=commit_report, flags=flags)
+ assert len(files) == 3
+ assert files == ["foo/file1.py", "bar/file2.py", "another/file3.py"]
+
+ def test_files_belonging_to_flags_with_known_and_unknown_flag(self):
+ commit_report = flags_report()
+ flags = ["flag-a", "flag-b", "random-value-123"]
+ files = files_belonging_to_flags(commit_report=commit_report, flags=flags)
+ assert len(files) == 2
+ assert files == ["foo/file1.py", "bar/file2.py"]
+
+ def test_files_belonging_to_flags_with_only_unknown_flag(self):
+ commit_report = flags_report()
+ flags = ["random-value-123"]
+ files = files_belonging_to_flags(commit_report=commit_report, flags=flags)
+ assert len(files) == 0
+ assert files == []
diff --git a/apps/codecov-api/services/tests/test_self_hosted.py b/apps/codecov-api/services/tests/test_self_hosted.py
new file mode 100644
index 0000000000..d555e4db9d
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_self_hosted.py
@@ -0,0 +1,241 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory
+from shared.license import LicenseInformation
+
+from codecov_auth.models import Owner
+from services.self_hosted import (
+ LicenseException,
+ activate_owner,
+ activated_owners,
+ admin_owners,
+ can_activate_owner,
+ deactivate_owner,
+ disable_autoactivation,
+ enable_autoactivation,
+ is_activated_owner,
+ is_admin_owner,
+ is_autoactivation_enabled,
+ license_seats,
+)
+
+
+@override_settings(IS_ENTERPRISE=True)
+class SelfHostedTestCase(TestCase):
+ @patch("services.self_hosted.get_config")
+ def test_admin_owners(self, get_config):
+ owner1 = OwnerFactory(service="github", username="foo")
+ OwnerFactory(service="github", username="bar")
+ owner3 = OwnerFactory(service="gitlab", username="foo")
+
+ get_config.return_value = [
+ {"service": "github", "username": "foo"},
+ {"service": "gitlab", "username": "foo"},
+ ]
+
+ owners = admin_owners()
+ assert list(owners) == [owner1, owner3]
+
+ get_config.assert_called_once_with("setup", "admins", default=[])
+
+ def test_admin_owners_empty(self):
+ OwnerFactory(service="github", username="foo")
+ OwnerFactory(service="github", username="bar")
+ OwnerFactory(service="gitlab", username="foo")
+
+ owners = admin_owners()
+ assert list(owners) == []
+
+ @patch("services.self_hosted.admin_owners")
+ def test_is_admin_owner(self, admin_owners):
+ owner1 = OwnerFactory(service="github", username="foo")
+ owner2 = OwnerFactory(service="github", username="bar")
+ owner3 = OwnerFactory(service="gitlab", username="foo")
+
+ admin_owners.return_value = Owner.objects.filter(pk__in=[owner1.pk, owner2.pk])
+
+ assert is_admin_owner(owner1) == True
+ assert is_admin_owner(owner2) == True
+ assert is_admin_owner(owner3) == False
+ assert is_admin_owner(None) == False
+
+ def test_activated_owners(self):
+ user1 = OwnerFactory()
+ user2 = OwnerFactory()
+ user3 = OwnerFactory()
+ OwnerFactory()
+ OwnerFactory(plan_activated_users=[user1.pk])
+ OwnerFactory(plan_activated_users=[user2.pk, user3.pk])
+
+ owners = activated_owners()
+ assert list(owners) == [user1, user2, user3]
+
+ @patch("services.self_hosted.activated_owners")
+ def test_is_activated_owner(self, activated_owners):
+ owner1 = OwnerFactory(service="github", username="foo")
+ owner2 = OwnerFactory(service="github", username="bar")
+ owner3 = OwnerFactory(service="gitlab", username="foo")
+
+ activated_owners.return_value = Owner.objects.filter(
+ pk__in=[owner1.pk, owner2.pk]
+ )
+
+ assert is_activated_owner(owner1) == True
+ assert is_activated_owner(owner2) == True
+ assert is_activated_owner(owner3) == False
+
+ @patch("services.self_hosted.get_current_license")
+ def test_license_seats(self, get_current_license):
+ get_current_license.return_value = LicenseInformation(
+ number_allowed_users=123, is_valid=True
+ )
+ assert license_seats() == 123
+
+ @patch("services.self_hosted.get_current_license")
+ def test_license_seats_not_specified(self, get_current_license):
+ get_current_license.return_value = LicenseInformation(is_valid=True)
+ assert license_seats() == 0
+
+ @patch("services.self_hosted.activated_owners")
+ @patch("services.self_hosted.license_seats")
+ def test_can_activate_owner(self, license_seats, activated_owners):
+ license_seats.return_value = 1
+
+ owner1 = OwnerFactory(service="github", username="foo")
+ owner2 = OwnerFactory(service="github", username="bar")
+ owner3 = OwnerFactory(service="gitlab", username="foo")
+
+ activated_owners.return_value = Owner.objects.filter(
+ pk__in=[owner1.pk, owner2.pk]
+ )
+
+ assert can_activate_owner(owner1) == True
+ assert can_activate_owner(owner2) == True
+ assert can_activate_owner(owner3) == False
+
+ license_seats.return_value = 5
+
+ assert can_activate_owner(owner1) == True
+ assert can_activate_owner(owner2) == True
+ assert can_activate_owner(owner3) == True
+
+ @patch("services.self_hosted.can_activate_owner")
+ def test_activate_owner(self, can_activate_owner):
+ can_activate_owner.return_value = True
+
+ other_owner = OwnerFactory()
+ org1 = OwnerFactory(plan_activated_users=[other_owner.pk])
+ org2 = OwnerFactory(plan_activated_users=[])
+ org3 = OwnerFactory(plan_activated_users=[other_owner.pk])
+ owner = OwnerFactory(organizations=[org1.pk, org2.pk])
+
+ activate_owner(owner)
+
+ org1.refresh_from_db()
+ assert org1.plan_activated_users == [other_owner.pk, owner.pk]
+ org2.refresh_from_db()
+ assert org2.plan_activated_users == [owner.pk]
+ org3.refresh_from_db()
+ assert org3.plan_activated_users == [other_owner.pk]
+
+ activate_owner(owner)
+
+ # does not add duplicate entry
+ org1.refresh_from_db()
+ assert org1.plan_activated_users == [other_owner.pk, owner.pk]
+ org2.refresh_from_db()
+ assert org2.plan_activated_users == [owner.pk]
+ org3.refresh_from_db()
+ assert org3.plan_activated_users == [other_owner.pk]
+
+ @patch("services.self_hosted.can_activate_owner")
+ def test_activate_owner_cannot_activate(self, can_activate_owner):
+ can_activate_owner.return_value = False
+
+ other_owner = OwnerFactory()
+ org1 = OwnerFactory(plan_activated_users=[other_owner.pk])
+ org2 = OwnerFactory(plan_activated_users=[])
+ owner = OwnerFactory(organizations=[org2.pk])
+
+ with self.assertRaises(LicenseException) as e:
+ activate_owner(owner)
+ assert e.message == "no more seats available"
+
+ org1.refresh_from_db()
+ assert org1.plan_activated_users == [other_owner.pk]
+ org2.refresh_from_db()
+ assert org2.plan_activated_users == []
+
+ def test_deactivate_owner(self):
+ owner1 = OwnerFactory()
+ owner2 = OwnerFactory()
+ org1 = OwnerFactory(plan_activated_users=[owner1.pk, owner2.pk])
+ org2 = OwnerFactory(plan_activated_users=[owner1.pk])
+ org3 = OwnerFactory(plan_activated_users=[owner2.pk])
+
+ deactivate_owner(owner1)
+
+ org1.refresh_from_db()
+ assert org1.plan_activated_users == [owner2.pk]
+ org2.refresh_from_db()
+ assert org2.plan_activated_users == []
+ org3.refresh_from_db()
+ assert org3.plan_activated_users == [owner2.pk]
+
+ def test_autoactivation(self):
+ disable_autoactivation()
+
+ owner1 = OwnerFactory(plan_auto_activate=False)
+ owner2 = OwnerFactory(plan_auto_activate=False)
+ assert is_autoactivation_enabled() == False
+
+ owner1.plan_auto_activate = True
+ owner1.save()
+ assert is_autoactivation_enabled() == True
+
+ owner2.plan_auto_activate = True
+ owner2.save()
+ assert is_autoactivation_enabled() == True
+
+ def test_enable_autoactivation(self):
+ owner = OwnerFactory(plan_auto_activate=False)
+ enable_autoactivation()
+ owner.refresh_from_db()
+ assert owner.plan_auto_activate == True
+
+ def test_disable_autoactivation(self):
+ owner = OwnerFactory(plan_auto_activate=True)
+ disable_autoactivation()
+ owner.refresh_from_db()
+ assert owner.plan_auto_activate == False
+
+
+@override_settings(IS_ENTERPRISE=False)
+class SelfHostedNonEnterpriseTestCase(TestCase):
+ def test_activate_owner(self):
+ org = OwnerFactory(plan_activated_users=[])
+ owner = OwnerFactory(organizations=[org.pk])
+
+ with self.assertRaises(Exception):
+ activate_owner(owner)
+
+ org.refresh_from_db()
+ assert org.plan_activated_users == []
+
+ def test_deactivate_owner(self):
+ owner1 = OwnerFactory()
+ owner2 = OwnerFactory()
+ org1 = OwnerFactory(plan_activated_users=[owner1.pk, owner2.pk])
+ org2 = OwnerFactory(plan_activated_users=[owner1.pk])
+ org3 = OwnerFactory(plan_activated_users=[owner2.pk])
+
+ with self.assertRaises(Exception):
+ deactivate_owner(owner1)
+
+ org1.refresh_from_db()
+ assert org1.plan_activated_users == [owner1.pk, owner2.pk]
+ org2.refresh_from_db()
+ assert org2.plan_activated_users == [owner1.pk]
+ org3.refresh_from_db()
+ assert org3.plan_activated_users == [owner2.pk]
diff --git a/apps/codecov-api/services/tests/test_sentry.py b/apps/codecov-api/services/tests/test_sentry.py
new file mode 100644
index 0000000000..3ffd494002
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_sentry.py
@@ -0,0 +1,143 @@
+import json
+from unittest.mock import MagicMock, patch
+
+import jwt
+from django.test import TestCase, TransactionTestCase, override_settings
+from shared.django_apps.core.tests.factories import OwnerFactory
+
+from services.sentry import (
+ SentryInvalidStateError,
+ SentryState,
+ SentryUserAlreadyExistsError,
+ decode_state,
+ is_sentry_user,
+ save_sentry_state,
+ send_user_webhook,
+)
+
+
+@override_settings(SENTRY_JWT_SHARED_SECRET="secret")
+class DecodeStateTests(TestCase):
+ def setUp(self):
+ self.decoded_state = {"user_id": "sentry-user-id", "org_id": "sentry-org-id"}
+ self.state = jwt.encode(self.decoded_state, "secret", algorithm="HS256")
+
+ def test_decode_state(self):
+ res = decode_state(self.state)
+ assert res.data == self.decoded_state
+
+ @override_settings(SENTRY_JWT_SHARED_SECRET="wrong")
+ def test_decode_state_wrong_secret(self):
+ res = decode_state(self.state)
+ assert res is None
+
+ def test_decode_state_malformed(self):
+ res = decode_state("malformed")
+ assert res is None
+
+
+class SaveSentryStateTests(TransactionTestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+
+ self.decode_state_patcher = patch("services.sentry.decode_state")
+ self.decode_state = self.decode_state_patcher.start()
+ self.decode_state.return_value = SentryState(
+ {
+ "user_id": "sentry-user-id",
+ "org_id": "sentry-org-id",
+ }
+ )
+ self.addCleanup(self.decode_state_patcher.stop)
+
+ def test_save_sentry_state(self):
+ state_mock = MagicMock()
+ save_sentry_state(self.owner, state_mock)
+
+ self.decode_state.assert_called_once_with(state_mock)
+
+ self.owner.refresh_from_db()
+ assert self.owner.sentry_user_id == "sentry-user-id"
+ assert self.owner.sentry_user_data == {
+ "user_id": "sentry-user-id",
+ "org_id": "sentry-org-id",
+ }
+
+ def test_save_sentry_state_invalid_state(self):
+ self.decode_state.return_value = None
+
+ with self.assertRaises(SentryInvalidStateError):
+ save_sentry_state(self.owner, MagicMock())
+
+ self.owner.refresh_from_db()
+ assert self.owner.sentry_user_id is None
+ assert self.owner.sentry_user_data is None
+
+ def test_save_sentry_state_duplicate_user_id(self):
+ OwnerFactory(sentry_user_id="sentry-user-id")
+ with self.assertRaises(SentryUserAlreadyExistsError):
+ save_sentry_state(self.owner, MagicMock())
+
+ self.owner.refresh_from_db()
+ assert self.owner.sentry_user_id is None
+ assert self.owner.sentry_user_data is None
+
+
+class IsSentryUserTests(TestCase):
+ def setUp(self):
+ self.owner = OwnerFactory()
+
+ def test_owner_has_sentry_user_id(self):
+ self.owner.sentry_user_id = "testing"
+ assert is_sentry_user(self.owner) == True
+
+ def test_owner_missing_sentry_user_id(self):
+ self.owner.sentry_user_id = None
+ assert is_sentry_user(self.owner) == False
+
+
+@patch("services.task.TaskService.http_request")
+class SendWebhookTests(TestCase):
+ def setUp(self):
+ self.user = OwnerFactory(
+ sentry_user_id="sentry-user-id",
+ sentry_user_data={"user_id": "sentry-user-id", "org_id": "sentry-org-id"},
+ )
+ self.org = OwnerFactory(
+ service="github",
+ service_id="org-service-id",
+ )
+
+ @override_settings(
+ SENTRY_USER_WEBHOOK_URL="https://example.com", SENTRY_JWT_SHARED_SECRET="secret"
+ )
+ def test_webhook(self, http_request):
+ send_user_webhook(self.user, self.org)
+
+ encoded_state = jwt.encode(
+ {
+ "user_id": self.user.sentry_user_id,
+ "org_id": self.user.sentry_user_data.get("org_id"),
+ "codecov_owner_id": self.user.pk,
+ "codecov_organization_id": self.org.pk,
+ "service": self.org.service,
+ "service_id": self.org.service_id,
+ },
+ "secret",
+ algorithm="HS256",
+ )
+
+ http_request.assert_called_once_with(
+ url="https://example.com",
+ method="POST",
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": "Codecov",
+ },
+ data=json.dumps({"state": f"{encoded_state}"}),
+ )
+
+ @override_settings(SENTRY_USER_WEBHOOK_URL=None)
+ def test_webhook_no_url(self, http_request):
+ send_user_webhook(self.user, self.org)
+ assert not http_request.called
diff --git a/apps/codecov-api/services/tests/test_task.py b/apps/codecov-api/services/tests/test_task.py
new file mode 100644
index 0000000000..6f564d8348
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_task.py
@@ -0,0 +1,324 @@
+from datetime import datetime
+from unittest.mock import MagicMock
+
+import pytest
+from django.conf import settings
+from freezegun import freeze_time
+from shared import celery_config
+from shared.django_apps.core.tests.factories import RepositoryFactory
+
+from services.task import TaskService, celery_app
+from timeseries.tests.factories import DatasetFactory
+
+
+def test_refresh_task(mocker):
+ chain_mock = mocker.patch("services.task.task.chain")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "my_queue"}
+ )
+ TaskService().refresh(5, "codecov")
+ chain_mock.assert_called()
+ mock_route_task.assert_called()
+
+
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_compute_comparison_task(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "my_queue"}
+ )
+ TaskService().compute_comparison(5)
+ mock_route_task.assert_called_with(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=5),
+ )
+ signature_mock.assert_called_with(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=5),
+ app=celery_app,
+ queue="my_queue",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+
+
+def test_compute_comparisons_task(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "my_queue"}
+ )
+ apply_async_mock = mocker.patch("celery.group.apply_async")
+ TaskService().compute_comparisons([5, 10])
+ assert mock_route_task.call_count == 1
+ mock_route_task.assert_called_with(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=5),
+ )
+ assert signature_mock.call_count == 2
+ signature_mock.assert_any_call(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=10),
+ app=celery_app,
+ queue="my_queue",
+ soft_time_limit=None,
+ time_limit=None,
+ )
+ signature_mock.assert_any_call(
+ celery_config.compute_comparison_task_name,
+ args=None,
+ kwargs=dict(comparison_id=5),
+ app=celery_app,
+ queue="my_queue",
+ soft_time_limit=None,
+ time_limit=None,
+ )
+ apply_async_mock.assert_called_once_with()
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+@pytest.mark.django_db(databases={"default", "timeseries"})
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_backfill_repo(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "celery"}
+ )
+ apply_async_mock = mocker.patch("celery.group.apply_async")
+
+ repo = RepositoryFactory()
+ TaskService().backfill_repo(
+ repo,
+ start_date=datetime(2022, 1, 1),
+ end_date=datetime(2022, 1, 25),
+ dataset_names=["testing"],
+ )
+
+ assert signature_mock.call_count == 3
+ assert mock_route_task.call_count == 3
+ mock_route_task.assert_any_call(
+ celery_config.timeseries_backfill_task_name,
+ args=None,
+ kwargs=dict(
+ repoid=repo.pk,
+ start_date="2022-01-15T00:00:00",
+ end_date="2022-01-25T00:00:00",
+ dataset_names=["testing"],
+ ),
+ )
+
+ signature_mock.assert_any_call(
+ celery_config.timeseries_backfill_task_name,
+ args=None,
+ kwargs=dict(
+ repoid=repo.pk,
+ start_date="2022-01-15T00:00:00",
+ end_date="2022-01-25T00:00:00",
+ dataset_names=["testing"],
+ ),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+ signature_mock.assert_any_call(
+ celery_config.timeseries_backfill_task_name,
+ args=None,
+ kwargs=dict(
+ repoid=repo.pk,
+ start_date="2022-01-05T00:00:00",
+ end_date="2022-01-15T00:00:00",
+ dataset_names=["testing"],
+ ),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+ signature_mock.assert_any_call(
+ celery_config.timeseries_backfill_task_name,
+ args=None,
+ kwargs=dict(
+ repoid=repo.pk,
+ start_date="2022-01-01T00:00:00",
+ end_date="2022-01-05T00:00:00",
+ dataset_names=["testing"],
+ ),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+
+ apply_async_mock.assert_called_once_with()
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+@pytest.mark.django_db(databases={"default", "timeseries"})
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_backfill_dataset(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mocker.patch("services.task.task.route_task", return_value={"queue": "celery"})
+ signature = MagicMock()
+ signature_mock.return_value = signature
+
+ dataset = DatasetFactory()
+ TaskService().backfill_dataset(
+ dataset,
+ start_date=datetime(2022, 1, 1),
+ end_date=datetime(2022, 8, 9),
+ )
+
+ signature_mock.assert_called_with(
+ "app.tasks.timeseries.backfill_dataset",
+ args=None,
+ kwargs=dict(
+ dataset_id=dataset.pk,
+ start_date="2022-01-01T00:00:00",
+ end_date="2022-08-09T00:00:00",
+ ),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+ signature.apply_async.assert_called_once_with()
+
+
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_timeseries_delete(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "celery"}
+ )
+ TaskService().delete_timeseries(repository_id=12345)
+ mock_route_task.assert_called_with(
+ celery_config.timeseries_delete_task_name,
+ args=None,
+ kwargs=dict(repository_id=12345),
+ )
+ signature_mock.assert_called_with(
+ celery_config.timeseries_delete_task_name,
+ args=None,
+ kwargs=dict(repository_id=12345),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+
+
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_flush_repo(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "celery"}
+ )
+ TaskService().flush_repo(repository_id=12345)
+ mock_route_task.assert_called_with(
+ "app.tasks.flush_repo.FlushRepo",
+ args=None,
+ kwargs=dict(repoid=12345),
+ )
+ signature_mock.assert_called_with(
+ "app.tasks.flush_repo.FlushRepo",
+ args=None,
+ kwargs=dict(repoid=12345),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+
+
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_update_commit_task(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task",
+ return_value={
+ "queue": "celery",
+ "extra_config": {"soft_timelimit": 300, "hard_timelimit": 400},
+ },
+ )
+ TaskService().update_commit(1, 2)
+ mock_route_task.assert_called_with(
+ celery_config.commit_update_task_name,
+ args=None,
+ kwargs=dict(commitid=1, repoid=2),
+ )
+ signature_mock.assert_called_with(
+ celery_config.commit_update_task_name,
+ args=None,
+ kwargs=dict(commitid=1, repoid=2),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=300,
+ time_limit=400,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
+
+
+@freeze_time("2023-06-13T10:01:01.000123")
+def test_make_http_request_task(mocker):
+ signature_mock = mocker.patch("services.task.task.signature")
+ mock_route_task = mocker.patch(
+ "services.task.task.route_task", return_value={"queue": "celery"}
+ )
+ TaskService().http_request(
+ url="http://example.com",
+ method="POST",
+ headers={"Content-Type": "text/plain"},
+ data="test body",
+ timeout=10,
+ )
+ mock_route_task.assert_called_with(
+ "app.tasks.http_request.HTTPRequest",
+ args=None,
+ kwargs=dict(
+ url="http://example.com",
+ method="POST",
+ headers={"Content-Type": "text/plain"},
+ data="test body",
+ timeout=10,
+ ),
+ )
+ signature_mock.assert_called_with(
+ "app.tasks.http_request.HTTPRequest",
+ args=None,
+ kwargs=dict(
+ url="http://example.com",
+ method="POST",
+ headers={"Content-Type": "text/plain"},
+ data="test body",
+ timeout=10,
+ ),
+ app=celery_app,
+ queue="celery",
+ soft_time_limit=None,
+ time_limit=None,
+ headers=dict(created_timestamp="2023-06-13T10:01:01.000123"),
+ immutable=False,
+ )
diff --git a/apps/codecov-api/services/tests/test_task_router.py b/apps/codecov-api/services/tests/test_task_router.py
new file mode 100644
index 0000000000..df4accab36
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_task_router.py
@@ -0,0 +1,200 @@
+import pytest
+import shared.celery_config as shared_celery_config
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName
+
+from compare.tests.factories import CommitComparisonFactory
+from labelanalysis.tests.factories import LabelAnalysisRequestFactory
+from services.task.task_router import (
+ _get_user_plan_from_comparison_id,
+ _get_user_plan_from_label_request_id,
+ _get_user_plan_from_ownerid,
+ _get_user_plan_from_repoid,
+ _get_user_plan_from_suite_id,
+ _get_user_plan_from_task,
+ route_task,
+)
+from staticanalysis.tests.factories import StaticAnalysisSuiteFactory
+
+
+@pytest.fixture
+def fake_owners(db):
+ owner = OwnerFactory.create(plan=PlanName.CODECOV_PRO_MONTHLY.value)
+ owner_enterprise_cloud = OwnerFactory.create(
+ plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ owner.save()
+ owner_enterprise_cloud.save()
+ return (owner, owner_enterprise_cloud)
+
+
+@pytest.fixture
+def fake_repos(db, fake_owners):
+ (owner, owner_enterprise_cloud) = fake_owners
+
+ repo = RepositoryFactory(author=owner)
+ repo_enterprise = RepositoryFactory(author=owner_enterprise_cloud)
+ repo.save()
+ repo_enterprise.save()
+ return (repo, repo_enterprise)
+
+
+@pytest.fixture
+def fake_compare_commit(db, fake_repos):
+ (repo, repo_enterprise) = fake_repos
+ compare_commit = CommitComparisonFactory(compare_commit__repository=repo)
+ compare_commit_enterprise = CommitComparisonFactory(
+ compare_commit__repository=repo_enterprise
+ )
+ compare_commit.save()
+ compare_commit_enterprise.save()
+ return (compare_commit, compare_commit_enterprise)
+
+
+@pytest.fixture
+def fake_label_analysis_request(db, fake_repos):
+ (repo, repo_enterprise_cloud) = fake_repos
+ label_analysis_request = LabelAnalysisRequestFactory(head_commit__repository=repo)
+ label_analysis_request_enterprise = LabelAnalysisRequestFactory(
+ head_commit__repository=repo_enterprise_cloud
+ )
+ label_analysis_request.save()
+ label_analysis_request_enterprise.save()
+ return (label_analysis_request, label_analysis_request_enterprise)
+
+
+@pytest.fixture
+def fake_static_analysis_suite(db, fake_repos):
+ (repo, repo_enterprise_cloud) = fake_repos
+ static_analysis_suite = StaticAnalysisSuiteFactory(commit__repository=repo)
+ static_analysis_suite_enterprise = StaticAnalysisSuiteFactory(
+ commit__repository=repo_enterprise_cloud
+ )
+ static_analysis_suite.save()
+ static_analysis_suite_enterprise.save()
+ return (static_analysis_suite, static_analysis_suite_enterprise)
+
+
+def test_get_owner_plan_from_ownerid(fake_owners):
+ (owner, owner_enterprise_cloud) = fake_owners
+ assert (
+ _get_user_plan_from_ownerid(owner.ownerid) == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+ assert (
+ _get_user_plan_from_ownerid(owner_enterprise_cloud.ownerid)
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ assert _get_user_plan_from_ownerid(10000000) == DEFAULT_FREE_PLAN
+
+
+def test_get_owner_plan_from_repoid(fake_repos):
+ (repo, repo_enterprise) = fake_repos
+ assert _get_user_plan_from_repoid(repo.repoid) == PlanName.CODECOV_PRO_MONTHLY.value
+ assert (
+ _get_user_plan_from_repoid(repo_enterprise.repoid)
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ assert _get_user_plan_from_repoid(10000000) == DEFAULT_FREE_PLAN
+
+
+def test_get_user_plan_from_comparison_id(fake_compare_commit):
+ (compare_commit, compare_commit_enterprise) = fake_compare_commit
+ assert (
+ _get_user_plan_from_comparison_id(compare_commit.id)
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+ assert (
+ _get_user_plan_from_comparison_id(compare_commit_enterprise.id)
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ assert _get_user_plan_from_comparison_id(10000000) == DEFAULT_FREE_PLAN
+
+
+def test_get_user_plan_from_label_request_id(fake_label_analysis_request):
+ (
+ label_analysis_request,
+ label_analysis_request_enterprise,
+ ) = fake_label_analysis_request
+ assert (
+ _get_user_plan_from_label_request_id(request_id=label_analysis_request.id)
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+ assert (
+ _get_user_plan_from_label_request_id(
+ request_id=label_analysis_request_enterprise.id
+ )
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ assert _get_user_plan_from_label_request_id(10000000) == DEFAULT_FREE_PLAN
+
+
+def test_get_user_plan_from_static_analysis_suite(fake_static_analysis_suite):
+ (
+ static_analysis_suite,
+ static_analysis_suite_enterprise,
+ ) = fake_static_analysis_suite
+ assert (
+ _get_user_plan_from_suite_id(suite_id=static_analysis_suite.id)
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+ assert (
+ _get_user_plan_from_suite_id(suite_id=static_analysis_suite_enterprise.id)
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+ assert _get_user_plan_from_suite_id(10000000) == DEFAULT_FREE_PLAN
+
+
+def test_get_user_plan_from_task(
+ fake_repos,
+ fake_compare_commit,
+):
+ (repo, repo_enterprise_cloud) = fake_repos
+ compare_commit = fake_compare_commit[0]
+ task_kwargs = dict(repoid=repo.repoid, commitid=0, debug=False, rebuild=False)
+ assert (
+ _get_user_plan_from_task(shared_celery_config.upload_task_name, task_kwargs)
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+
+ task_kwargs = dict(
+ repoid=repo_enterprise_cloud.repoid, commitid=0, debug=False, rebuild=False
+ )
+ assert (
+ _get_user_plan_from_task(shared_celery_config.upload_task_name, task_kwargs)
+ == PlanName.ENTERPRISE_CLOUD_YEARLY.value
+ )
+
+ task_kwargs = dict(ownerid=repo.author.ownerid)
+ assert (
+ _get_user_plan_from_task(
+ shared_celery_config.delete_owner_task_name, task_kwargs
+ )
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+
+ task_kwargs = dict(comparison_id=compare_commit.id)
+ assert (
+ _get_user_plan_from_task(
+ shared_celery_config.compute_comparison_task_name, task_kwargs
+ )
+ == PlanName.CODECOV_PRO_MONTHLY.value
+ )
+
+ task_kwargs = dict(
+ repoid=repo_enterprise_cloud.repoid, commitid=0, debug=False, rebuild=False
+ )
+ assert _get_user_plan_from_task("unknown task", task_kwargs) == DEFAULT_FREE_PLAN
+
+
+def test_route_task(mocker, fake_repos):
+ mock_route_tasks_shared = mocker.patch(
+ "services.task.task_router.route_tasks_based_on_user_plan"
+ )
+ mock_route_tasks_shared.return_value = {"queue": "correct queue"}
+ repo = fake_repos[0]
+ task_kwargs = dict(repoid=repo.repoid, commitid=0, debug=False, rebuild=False)
+ response = route_task(shared_celery_config.upload_task_name, [], task_kwargs, {})
+ assert response == {"queue": "correct queue"}
+ mock_route_tasks_shared.assert_called_with(
+ shared_celery_config.upload_task_name, PlanName.CODECOV_PRO_MONTHLY.value
+ )
diff --git a/apps/codecov-api/services/tests/test_yaml.py b/apps/codecov-api/services/tests/test_yaml.py
new file mode 100644
index 0000000000..4bdf184cd5
--- /dev/null
+++ b/apps/codecov-api/services/tests/test_yaml.py
@@ -0,0 +1,73 @@
+from unittest.mock import patch
+
+import pytest
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.torngit.exceptions import TorngitObjectNotFoundError
+
+import services.yaml as yaml
+
+
+class YamlServiceTest(TestCase):
+ def setUp(self):
+ self.org = OwnerFactory()
+ self.repo = RepositoryFactory(author=self.org, private=False)
+ self.commit = CommitFactory(repository=self.repo)
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ def test_when_commit_has_yaml(self, mock_fetch_yaml):
+ mock_fetch_yaml.return_value = """
+ codecov:
+ notify:
+ require_ci_to_pass: no
+ """
+ config = yaml.final_commit_yaml(self.commit, None)
+ assert config["codecov"]["require_ci_to_pass"] is False
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ def test_when_commit_has_no_yaml(self, mock_fetch_yaml):
+ mock_fetch_yaml.side_effect = TorngitObjectNotFoundError(
+ response_data=404, message="not found"
+ )
+ config = yaml.final_commit_yaml(self.commit, None)
+ assert config["codecov"]["require_ci_to_pass"] is True
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ def test_when_commit_has_yaml_with_wrongly_typed_owner_arg(self, mock_fetch_yaml):
+ mock_fetch_yaml.return_value = """
+ codecov:
+ notify:
+ require_ci_to_pass: no
+ """
+ with pytest.raises(TypeError) as exc_info:
+ yaml.final_commit_yaml(self.commit, "something else")
+ assert (
+ str(exc_info.value)
+ == "fetch_commit_yaml owner arg must be Owner or None. Provided: "
+ )
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ def test_when_commit_has_yaml_with_owner(self, mock_fetch_yaml):
+ mock_fetch_yaml.return_value = """
+ codecov:
+ notify:
+ require_ci_to_pass: no
+ """
+ config = yaml.final_commit_yaml(self.commit, self.org)
+ assert config["codecov"]["require_ci_to_pass"] is False
+
+ @patch("services.yaml.fetch_current_yaml_from_provider_via_reference")
+ def test_when_commit_has_reserved_to_string_key(self, mock_fetch_yaml):
+ mock_fetch_yaml.return_value = """
+ codecov:
+ notify:
+ require_ci_to_pass: no
+ to_string: hello
+ """
+ config = yaml.final_commit_yaml(self.commit, self.org)
+ assert config.get("to_string") is None
+ assert "to_string" not in config.to_dict()
diff --git a/apps/codecov-api/services/yaml.py b/apps/codecov-api/services/yaml.py
new file mode 100644
index 0000000000..1bb458444c
--- /dev/null
+++ b/apps/codecov-api/services/yaml.py
@@ -0,0 +1,77 @@
+import enum
+import logging
+from functools import lru_cache
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.helpers.cache import cache
+from shared.yaml import UserYaml, fetch_current_yaml_from_provider_via_reference
+from shared.yaml.validation import validate_yaml
+from yaml import safe_load
+
+from codecov_auth.models import Owner, get_config
+from core.models import Commit
+from services.repo_providers import RepoProviderService
+
+
+class YamlStates(enum.Enum):
+ DEFAULT = "default"
+
+
+log = logging.getLogger(__name__)
+
+
+@cache.cache_function(ttl=60 * 60)
+def fetch_commit_yaml(commit: Commit, owner: Owner | None) -> dict | None:
+ """
+ Fetches the codecov.yaml file for a particular commit from the service provider.
+ Service provider API request is made on behalf of the given `owner`.
+ """
+
+ # There's been a lot of instances where this function is called where the owner arg is not Owner type
+ # Previously this issue is masked by the catch all exception handling. Most badly typed calls have
+ # been addressed, but there might still be some entrypoints unaccounted for, will fix new discoveries
+ # from this assertion being raised.
+ if owner is not None and not isinstance(owner, Owner):
+ raise TypeError(
+ f"fetch_commit_yaml owner arg must be Owner or None. Provided: {type(owner)}"
+ )
+
+ try:
+ repository_service = RepoProviderService().get_adapter(
+ owner=owner, repo=commit.repository
+ )
+ yaml_str = async_to_sync(fetch_current_yaml_from_provider_via_reference)(
+ commit.commitid, repository_service
+ )
+ yaml_dict = safe_load(yaml_str)
+ return validate_yaml(yaml_dict, show_secrets_for=None)
+ except Exception as e:
+ # fetching, parsing, validating the yaml inside the commit can
+ # have various exceptions, which we do not care about to get the final
+ # yaml used for a commit, as any error here, the codecov.yaml would not
+ # be used, so we return None here
+
+ log.warning(
+ f"Was not able to fetch yaml file for commit. Ignoring error and returning None. Exception: {e}",
+ extra={
+ "commit_id": commit.commitid,
+ "owner_arg": type(owner),
+ },
+ )
+ return None
+
+
+@lru_cache()
+@sentry_sdk.trace
+def final_commit_yaml(commit: Commit, owner: Owner | None) -> UserYaml:
+ return UserYaml.get_final_yaml(
+ owner_yaml=commit.repository.author.yaml,
+ repo_yaml=commit.repository.yaml,
+ commit_yaml=fetch_commit_yaml(commit, owner),
+ )
+
+
+def get_yaml_state(yaml: UserYaml) -> YamlStates | None:
+ if yaml == get_config("site", default={}):
+ return YamlStates.DEFAULT
diff --git a/apps/codecov-api/staging.sh b/apps/codecov-api/staging.sh
new file mode 100644
index 0000000000..cef17032e6
--- /dev/null
+++ b/apps/codecov-api/staging.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# starts the development server using gunicorn
+# NEVER run production with the --reload option command
+echo "Starting gunicorn in dev mode"
+export PYTHONWARNINGS=always
+prefix=""
+if [ -f "/usr/local/bin/berglas" ]; then
+ prefix="berglas exec --"
+fi
+
+if [ "$GUNICORN_WORKERS" -gt 1 ];
+then
+ export PROMETHEUS_MULTIPROC_DIR="${PROMETHEUS_MULTIPROC_DIR:-$HOME/.prometheus}"
+ rm -r ${PROMETHEUS_MULTIPROC_DIR?}/* 2> /dev/null
+ mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
+fi
+
+$prefix gunicorn codecov.wsgi:application --reload --workers=${GUNICORN_WORKERS:-2} --threads=${GUNICORN_THREADS:-1} --worker-connections=${GUNICORN_WORKER_CONNECTIONS:-1000} --bind 0.0.0.0:8000 --access-logfile '-' --timeout "${GUNICORN_TIMEOUT:-600}" --disable-redirect-access-to-syslog --config=gunicorn.conf.py
diff --git a/apps/codecov-api/staticanalysis/__init__.py b/apps/codecov-api/staticanalysis/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/staticanalysis/admin.py b/apps/codecov-api/staticanalysis/admin.py
new file mode 100644
index 0000000000..846f6b4061
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/admin.py
@@ -0,0 +1 @@
+# Register your models here.
diff --git a/apps/codecov-api/staticanalysis/apps.py b/apps/codecov-api/staticanalysis/apps.py
new file mode 100644
index 0000000000..338c1f7f04
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class StaticanalysisConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "staticanalysis"
diff --git a/apps/codecov-api/staticanalysis/models.py b/apps/codecov-api/staticanalysis/models.py
new file mode 100644
index 0000000000..2fc17ec3bb
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/models.py
@@ -0,0 +1 @@
+from shared.django_apps.staticanalysis.models import *
diff --git a/apps/codecov-api/staticanalysis/serializers.py b/apps/codecov-api/staticanalysis/serializers.py
new file mode 100644
index 0000000000..d7056ebaee
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/serializers.py
@@ -0,0 +1,156 @@
+import logging
+import math
+
+from rest_framework import exceptions, serializers
+from shared.api_archive.archive import ArchiveService, MinioEndpoints
+
+from core.models import Commit
+from staticanalysis.models import (
+ StaticAnalysisSingleFileSnapshot,
+ StaticAnalysisSingleFileSnapshotState,
+ StaticAnalysisSuite,
+ StaticAnalysisSuiteFilepath,
+)
+
+log = logging.getLogger(__name__)
+
+
+class CommitFromShaSerializerField(serializers.Field):
+ def to_representation(self, commit):
+ return commit.commitid
+
+ def to_internal_value(self, commit_sha):
+ # TODO: Change this query when we change how we fetch URLs
+ commit = Commit.objects.filter(
+ repository__in=self.context["request"].auth.get_repositories(),
+ commitid=commit_sha,
+ ).first()
+ if commit is None:
+ raise exceptions.NotFound("Commit not found.")
+ return commit
+
+
+def _dict_to_suite_filepath(
+ analysis_suite,
+ repository,
+ archive_service,
+ existing_file_snapshots_mapping,
+ file_dict,
+):
+ if file_dict["file_hash"] in existing_file_snapshots_mapping:
+ db_element = existing_file_snapshots_mapping[file_dict["file_hash"]]
+ was_created = False
+ else:
+ path = MinioEndpoints.static_analysis_single_file.get_path(
+ version="v4",
+ repo_hash=archive_service.storage_hash,
+ location=f"{file_dict['file_hash']}.json",
+ )
+ # Using get or create in the case the object was already
+ # created somewhere else first, but also because get_or_create
+ # is internally get_or_create_or_get, so Django handles the conflicts
+ # that can arise on race conditions on the create step
+ # We might choose to change it if the number of extra GETs become too much
+ (
+ db_element,
+ was_created,
+ ) = StaticAnalysisSingleFileSnapshot.objects.get_or_create(
+ file_hash=file_dict["file_hash"],
+ repository=repository,
+ defaults=dict(
+ state_id=StaticAnalysisSingleFileSnapshotState.CREATED.db_id,
+ content_location=path,
+ ),
+ )
+ if was_created:
+ log.debug(
+ "Created new snapshot for repository",
+ extra=dict(repoid=repository.repoid, snapshot_id=db_element.id),
+ )
+ return StaticAnalysisSuiteFilepath(
+ filepath=file_dict["filepath"],
+ file_snapshot=db_element,
+ analysis_suite=analysis_suite,
+ )
+
+
+class StaticAnalysisSuiteFilepathField(serializers.ModelSerializer):
+ file_hash = serializers.UUIDField()
+ raw_upload_location = serializers.SerializerMethodField()
+ state = serializers.SerializerMethodField()
+
+ class Meta:
+ model = StaticAnalysisSuiteFilepath
+ fields = [
+ "filepath",
+ "file_hash",
+ "raw_upload_location",
+ "state",
+ ]
+
+ def get_state(self, obj):
+ return StaticAnalysisSingleFileSnapshotState.enum_from_int(
+ obj.file_snapshot.state_id
+ ).name
+
+ def get_raw_upload_location(self, obj):
+ # TODO: This has a built-in ttl of 10 seconds.
+ # We have to consider changing it in case customers are doing a few
+ # thousand uploads on the first time
+ return self.context["archive_service"].create_presigned_put(
+ obj.file_snapshot.content_location
+ )
+
+
+class FilepathListField(serializers.ListField):
+ child = StaticAnalysisSuiteFilepathField()
+
+ def to_representation(self, data):
+ data = data.select_related(
+ "file_snapshot",
+ ).all()
+ return super().to_representation(data)
+
+
+class StaticAnalysisSuiteSerializer(serializers.ModelSerializer):
+ commit = CommitFromShaSerializerField(required=True)
+ filepaths = FilepathListField()
+
+ class Meta:
+ model = StaticAnalysisSuite
+ fields = ["external_id", "commit", "filepaths"]
+ read_only_fields = ["raw_upload_location", "external_id"]
+
+ def create(self, validated_data):
+ file_metadata_array = validated_data.pop("filepaths")
+ # `validated_data` only contains `commit` after pop
+ obj = StaticAnalysisSuite.objects.create(**validated_data)
+ request = self.context["request"]
+ repository = request.auth.get_repositories()[0]
+ archive_service = ArchiveService(repository)
+ # allow 1s per 10 uploads
+ ttl = max(math.ceil(len(file_metadata_array) / 10) + 5, 10)
+ self.context["archive_service"] = ArchiveService(repository, ttl=ttl)
+ all_hashes = [val["file_hash"] for val in file_metadata_array]
+ existing_values = StaticAnalysisSingleFileSnapshot.objects.filter(
+ repository=repository, file_hash__in=all_hashes
+ )
+ existing_values_mapping = {val.file_hash: val for val in existing_values}
+ created_filepaths = [
+ _dict_to_suite_filepath(
+ obj,
+ repository,
+ archive_service,
+ existing_values_mapping,
+ file_dict,
+ )
+ for file_dict in file_metadata_array
+ ]
+ StaticAnalysisSuiteFilepath.objects.bulk_create(created_filepaths)
+ log.info(
+ "Created static analysis filepaths",
+ extra=dict(
+ created_ids=[f.id for f in created_filepaths], repoid=repository.repoid
+ ),
+ )
+ return obj
diff --git a/apps/codecov-api/staticanalysis/tests.py b/apps/codecov-api/staticanalysis/tests.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/staticanalysis/tests/__init__.py b/apps/codecov-api/staticanalysis/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/staticanalysis/tests/factories.py b/apps/codecov-api/staticanalysis/tests/factories.py
new file mode 100644
index 0000000000..b1ebc3645b
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/tests/factories.py
@@ -0,0 +1 @@
+from shared.django_apps.staticanalysis.tests.factories import *
diff --git a/apps/codecov-api/staticanalysis/tests/test_views.py b/apps/codecov-api/staticanalysis/tests/test_views.py
new file mode 100644
index 0000000000..7e04738df1
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/tests/test_views.py
@@ -0,0 +1,101 @@
+from uuid import uuid4
+
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.celery_config import static_analysis_task_name
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ RepositoryTokenFactory,
+)
+
+from services.task import TaskService
+from staticanalysis.models import StaticAnalysisSuite
+from staticanalysis.tests.factories import StaticAnalysisSuiteFactory
+
+
+def test_simple_static_analysis_call_no_uploads_yet(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ mocked_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="banana.txt",
+ )
+ commit = CommitFactory.create(repository__active=True)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ url = reverse("staticanalyses-list")
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ some_uuid, second_uuid = uuid4(), uuid4()
+ response = client.post(
+ url,
+ {
+ "commit": commit.commitid,
+ "filepaths": [
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": some_uuid,
+ },
+ {
+ "filepath": "banana.cpp",
+ "file_hash": second_uuid,
+ },
+ ],
+ },
+ format="json",
+ )
+ assert response.status_code == 201
+ assert StaticAnalysisSuite.objects.filter(commit=commit).count() == 1
+ produced_object = StaticAnalysisSuite.objects.filter(commit=commit).get()
+ response_json = response.json()
+ assert "filepaths" in response_json
+ # Popping and sorting because the order doesn't matter, as long as all are there
+ assert sorted(response_json.pop("filepaths"), key=lambda x: x["filepath"]) == [
+ {
+ "filepath": "banana.cpp",
+ "file_hash": str(second_uuid),
+ "raw_upload_location": "banana.txt",
+ "state": "CREATED",
+ },
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": str(some_uuid),
+ "raw_upload_location": "banana.txt",
+ "state": "CREATED",
+ },
+ ]
+ # Now asserting the remaining of the response
+ assert response_json == {
+ "external_id": str(produced_object.external_id),
+ "commit": commit.commitid,
+ }
+ mocked_task_service.assert_called_with(
+ static_analysis_task_name,
+ kwargs={"suite_id": produced_object.id},
+ apply_async_kwargs={"countdown": 10},
+ )
+ mocked_presigned_put.assert_called_with(
+ "archive",
+ mocker.ANY,
+ 10,
+ )
+
+
+def test_static_analysis_finish(db, mocker):
+ mocked_task_service = mocker.patch.object(TaskService, "schedule_task")
+ commit = CommitFactory.create(repository__active=True)
+ suite = StaticAnalysisSuiteFactory(commit=commit)
+ token = RepositoryTokenFactory.create(
+ repository=commit.repository, token_type="static_analysis"
+ )
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="repotoken " + token.key)
+ response = client.post(
+ reverse("staticanalyses-finish", kwargs={"external_id": suite.external_id})
+ )
+ assert response.status_code == 204
+ mocked_task_service.assert_called_with(
+ static_analysis_task_name,
+ kwargs={"suite_id": suite.id},
+ apply_async_kwargs={},
+ )
diff --git a/apps/codecov-api/staticanalysis/tests/unit/__init__.py b/apps/codecov-api/staticanalysis/tests/unit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/staticanalysis/tests/unit/test_serializers.py b/apps/codecov-api/staticanalysis/tests/unit/test_serializers.py
new file mode 100644
index 0000000000..4676ec8a89
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/tests/unit/test_serializers.py
@@ -0,0 +1,331 @@
+import re
+from uuid import UUID, uuid4
+
+import pytest
+from rest_framework.exceptions import NotFound, ValidationError
+from shared.api_archive.archive import ArchiveService
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from staticanalysis.models import (
+ StaticAnalysisSingleFileSnapshotState,
+ StaticAnalysisSuite,
+ StaticAnalysisSuiteFilepath,
+)
+from staticanalysis.serializers import (
+ CommitFromShaSerializerField,
+ StaticAnalysisSuiteFilepathField,
+ StaticAnalysisSuiteSerializer,
+)
+from staticanalysis.tests.factories import (
+ StaticAnalysisSingleFileSnapshotFactory,
+ StaticAnalysisSuiteFilepathFactory,
+)
+
+expected_location_regex = re.compile(
+ "v4/repos/[A-F0-9]{32}/static_analysis/files/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}.json"
+)
+
+
+def test_commit_from_sha_serializer_field_to_internal_value(mocker, db):
+ commit = CommitFactory.create()
+ # notice this second commit has a different repo by default
+ second_commit = CommitFactory.create()
+ commit.save()
+ second_commit.save()
+ fake_request = mocker.MagicMock(
+ auth=mocker.MagicMock(
+ get_repositories=mocker.MagicMock(return_value=[commit.repository])
+ )
+ )
+ # silly workaround to not have to manually bind serializers
+ mocker.patch.object(
+ CommitFromShaSerializerField, "context", {"request": fake_request}
+ )
+ serializer_field = CommitFromShaSerializerField()
+ with pytest.raises(NotFound):
+ assert serializer_field.to_internal_value("abcde" * 8)
+ with pytest.raises(NotFound):
+ assert serializer_field.to_internal_value(second_commit.commitid)
+ assert serializer_field.to_internal_value(commit.commitid) == commit
+
+
+def test_filepath_field(db, mocker):
+ sasfs = StaticAnalysisSingleFileSnapshotFactory.create(
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id
+ )
+ sasfs.save()
+ fp = StaticAnalysisSuiteFilepathFactory.create(
+ filepath="ohoooo", file_snapshot=sasfs
+ )
+ fp.analysis_suite.save()
+ fp.save()
+ fake_archive_service = mocker.MagicMock(
+ create_presigned_put=mocker.MagicMock(return_value="some_url_stuff")
+ )
+ serializer_field = StaticAnalysisSuiteFilepathField(
+ context={
+ "archive_service": fake_archive_service,
+ }
+ )
+ assert dict(serializer_field.to_representation(fp)) == {
+ "file_hash": sasfs.file_hash,
+ "filepath": "ohoooo",
+ "raw_upload_location": "some_url_stuff",
+ "state": "VALID",
+ }
+
+
+class TestStaticAnalysisSuiteSerializer(object):
+ def test_to_internal_value_missing_filepaths(self, mocker, db):
+ commit = CommitFactory.create()
+ commit.save()
+ input_data = {"commit": commit.commitid}
+ fake_request = mocker.MagicMock(
+ auth=mocker.MagicMock(
+ get_repositories=mocker.MagicMock(return_value=[commit.repository])
+ )
+ )
+ serializer = StaticAnalysisSuiteSerializer(context={"request": fake_request})
+ with pytest.raises(ValidationError) as exc:
+ serializer.to_internal_value(input_data)
+ assert exc.value.detail == {"filepaths": ["This field is required."]}
+
+ def test_to_internal_value_complete(self, mocker, db):
+ commit = CommitFactory.create()
+ commit.save()
+ input_data = {
+ "commit": commit.commitid,
+ "filepaths": [
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": "c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2",
+ },
+ {
+ "filepath": "anothersomething",
+ "file_hash": "3998813e-60db-4686-be84-1a0efa7d9b9f",
+ },
+ ],
+ }
+ fake_request = mocker.MagicMock(
+ auth=mocker.MagicMock(
+ get_repositories=mocker.MagicMock(return_value=[commit.repository])
+ )
+ )
+ serializer = StaticAnalysisSuiteSerializer(context={"request": fake_request})
+ res = serializer.to_internal_value(input_data)
+ assert res == {
+ "commit": commit,
+ "filepaths": [
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": UUID("c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"),
+ },
+ {
+ "filepath": "anothersomething",
+ "file_hash": UUID("3998813e-60db-4686-be84-1a0efa7d9b9f"),
+ },
+ ],
+ }
+
+ def test_create_no_data_previously_exists(self, mocker, db):
+ first_repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=first_repository)
+ commit.save()
+ validated_data = {
+ "commit": commit,
+ "filepaths": [
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": UUID("c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"),
+ },
+ {
+ "filepath": "anothersomething",
+ "file_hash": UUID("3998813e-60db-4686-be84-1a0efa7d9b9f"),
+ },
+ ],
+ }
+ fake_request = mocker.MagicMock(
+ auth=mocker.MagicMock(
+ get_repositories=mocker.MagicMock(return_value=[commit.repository])
+ )
+ )
+ serializer = StaticAnalysisSuiteSerializer(context={"request": fake_request})
+ res = serializer.create(validated_data)
+ assert isinstance(res, StaticAnalysisSuite)
+ assert res.commit == commit
+ assert res.filepaths.count() == 2
+ first_filepath, second_filepath = sorted(
+ res.filepaths.all(), key=lambda x: x.filepath
+ )
+ assert isinstance(first_filepath, StaticAnalysisSuiteFilepath)
+ assert first_filepath.filepath == "anothersomething"
+ assert first_filepath.file_snapshot is not None
+ archive_hash = ArchiveService.get_archive_hash(commit.repository)
+ assert first_filepath.file_snapshot.repository == commit.repository
+ assert first_filepath.file_snapshot.file_hash == UUID(
+ "3998813e-60db-4686-be84-1a0efa7d9b9f"
+ )
+ assert archive_hash in first_filepath.file_snapshot.content_location
+ assert (
+ expected_location_regex.match(first_filepath.file_snapshot.content_location)
+ is not None
+ )
+ assert (
+ first_filepath.file_snapshot.state_id
+ == StaticAnalysisSingleFileSnapshotState.CREATED.db_id
+ )
+
+ def test_create_some_data_previously_exists(self, mocker, db):
+ first_repository = RepositoryFactory.create()
+ second_repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=first_repository)
+ first_repo_first_snapshot = StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=UUID("c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"),
+ repository=first_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id,
+ content_location="first_repo_first_snapshot",
+ )
+ second_repo_first_snapshot = StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=UUID("c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"),
+ repository=second_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id,
+ content_location="second_repo_first_snapshot",
+ )
+ second_repo_second_snapshot = StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=UUID("3998813e-60db-4686-be84-1a0efa7d9b9f"),
+ repository=second_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id,
+ content_location="second_repo_second_snapshot",
+ )
+ first_repo_separate_snapshot = StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=uuid4(),
+ repository=first_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id,
+ content_location="first_repo_separate_snapshot",
+ )
+ second_repo_separate_snapshot = StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=uuid4(),
+ repository=second_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.VALID.db_id,
+ content_location="second_repo_separate_snapshot",
+ )
+ first_repo_exists_but_not_valid_yet = (
+ StaticAnalysisSingleFileSnapshotFactory.create(
+ file_hash=UUID("31803149-8bd7-4c2b-9a80-71f259360c72"),
+ repository=first_repository,
+ state_id=StaticAnalysisSingleFileSnapshotState.CREATED.db_id,
+ content_location="first_repo_exists_but_not_valid_yet",
+ )
+ )
+ first_repo_first_snapshot.save()
+ second_repo_first_snapshot.save()
+ second_repo_second_snapshot.save()
+ first_repo_separate_snapshot.save()
+ second_repo_separate_snapshot.save()
+ first_repo_exists_but_not_valid_yet.save()
+
+ validated_data = {
+ "commit": commit,
+ "filepaths": [
+ {
+ "filepath": "path/to/a.py",
+ "file_hash": UUID("c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"),
+ },
+ {
+ "filepath": "anothersomething",
+ "file_hash": UUID("3998813e-60db-4686-be84-1a0efa7d9b9f"),
+ },
+ {
+ "filepath": "oooaaa.rb",
+ "file_hash": UUID("60228df6-4d11-44d4-a048-ec2fa1ea2c32"),
+ },
+ {
+ "filepath": "awert.qt",
+ "file_hash": UUID("31803149-8bd7-4c2b-9a80-71f259360c72"),
+ },
+ ],
+ }
+ fake_request = mocker.MagicMock(
+ auth=mocker.MagicMock(
+ get_repositories=mocker.MagicMock(return_value=[commit.repository])
+ )
+ )
+ serializer = StaticAnalysisSuiteSerializer(context={"request": fake_request})
+ res = serializer.create(validated_data)
+ assert isinstance(res, StaticAnalysisSuite)
+ assert res.commit == commit
+ assert res.filepaths.count() == 4
+ first_filepath, second_filepath, third_filepath, fourth_filepath = sorted(
+ res.filepaths.all(), key=lambda x: x.filepath
+ )
+ archive_hash = ArchiveService.get_archive_hash(first_repository)
+ # first one
+ assert isinstance(first_filepath, StaticAnalysisSuiteFilepath)
+ assert first_filepath.filepath == "anothersomething"
+ assert first_filepath.file_snapshot is not None
+ assert first_filepath.file_snapshot.repository == first_repository
+ assert first_filepath.file_snapshot.file_hash == UUID(
+ "3998813e-60db-4686-be84-1a0efa7d9b9f"
+ )
+ assert archive_hash in first_filepath.file_snapshot.content_location
+ assert (
+ expected_location_regex.match(first_filepath.file_snapshot.content_location)
+ is not None
+ )
+ assert (
+ first_filepath.file_snapshot.state_id
+ == StaticAnalysisSingleFileSnapshotState.CREATED.db_id
+ )
+ # second one
+ assert isinstance(second_filepath, StaticAnalysisSuiteFilepath)
+ assert second_filepath.filepath == "awert.qt"
+ assert second_filepath.file_snapshot == first_repo_exists_but_not_valid_yet
+ assert second_filepath.file_snapshot.repository == first_repository
+ assert second_filepath.file_snapshot.file_hash == UUID(
+ "31803149-8bd7-4c2b-9a80-71f259360c72"
+ )
+ # content location was already there, so nothing is created
+ # asserting the old value is still there
+ assert (
+ second_filepath.file_snapshot.content_location
+ == "first_repo_exists_but_not_valid_yet"
+ )
+ assert (
+ second_filepath.file_snapshot.state_id
+ == StaticAnalysisSingleFileSnapshotState.CREATED.db_id
+ )
+ # third one
+ assert isinstance(third_filepath, StaticAnalysisSuiteFilepath)
+ assert third_filepath.filepath == "oooaaa.rb"
+ assert third_filepath.file_snapshot is not None
+ assert third_filepath.file_snapshot.repository == first_repository
+ assert third_filepath.file_snapshot.file_hash == UUID(
+ "60228df6-4d11-44d4-a048-ec2fa1ea2c32"
+ )
+ assert archive_hash in third_filepath.file_snapshot.content_location
+ assert (
+ expected_location_regex.match(third_filepath.file_snapshot.content_location)
+ is not None
+ )
+ assert (
+ third_filepath.file_snapshot.state_id
+ == StaticAnalysisSingleFileSnapshotState.CREATED.db_id
+ )
+ # fourth one
+ assert isinstance(fourth_filepath, StaticAnalysisSuiteFilepath)
+ assert fourth_filepath.filepath == "path/to/a.py"
+ assert fourth_filepath.file_snapshot == first_repo_first_snapshot
+ assert fourth_filepath.file_snapshot.repository == first_repository
+ assert fourth_filepath.file_snapshot.file_hash == UUID(
+ "c8c23bea-c383-4abf-8a7e-6b9cadbeb5b2"
+ )
+ # content location was already there, so nothing is created
+ # asserting the old value is still there
+ assert (
+ fourth_filepath.file_snapshot.content_location
+ == "first_repo_first_snapshot"
+ )
+ assert (
+ fourth_filepath.file_snapshot.state_id
+ == StaticAnalysisSingleFileSnapshotState.VALID.db_id
+ )
diff --git a/apps/codecov-api/staticanalysis/urls.py b/apps/codecov-api/staticanalysis/urls.py
new file mode 100644
index 0000000000..6a2a5510b8
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/urls.py
@@ -0,0 +1,7 @@
+from staticanalysis.views import StaticAnalysisSuiteViewSet
+from utils.routers import OptionalTrailingSlashRouter
+
+router = OptionalTrailingSlashRouter()
+router.register("analyses", StaticAnalysisSuiteViewSet, basename="staticanalyses")
+
+urlpatterns = router.urls
diff --git a/apps/codecov-api/staticanalysis/views.py b/apps/codecov-api/staticanalysis/views.py
new file mode 100644
index 0000000000..7045e63020
--- /dev/null
+++ b/apps/codecov-api/staticanalysis/views.py
@@ -0,0 +1,46 @@
+import logging
+
+from django.http import HttpResponse
+from rest_framework import mixins, viewsets
+from rest_framework.decorators import action
+from shared.celery_config import static_analysis_task_name
+
+from codecov_auth.authentication.repo_auth import RepositoryTokenAuthentication
+from codecov_auth.permissions import SpecificScopePermission
+from services.task import TaskService
+from staticanalysis.models import StaticAnalysisSuite
+from staticanalysis.serializers import StaticAnalysisSuiteSerializer
+
+log = logging.getLogger(__name__)
+
+
+class StaticAnalysisSuiteViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
+ serializer_class = StaticAnalysisSuiteSerializer
+ authentication_classes = [RepositoryTokenAuthentication]
+ permission_classes = [SpecificScopePermission]
+ required_scopes = ["static_analysis"]
+ lookup_field = "external_id"
+
+ def get_queryset(self):
+ repository = self.request.auth.get_repositories()[0]
+ return StaticAnalysisSuite.objects.filter(commit__repository=repository)
+
+ def perform_create(self, serializer):
+ instance = serializer.save()
+ # TODO: remove this once the CLI is calling the `finish` endpoint
+ TaskService().schedule_task(
+ static_analysis_task_name,
+ kwargs=dict(suite_id=instance.id),
+ apply_async_kwargs=dict(countdown=10),
+ )
+ return instance
+
+ @action(detail=True, methods=["post"])
+ def finish(self, request, *args, **kwargs):
+ suite = self.get_object()
+ TaskService().schedule_task(
+ static_analysis_task_name,
+ kwargs=dict(suite_id=suite.pk),
+ apply_async_kwargs={},
+ )
+ return HttpResponse(status=204)
diff --git a/apps/codecov-api/templates/admin/base_site.html b/apps/codecov-api/templates/admin/base_site.html
new file mode 100644
index 0000000000..11c7f81767
--- /dev/null
+++ b/apps/codecov-api/templates/admin/base_site.html
@@ -0,0 +1,50 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrahead %}
+{{ block.super }}
+
+
+{% endblock %}
diff --git a/apps/codecov-api/timeseries/__init__.py b/apps/codecov-api/timeseries/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/timeseries/admin.py b/apps/codecov-api/timeseries/admin.py
new file mode 100644
index 0000000000..5e493dffd4
--- /dev/null
+++ b/apps/codecov-api/timeseries/admin.py
@@ -0,0 +1,79 @@
+from datetime import datetime
+
+import django.forms as forms
+from django.conf import settings
+from django.contrib import admin, messages
+from django.db.models import QuerySet
+from django.shortcuts import render
+
+from codecov.admin import AdminMixin
+from core.models import Repository
+from services.task import TaskService
+from timeseries.models import Dataset
+
+
+def enqueue_tasks(datasets: QuerySet, start_date: datetime, end_date: datetime):
+ count = datasets.update(backfilled=False)
+
+ for dataset in datasets:
+ TaskService().backfill_dataset(
+ dataset,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ return count
+
+
+class BackfillForm(forms.Form):
+ start_date = forms.DateTimeField(required=True)
+ end_date = forms.DateTimeField(required=True)
+
+
+class DatasetAdmin(AdminMixin, admin.ModelAdmin):
+ list_display = ("name", "repository", "backfilled")
+ actions = ["backfill"]
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+
+ # this prevents an N+1 query from the `repository` method below
+ repo_ids = list(queryset.values_list("repository_id", flat=True))
+ self._repositories = {
+ repository.pk: repository
+ for repository in Repository.objects.filter(pk__in=repo_ids)
+ }
+
+ return queryset
+
+ def repository(self, dataset):
+ return self._repositories[dataset.repository_id]
+
+ def backfill(self, request, queryset):
+ if "backfill" in request.POST:
+ form = BackfillForm(request.POST)
+ if form.is_valid():
+ count = enqueue_tasks(
+ queryset,
+ start_date=form.cleaned_data["start_date"],
+ end_date=form.cleaned_data["end_date"],
+ )
+ messages.success(
+ request, f"Enqueued backfill tasks for {count} datasets"
+ )
+ return
+ else:
+ form = BackfillForm()
+
+ return render(
+ request,
+ "admin/backfill.html",
+ context={
+ "form": form,
+ "datasets": queryset,
+ },
+ )
+
+
+if settings.TIMESERIES_ENABLED:
+ admin.site.register(Dataset, DatasetAdmin)
diff --git a/apps/codecov-api/timeseries/helpers.py b/apps/codecov-api/timeseries/helpers.py
new file mode 100644
index 0000000000..8ce735e2a8
--- /dev/null
+++ b/apps/codecov-api/timeseries/helpers.py
@@ -0,0 +1,427 @@
+import math
+from datetime import datetime, timedelta
+from typing import Iterable, List, Optional
+
+import sentry_sdk
+from django.conf import settings
+from django.db import connections
+from django.db.models import (
+ Avg,
+ DateTimeField,
+ DecimalField,
+ F,
+ FloatField,
+ Func,
+ Max,
+ Min,
+ QuerySet,
+ Sum,
+ Value,
+)
+from django.db.models.fields.json import KeyTextTransform
+from django.db.models.functions import Cast
+from django.utils import timezone
+
+from codecov_auth.models import Owner
+from core.models import Commit, Repository
+from services.task import TaskService
+from timeseries.models import (
+ Dataset,
+ Interval,
+ MeasurementName,
+ MeasurementSummary,
+)
+
+interval_deltas = {
+ Interval.INTERVAL_1_DAY: timedelta(days=1),
+ Interval.INTERVAL_7_DAY: timedelta(days=7),
+ Interval.INTERVAL_30_DAY: timedelta(days=30),
+}
+
+
+@sentry_sdk.trace
+def refresh_measurement_summaries(start_date: datetime, end_date: datetime) -> None:
+ """
+ Refresh the measurement summaries for the given time range.
+ This calls a TimescaleDB provided SQL function for each of the continuous aggregates
+ to refresh the aggregate data in the provided time range.
+ """
+ continuous_aggregates = [
+ "timeseries_measurement_summary_1day",
+ "timeseries_measurement_summary_7day",
+ "timeseries_measurement_summary_30day",
+ ]
+ with connections["timeseries"].cursor() as cursor:
+ for cagg in continuous_aggregates:
+ sql = f"CALL refresh_continuous_aggregate('{cagg}', '{start_date.isoformat()}', '{end_date.isoformat()}')"
+ cursor.execute(sql)
+
+
+@sentry_sdk.trace
+def aggregate_measurements(
+ queryset: QuerySet, group_by: Iterable[str] = None
+) -> QuerySet:
+ """
+ The given queryset is a set of measurement summaries. These are already
+ pre-aggregated by (timestamp, owner_id, repo_id, measurable_id, branch) via TimescaleDB's
+ continuous aggregates. If we want to further aggregate over any of those columns
+ then we need to perform additional aggregation in SQL. That is what this function
+ does to the given queryset.
+ """
+ if not group_by:
+ group_by = ["timestamp_bin"]
+
+ return (
+ queryset.values(*group_by)
+ .annotate(
+ min=Min("value_min"),
+ max=Max("value_max"),
+ avg=Cast(
+ Sum(F("value_avg") * F("value_count")) / Sum(F("value_count")),
+ # this is equivalent to Postgres' numeric(1000, 5) type
+ # 1000 is the max precision
+ # (used to avoid floating point error)
+ DecimalField(max_digits=1000, decimal_places=5),
+ ),
+ )
+ .order_by("timestamp_bin")
+ )
+
+
+def _filter_repos(
+ queryset: QuerySet, repos: Optional[List[Repository]], column_name: str = "repo_id"
+) -> QuerySet:
+ """
+ Filter the given generic queryset by a set of (repoid, branch) tuples.
+ """
+ if repos:
+ queryset = queryset.extra(
+ where=[f"({column_name}, branch) in %s"],
+ params=[tuple((repo.repoid, repo.branch) for repo in repos)],
+ )
+ return queryset
+
+
+@sentry_sdk.trace
+def coverage_measurements(
+ interval: Interval,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ repos: Optional[List[Repository]] = None,
+ **filters,
+):
+ timestamp_filters = {}
+ if start_date is not None:
+ timestamp_filters["timestamp_bin__gte"] = start_date
+ if end_date is not None:
+ timestamp_filters["timestamp_bin__lte"] = end_date
+
+ queryset = (
+ MeasurementSummary.agg_by(interval)
+ .filter(name=MeasurementName.COVERAGE.value, **timestamp_filters)
+ .filter(**filters)
+ )
+
+ queryset = _filter_repos(queryset, repos)
+
+ if start_date:
+ # The first measurement of the specified range (`start_date` through `end_date`)
+ # may be missing the first datapoint. In order for consumers of this API to have
+ # usable data to show we can carry an older datapoint forward to the first time bin.
+ # Including this older datapoint in the result set makes that possible.
+ older = (
+ MeasurementSummary.agg_by(interval)
+ .filter(
+ name=MeasurementName.COVERAGE.value,
+ timestamp_bin__lt=start_date,
+ )
+ .filter(**filters)
+ )
+ older = _filter_repos(older, repos)
+ older = aggregate_measurements(older).order_by("-timestamp_bin")[:1]
+
+ return older.union(aggregate_measurements(queryset)).order_by("timestamp_bin")
+ else:
+ return aggregate_measurements(queryset).order_by("timestamp_bin")
+
+
+def trigger_backfill(datasets: list[Dataset]):
+ """
+ Triggers a backfill for the full timespan of the dataset's repo's commits.
+ """
+ repo_ids = {d.repository_id for d in datasets}
+ timeranges = (
+ Commit.objects.filter(repository_id__in=repo_ids)
+ .values_list("repository_id")
+ .annotate(start_date=Min("timestamp"), end_date=Max("timestamp"))
+ )
+
+ timerange_by_repo = {
+ repo_id: (start_date, end_date) for repo_id, start_date, end_date in timeranges
+ }
+
+ for dataset in datasets:
+ if dataset.repository_id not in timerange_by_repo:
+ continue # there are no commits, and thus nothing to backfill
+ start_date, end_date = timerange_by_repo[dataset.repository_id]
+ TaskService().backfill_dataset(
+ dataset, start_date=start_date, end_date=end_date
+ )
+
+
+def aligned_start_date(interval: Interval, date: datetime) -> datetime:
+ """
+ Finds the aligned start date for the given timedelta and date.
+ TimescaleDB aligns time buckets starting on 2000-01-03 so this function will
+ return the date of the start of the bin containing the given `date`.
+ The return value will be <= the given date.
+ """
+ delta = interval_deltas[interval]
+
+ # TimescaleDB aligns time buckets starting on 2000-01-03)
+ aligning_date = datetime(2000, 1, 3, tzinfo=timezone.utc)
+
+ # number of full intervals between aligning date and the given date
+ intervals_before = math.floor((date - aligning_date) / delta)
+
+ # starting date of time bucket that contains the given date
+ return aligning_date + (intervals_before * delta)
+
+
+@sentry_sdk.trace
+def fill_sparse_measurements(
+ measurements: Iterable[dict],
+ interval: Interval,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+) -> Iterable[dict]:
+ """
+ Fill in sparse array of measurements with values such that we
+ have an entry for every interval within the requested time range.
+ Those placeholder entries will have empty measurement values.
+ """
+ by_timestamp = {
+ measurement["timestamp_bin"].replace(tzinfo=timezone.utc): measurement
+ for measurement in measurements
+ }
+ timestamps = sorted(by_timestamp.keys())
+ if len(timestamps) == 0:
+ return []
+
+ delta = interval_deltas[interval]
+
+ if start_date is None:
+ start_date = timestamps[0]
+ start_date = aligned_start_date(interval, start_date)
+
+ if end_date is None:
+ end_date = timezone.now()
+
+ intervals = []
+
+ current_date = start_date
+ while current_date <= end_date:
+ if current_date in by_timestamp:
+ intervals.append(by_timestamp[current_date])
+ else:
+ # interval not found
+ intervals.append(
+ {
+ "timestamp_bin": current_date,
+ "avg": None,
+ "min": None,
+ "max": None,
+ }
+ )
+ current_date += delta
+
+ if len(timestamps) > 0:
+ oldest_date = timestamps[0]
+ if (
+ oldest_date <= start_date
+ and len(intervals) > 0
+ and intervals[0]["avg"] is None
+ ):
+ # we're missing the first datapoint but we can carry forward
+ # and older measurement that was selected
+ measurement = by_timestamp[oldest_date]
+ intervals[0] = {
+ **measurement,
+ "timestamp_bin": start_date,
+ }
+
+ return intervals
+
+
+@sentry_sdk.trace
+def coverage_fallback_query(
+ interval: Interval,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ repos: Optional[List[Repository]] = None,
+ **filters,
+):
+ """
+ Query for coverage timeseries directly from the database
+ """
+ timestamp_filters = {}
+ if start_date is not None:
+ timestamp_filters["timestamp__gte"] = start_date
+ if end_date is not None:
+ timestamp_filters["timestamp__lte"] = end_date
+ commits = Commit.objects.filter(**timestamp_filters).filter(**filters)
+ commits = _filter_repos(commits, repos, column_name="repoid")
+ commits = _commits_coverage(commits, interval)
+
+ if start_date:
+ # The first measurement of the specified range (`start_date` through `end_date`)
+ # may be missing the first datapoint. In order for consumers of this API to have
+ # usable data to show we can carry an older datapoint forward to the first time bin.
+ # Including this older datapoint in the result set makes that possible.
+ older = Commit.objects.filter(
+ timestamp__lt=start_date,
+ ).filter(**filters)
+ older = _filter_repos(older, repos, column_name="repoid")
+ older = _commits_coverage(older, interval).order_by("-timestamp_bin")[:1]
+
+ return older.union(commits).order_by("timestamp_bin")
+ else:
+ return commits.order_by("timestamp_bin")
+
+
+def _commits_coverage(
+ commits_queryset: QuerySet[Commit], interval: Interval
+) -> QuerySet[Commit]:
+ intervals = {
+ Interval.INTERVAL_1_DAY: "1 day",
+ Interval.INTERVAL_7_DAY: "7 days",
+ Interval.INTERVAL_30_DAY: "30 days",
+ }
+
+ return (
+ commits_queryset.annotate(
+ timestamp_bin=Func(
+ Value(intervals[interval]),
+ F("timestamp"),
+ Value("2000-01-03"), # mimic how Timescale aligns bins
+ function="date_bin",
+ template="%(function)s(%(expressions)s) at time zone 'utc'",
+ output_field=DateTimeField(),
+ ),
+ coverage=Cast(KeyTextTransform("c", "totals"), output_field=FloatField()),
+ )
+ .filter(coverage__isnull=False)
+ .values("timestamp_bin")
+ .annotate(
+ min=Min("coverage"),
+ max=Max("coverage"),
+ avg=Avg("coverage"),
+ )
+ .order_by("timestamp_bin")
+ )
+
+
+@sentry_sdk.trace
+def repository_coverage_measurements_with_fallback(
+ repository: Repository,
+ interval: Interval,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ branch: str = None,
+):
+ """
+ Tries to return repository coverage measurements from Timescale.
+ If those are not available then we trigger a backfill and return computed results
+ directly from the primary database (much slower to query).
+ """
+ if settings.TIMESERIES_ENABLED:
+ dataset = Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=repository.pk,
+ ).first()
+
+ if dataset and dataset.is_backfilled():
+ # timeseries data is ready
+ return coverage_measurements(
+ interval,
+ start_date=start_date,
+ end_date=end_date,
+ owner_id=repository.author_id,
+ repo_id=repository.pk,
+ measurable_id=str(repository.pk),
+ branch=branch or repository.branch,
+ )
+
+ if not dataset:
+ # we need to backfill
+ dataset, created = Dataset.objects.get_or_create(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=repository.pk,
+ )
+ if created:
+ trigger_backfill([dataset])
+
+ # we're still backfilling or timeseries is disabled
+ return coverage_fallback_query(
+ interval,
+ start_date=start_date,
+ end_date=end_date,
+ repository_id=repository.pk,
+ branch=branch or repository.branch,
+ )
+
+
+@sentry_sdk.trace
+def owner_coverage_measurements_with_fallback(
+ owner: Owner,
+ repo_ids: Iterable[str],
+ interval: Interval,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+):
+ """
+ Tries to return owner coverage measurements from Timescale.
+ If those are not available then we trigger a backfill and return computed results
+ directly from the primary database (much slower to query).
+ """
+ # we can't join across databases so we need to load all this into memory.
+ # select just the needed columns to keep this manageable
+ repos = Repository.objects.filter(repoid__in=repo_ids).only("repoid", "branch")
+
+ if settings.TIMESERIES_ENABLED:
+ datasets = Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id__in=repo_ids,
+ )
+ all_backfilled = len(datasets) == len(repo_ids) and all(
+ dataset.is_backfilled() for dataset in datasets
+ )
+
+ if all_backfilled:
+ # timeseries data is ready
+ return coverage_measurements(
+ interval,
+ start_date=start_date,
+ end_date=end_date,
+ owner_id=owner.pk,
+ repos=repos,
+ )
+
+ # we need to backfill some datasets
+ dataset_repo_ids = {dataset.repository_id for dataset in datasets}
+ missing_dataset_repo_ids = set(repo_ids) - dataset_repo_ids
+ created_datasets = Dataset.objects.bulk_create(
+ [
+ Dataset(name=MeasurementName.COVERAGE.value, repository_id=repo_id)
+ for repo_id in missing_dataset_repo_ids
+ ]
+ )
+ trigger_backfill(created_datasets)
+
+ # we're still backfilling or timeseries is disabled
+ return coverage_fallback_query(
+ interval,
+ start_date=start_date,
+ end_date=end_date,
+ repos=repos,
+ )
diff --git a/apps/codecov-api/timeseries/models.py b/apps/codecov-api/timeseries/models.py
new file mode 100644
index 0000000000..b4f7267db5
--- /dev/null
+++ b/apps/codecov-api/timeseries/models.py
@@ -0,0 +1 @@
+from shared.django_apps.timeseries.models import *
diff --git a/apps/codecov-api/timeseries/templates/admin/backfill.html b/apps/codecov-api/timeseries/templates/admin/backfill.html
new file mode 100644
index 0000000000..24bde6f2e0
--- /dev/null
+++ b/apps/codecov-api/timeseries/templates/admin/backfill.html
@@ -0,0 +1,28 @@
+{% extends "admin/base_site.html" %}
+
+{% block content %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/codecov-api/timeseries/tests/__init__.py b/apps/codecov-api/timeseries/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/timeseries/tests/factories.py b/apps/codecov-api/timeseries/tests/factories.py
new file mode 100644
index 0000000000..7e4b78ffcd
--- /dev/null
+++ b/apps/codecov-api/timeseries/tests/factories.py
@@ -0,0 +1 @@
+from shared.django_apps.timeseries.tests.factories import *
diff --git a/apps/codecov-api/timeseries/tests/test_admin.py b/apps/codecov-api/timeseries/tests/test_admin.py
new file mode 100644
index 0000000000..6f3c15eb16
--- /dev/null
+++ b/apps/codecov-api/timeseries/tests/test_admin.py
@@ -0,0 +1,81 @@
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.test import TestCase
+from django.urls import reverse
+from django.utils import timezone
+from shared.django_apps.codecov_auth.tests.factories import UserFactory
+from shared.django_apps.core.tests.factories import RepositoryFactory
+from shared.django_apps.timeseries.tests.factories import DatasetFactory
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class DatasetAdminTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.user = UserFactory(is_staff=True)
+ self.client.force_login(user=self.user)
+
+ self.repo1 = RepositoryFactory()
+ self.repo2 = RepositoryFactory()
+ self.dataset1 = DatasetFactory(repository_id=self.repo1.pk, backfilled=True)
+ self.dataset2 = DatasetFactory(repository_id=self.repo2.pk, backfilled=True)
+
+ def test_list_page(self):
+ res = self.client.get(reverse("admin:timeseries_dataset_changelist"))
+ assert res.status_code == 200
+
+ def test_backfill_page(self):
+ res = self.client.post(
+ reverse("admin:timeseries_dataset_changelist"),
+ {
+ "action": "backfill",
+ ACTION_CHECKBOX_NAME: [
+ self.dataset1.pk,
+ self.dataset2.pk,
+ ],
+ },
+ )
+ assert res.status_code == 200
+ assert "Backfill will be performed for the following datasets" in str(
+ res.content
+ )
+
+ @patch("services.task.TaskService.backfill_dataset")
+ def test_perform_backfill(self, backfill_dataset):
+ res = self.client.post(
+ reverse("admin:timeseries_dataset_changelist"),
+ {
+ "action": "backfill",
+ ACTION_CHECKBOX_NAME: [
+ self.dataset1.pk,
+ self.dataset2.pk,
+ ],
+ "start_date": "2000-01-01",
+ "end_date": "2022-01-01",
+ "backfill": True,
+ },
+ )
+ assert res.status_code == 302
+
+ assert backfill_dataset.call_count == 2
+ backfill_dataset.assert_any_call(
+ self.dataset1,
+ start_date=timezone.datetime(2000, 1, 1, tzinfo=timezone.utc),
+ end_date=timezone.datetime(2022, 1, 1, tzinfo=timezone.utc),
+ )
+ backfill_dataset.assert_any_call(
+ self.dataset2,
+ start_date=timezone.datetime(2000, 1, 1, tzinfo=timezone.utc),
+ end_date=timezone.datetime(2022, 1, 1, tzinfo=timezone.utc),
+ )
+
+ self.dataset1.refresh_from_db()
+ assert self.dataset1.backfilled == False
+ self.dataset2.refresh_from_db()
+ assert self.dataset2.backfilled == False
diff --git a/apps/codecov-api/timeseries/tests/test_helpers.py b/apps/codecov-api/timeseries/tests/test_helpers.py
new file mode 100644
index 0000000000..1c507a245c
--- /dev/null
+++ b/apps/codecov-api/timeseries/tests/test_helpers.py
@@ -0,0 +1,1230 @@
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.test import TestCase
+from freezegun import freeze_time
+from freezegun.api import FakeDatetime
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.django_apps.timeseries.tests.factories import (
+ DatasetFactory,
+ MeasurementFactory,
+)
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from timeseries.helpers import (
+ coverage_measurements,
+ fill_sparse_measurements,
+ owner_coverage_measurements_with_fallback,
+ refresh_measurement_summaries,
+ repository_coverage_measurements_with_fallback,
+)
+from timeseries.models import Dataset, Interval, MeasurementName
+
+
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["flag1", "flag2"]))
+ return report
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class RefreshMeasurementSummariesTest(TestCase):
+ databases = {"timeseries"}
+
+ @patch("django.db.backends.utils.CursorWrapper.execute")
+ def test_refresh_measurement_summaries(self, execute):
+ refresh_measurement_summaries(
+ start_date=datetime(2022, 1, 1, 0, 0, 0),
+ end_date=datetime(2022, 1, 2, 0, 0, 0),
+ )
+
+ assert execute.call_count == 3
+ sql_statements = [call[0][0] for call in execute.call_args_list]
+ assert sql_statements == [
+ "CALL refresh_continuous_aggregate('timeseries_measurement_summary_1day', '2022-01-01T00:00:00', '2022-01-02T00:00:00')",
+ "CALL refresh_continuous_aggregate('timeseries_measurement_summary_7day', '2022-01-01T00:00:00', '2022-01-02T00:00:00')",
+ "CALL refresh_continuous_aggregate('timeseries_measurement_summary_30day', '2022-01-01T00:00:00', '2022-01-02T00:00:00')",
+ ]
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class RepositoryCoverageMeasurementsTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ def test_coverage_measurements(self):
+ res = coverage_measurements(
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 30, 0, 0, 0),
+ end_date=datetime(2022, 1, 4, 0, 0, 0),
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ branch=self.repo.branch,
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class FillSparseMeasurementsTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ def test_fill_sparse_measurements(self):
+ start_date = datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
+ end_date = datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc)
+ measurements = coverage_measurements(
+ Interval.INTERVAL_1_DAY,
+ start_date=start_date,
+ end_date=end_date,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ branch="main",
+ )
+ assert fill_sparse_measurements(
+ measurements, Interval.INTERVAL_1_DAY, start_date, end_date
+ ) == [
+ {
+ "timestamp_bin": datetime(2021, 12, 31, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ {
+ "timestamp_bin": datetime(2022, 1, 3, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ]
+
+ def test_fill_sparse_measurements_no_start_date(self):
+ end_date = datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc)
+ measurements = coverage_measurements(
+ Interval.INTERVAL_1_DAY,
+ end_date=end_date,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ branch="main",
+ )
+ assert fill_sparse_measurements(
+ measurements, Interval.INTERVAL_1_DAY, start_date=None, end_date=end_date
+ ) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ {
+ "timestamp_bin": datetime(2022, 1, 3, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ]
+
+ @freeze_time("2022-01-03T00:00:00")
+ def test_fill_sparse_measurements_no_end_date(self):
+ start_date = datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
+ measurements = coverage_measurements(
+ Interval.INTERVAL_1_DAY,
+ start_date=start_date,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ branch="main",
+ )
+ assert fill_sparse_measurements(
+ measurements,
+ Interval.INTERVAL_1_DAY,
+ start_date=start_date,
+ ) == [
+ {
+ "timestamp_bin": FakeDatetime(2021, 12, 31, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ {
+ "timestamp_bin": FakeDatetime(2022, 1, 3, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ]
+
+ def test_fill_sparse_measurements_first_datapoint(self):
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2021, 12, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2021, 12, 1, 1, 0, 0),
+ value=90.0,
+ branch="main",
+ )
+
+ start_date = datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
+ end_date = datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc)
+ measurements = coverage_measurements(
+ Interval.INTERVAL_1_DAY,
+ start_date=start_date,
+ end_date=end_date,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ branch="main",
+ )
+ assert fill_sparse_measurements(
+ measurements, Interval.INTERVAL_1_DAY, start_date, end_date
+ ) == [
+ {
+ # this bin is carried forward from the last datapoint before `start_date`
+ "timestamp_bin": datetime(2021, 12, 31, 0, 0, tzinfo=timezone.utc),
+ "avg": 85.0,
+ "min": 80.0,
+ "max": 90.0,
+ },
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ {
+ "timestamp_bin": datetime(2022, 1, 3, 0, 0, tzinfo=timezone.utc),
+ "avg": None,
+ "min": None,
+ "max": None,
+ },
+ ]
+
+ def test_fill_sparse_measurements_no_measurements(self):
+ assert fill_sparse_measurements([], Interval.INTERVAL_1_DAY, None, None) == []
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class RepositoryCoverageMeasurementsWithFallbackTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.repo = RepositoryFactory()
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_backfilled_dataset(self, is_backfilled):
+ is_backfilled.return_value = True
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ res = repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_backfilled_dataset_no_start_end_dates(self, is_backfilled):
+ is_backfilled.return_value = True
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2021, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo.author_id,
+ repo_id=self.repo.pk,
+ measurable_id=str(self.repo.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ res = repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_unbackfilled_dataset(self, is_backfilled):
+ is_backfilled.return_value = False
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ res = repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_unbackfilled_dataset_no_start_end_dates(self, is_backfilled):
+ is_backfilled.return_value = False
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ res = repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.helpers.trigger_backfill")
+ def test_no_dataset(self, trigger_backfill):
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+
+ res = repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch (commit1, commit2)
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ dataset = Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ ).first()
+ assert dataset
+ trigger_backfill.assert_called_once_with([dataset])
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ @patch("timeseries.helpers.trigger_backfill")
+ @patch("timeseries.models.Dataset.objects.get_or_create")
+ def test_backfill_trigger_on_dataset_creation(
+ self, mock_get_or_create, mock_trigger_backfill, mock_is_backfilled
+ ):
+ mock_is_backfilled.return_value = False
+ mock_get_or_create.return_value = (Dataset(), True)
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "80.00"},
+ )
+
+ # Invoke the logic
+ repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+
+ # Ensure get_or_create was called with the expected arguments
+ mock_get_or_create.assert_called_once_with(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ # Ensure trigger_backfill was called when a new Dataset was created
+ mock_trigger_backfill.assert_called_once_with(
+ [mock_get_or_create.return_value[0]]
+ )
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ @patch("timeseries.helpers.trigger_backfill")
+ @patch("timeseries.models.Dataset.objects.get_or_create")
+ def test_backfill_not_triggered_if_no_dataset_creation(
+ self, mock_get_or_create, mock_trigger_backfill, mock_is_backfilled
+ ):
+ mock_is_backfilled.return_value = False
+ mock_get_or_create.return_value = (Dataset(), False)
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "80.00"},
+ )
+
+ repository_coverage_measurements_with_fallback(
+ self.repo,
+ Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+
+ mock_get_or_create.assert_called_once_with(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo.pk,
+ )
+
+ mock_trigger_backfill.assert_not_called()
+
+
+@pytest.mark.skipif(
+ not settings.TIMESERIES_ENABLED, reason="requires timeseries data storage"
+)
+class OwnerCoverageMeasurementsWithFallbackTest(TestCase):
+ databases = {"default", "timeseries"}
+
+ def setUp(self):
+ self.owner = OwnerFactory()
+ self.repo1 = RepositoryFactory(author=self.owner)
+ self.repo2 = RepositoryFactory(author=self.owner)
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_backfilled_datasets(self, is_backfilled):
+ is_backfilled.return_value = True
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo1.author_id,
+ repo_id=self.repo1.pk,
+ measurable_id=str(self.repo1.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo1.author_id,
+ repo_id=self.repo1.pk,
+ measurable_id=str(self.repo1.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo1.author_id,
+ repo_id=self.repo1.pk,
+ measurable_id=str(self.repo1.pk),
+ timestamp=datetime(2022, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo1.author_id,
+ repo_id=self.repo1.pk,
+ measurable_id=str(self.repo1.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo2.author_id,
+ repo_id=self.repo2.pk,
+ measurable_id=str(self.repo2.pk),
+ timestamp=datetime(2022, 1, 1, 1, 0, 0),
+ value=80.0,
+ branch="main",
+ commit_sha="commit1",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo2.author_id,
+ repo_id=self.repo2.pk,
+ measurable_id=str(self.repo2.pk),
+ timestamp=datetime(2022, 1, 1, 2, 0, 0),
+ value=85.0,
+ branch="main",
+ commit_sha="commit2",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo2.author_id,
+ repo_id=self.repo2.pk,
+ measurable_id=str(self.repo2.pk),
+ timestamp=datetime(2022, 1, 1, 3, 0, 0),
+ value=90.0,
+ branch="other",
+ commit_sha="commit3",
+ )
+ MeasurementFactory(
+ name=MeasurementName.COVERAGE.value,
+ owner_id=self.repo2.author_id,
+ repo_id=self.repo2.pk,
+ measurable_id=str(self.repo2.pk),
+ timestamp=datetime(2022, 1, 2, 1, 0, 0),
+ value=90.0,
+ branch="main",
+ commit_sha="commit4",
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo1.pk,
+ )
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo2.pk,
+ )
+
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk, self.repo2.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 85.0,
+ "min": 80.0,
+ "max": 90.0,
+ },
+ ]
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 measurements on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.models.Dataset.is_backfilled")
+ def test_unbackfilled_dataset(self, is_backfilled):
+ is_backfilled.return_value = False
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo1.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo2.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "90.00",
+ },
+ )
+
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo1.pk,
+ )
+ DatasetFactory(
+ name=MeasurementName.COVERAGE.value,
+ repository_id=self.repo2.pk,
+ )
+
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk, self.repo2.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 commits on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 commit (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 85.0,
+ "min": 80.0,
+ "max": 90.0,
+ },
+ ]
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 commits on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 commit (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
+
+ @patch("timeseries.helpers.trigger_backfill")
+ def test_no_dataset(self, trigger_backfill):
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo1.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo1.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+
+ CommitFactory(
+ commitid="commit1",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "80.00",
+ },
+ )
+ CommitFactory(
+ commitid="commit2",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "85.00"},
+ )
+ CommitFactory(
+ commitid="commit3",
+ repository_id=self.repo2.pk,
+ branch="other",
+ timestamp=datetime(2022, 1, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ totals={"c": "90.00"},
+ )
+ CommitFactory(
+ commitid="commit4",
+ repository_id=self.repo2.pk,
+ branch="main",
+ timestamp=datetime(2022, 1, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+ totals={
+ "c": "90.00",
+ },
+ )
+
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk, self.repo2.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 commits on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 85.0,
+ "min": 80.0,
+ "max": 90.0,
+ },
+ ]
+
+ datasets = list(
+ Dataset.objects.filter(
+ name=MeasurementName.COVERAGE.value,
+ repository_id__in=[self.repo1.pk, self.repo2.pk],
+ )
+ )
+ assert len(datasets) == 2
+ created_datasets = [
+ (
+ ds.repository_id,
+ ds.name,
+ ds.is_backfilled(),
+ )
+ for ds in datasets
+ ]
+
+ assert (
+ self.repo1.pk,
+ MeasurementName.COVERAGE.value,
+ False,
+ ) in created_datasets
+ assert (
+ self.repo2.pk,
+ MeasurementName.COVERAGE.value,
+ False,
+ ) in created_datasets
+
+ try:
+ trigger_backfill.assert_called_once_with(datasets)
+ except AssertionError:
+ datasets.reverse()
+ trigger_backfill.assert_called_once_with(datasets)
+
+ res = owner_coverage_measurements_with_fallback(
+ owner=self.owner,
+ repo_ids=[self.repo1.pk],
+ interval=Interval.INTERVAL_1_DAY,
+ start_date=datetime(2021, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
+ end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+ )
+ assert list(res) == [
+ {
+ # aggregates over 2 commits on main branch
+ "timestamp_bin": datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 82.5,
+ "min": 80.0,
+ "max": 85.0,
+ },
+ {
+ # aggregates over 1 measurement (commit4)
+ "timestamp_bin": datetime(2022, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+ "avg": 80.0,
+ "min": 80.0,
+ "max": 80.0,
+ },
+ ]
diff --git a/apps/codecov-api/upload/__init__.py b/apps/codecov-api/upload/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/upload/constants.py b/apps/codecov-api/upload/constants.py
new file mode 100644
index 0000000000..1d6808b1c8
--- /dev/null
+++ b/apps/codecov-api/upload/constants.py
@@ -0,0 +1,222 @@
+ci = {
+ "travis": {
+ "title": "Travis-CI",
+ "icon": "travis",
+ "require_token_when_public": False,
+ "instructions": "travis",
+ "build_url": "https://travis-ci.com/{owner.username}/{repo.name}/jobs/{upload.job_code}",
+ },
+ "azure_pipelines": {
+ "title": "Azure",
+ "icon": "azure_pipelines",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "docker": {
+ "title": "Docker",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "buildbot": {
+ "title": "Buildbot",
+ "icon": "buildbot",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "cirrus-ci": {
+ "title": "Cirrus CI",
+ "icon": "cirrus-ci",
+ "require_token_when_public": False,
+ "instructions": "generic",
+ "build_url": "https://cirrus-ci.com/build/{upload.build_code}",
+ },
+ "codebuild": {
+ "title": "AWS Codebuild",
+ "icon": "codebuild",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "codefresh": {
+ "title": "Codefresh",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": "https://g.codefresh.io/repositories/{owner.username}/{repo.name}/builds/{upload.build_code}",
+ },
+ "bitbucket": {
+ "title": "Bitbucket Pipelines",
+ "icon": "bitbucket",
+ "require_token_when_public": False,
+ "instructions": "generic",
+ "build_url": "https://bitbucket.org/{owner.username}/{repo.name}/addon/pipelines/home#!/results/{upload.job_code}",
+ },
+ "circleci": {
+ "title": "CircleCI",
+ "icon": "circleci",
+ "require_token_when_public": False,
+ "instructions": "circleci",
+ "build_url": "https://circleci.com/{service_short}/{owner.username}/{repo.name}/{upload.build_code}#tests/containers/{upload.job_code}",
+ },
+ "buddybuild": {
+ "title": "buddybuild",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "buddy": {
+ "title": "buddy",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "github-actions": {
+ "title": "GitHub Actions",
+ "icon": "github-actions",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "solano": {
+ "title": "Solano",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "teamcity": {
+ "title": "TeamCity",
+ "icon": "teamcity",
+ "require_token_when_public": True,
+ "instructions": "teamcity",
+ "build_url": None,
+ },
+ "appveyor": {
+ "title": "AppVeyor",
+ "icon": "appveyor",
+ "require_token_when_public": False,
+ "instructions": "appveyor",
+ "build_url": None,
+ },
+ "wercker": {
+ "title": "Wercker",
+ "icon": "wercker",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": "https://app.wercker.com/#build/{upload.build_code}",
+ },
+ "shippable": {
+ "title": "Shippable",
+ "icon": "shippable",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "codeship": {
+ "title": "Codeship",
+ "icon": "codeship",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "drone.io": {
+ "title": "Drone.io",
+ "icon": "drone.io",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "jenkins": {
+ "title": "Jenkins",
+ "icon": "jenkins",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "semaphore": {
+ "title": "Semaphore",
+ "icon": "semaphore",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": "https://semaphoreapp.com/{owner.username}/{repo.name}/branches/{commit.branch}/builds/{upload.build_code}",
+ },
+ "gitlab": {
+ "title": "GitLab CI",
+ "icon": "gitlab",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": "https://gitlab.com/{owner.username}/{repo.name}/builds/{upload.build_code}",
+ },
+ "bamboo": {
+ "title": "Bamboo",
+ "icon": "bamboo",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "buildkite": {
+ "title": "BuildKite",
+ "icon": "buildkite",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "bitrise": {
+ "title": "Bitrise",
+ "icon": "bitrise",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "greenhouse": {
+ "title": "Greenhouse",
+ "icon": "greenhouse",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "heroku": {
+ "title": "Heroku",
+ "icon": "heroku",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None,
+ },
+ "woodpecker": {
+ "title": "WoodpeckerCI",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+ "custom": {
+ "title": "Custom",
+ "icon": "custom",
+ "require_token_when_public": True,
+ "instructions": "generic",
+ "build_url": None, # provided in upload,
+ },
+}
+
+errors = {
+ "travis": {
+ "tokenless-general-error": "\nERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):\n\nRepo token: {}\nDocumentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token",
+ "tokenless-stale-build": "\nERROR: The coverage upload was rejected because the build is out of date. Please make sure the build is not stale for uploads to process correctly.",
+ "tokenless-bad-status": "\nERROR: The build status does not indicate that the current build is in progress. Please make sure the build is in progress or was finished within the past 4 minutes to ensure reports upload properly.",
+ }
+}
+
+global_upload_token_providers = [
+ "github",
+ "github_enterprise",
+ "gitlab",
+ "gitlab_enterprise",
+ "bitbucket",
+ "bitbucket_server",
+]
diff --git a/apps/codecov-api/upload/helpers.py b/apps/codecov-api/upload/helpers.py
new file mode 100644
index 0000000000..380b17cc1a
--- /dev/null
+++ b/apps/codecov-api/upload/helpers.py
@@ -0,0 +1,841 @@
+import logging
+import re
+from json import dumps
+from typing import Any, Dict, Optional
+from urllib.parse import urlparse
+
+import jwt
+from asgiref.sync import async_to_sync
+from cerberus import Validator
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
+from django.http import HttpRequest
+from django.utils import timezone
+from jwt import PyJWKClient, PyJWTError
+from redis import Redis
+from rest_framework.exceptions import NotFound, Throttled, ValidationError
+from shared.github import InvalidInstallationError, get_github_integration_token
+from shared.helpers.redis import get_redis_connection
+from shared.plan.service import PlanService
+from shared.reports.enums import UploadType
+from shared.torngit.base import TorngitBaseAdapter
+from shared.torngit.exceptions import TorngitClientError, TorngitObjectNotFoundError
+from shared.typings.oauth_token_types import OauthConsumerToken
+from shared.upload.utils import query_monthly_coverage_measurements
+
+from codecov_auth.models import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ SERVICE_GITHUB,
+ SERVICE_GITHUB_ENTERPRISE,
+ GithubAppInstallation,
+ Owner,
+ Plan,
+)
+from core.models import Commit, Repository
+from reports.models import CommitReport, ReportSession
+from services.analytics import AnalyticsService
+from services.repo_providers import RepoProviderService
+from services.task import TaskService
+from upload.tokenless.tokenless import TokenlessUploadHandler
+from utils import is_uuid
+from utils.config import get_config
+from utils.encryption import encryptor
+
+from .constants import ci, global_upload_token_providers
+
+is_pull_noted_in_branch = re.compile(r".*(pull|pr)\/(\d+).*")
+
+# Valid values are `https://dev.azure.com/username/` or `https://username.visualstudio.com/`
+# May be URL-encoded, so ':' can be '%3A' and '/' can be '%2F'
+# Username is alphanumeric with '_' and '-'
+_valid_azure_server_uri = r"^https?(?:://|%3A%2F%2F)(?:dev.azure.com(?:/|%2F)[a-zA-Z0-9_-]+(?:/|%2F)|[a-zA-Z0-9_-]+.visualstudio.com(?:/|%2F))$"
+
+log = logging.getLogger(__name__)
+redis = get_redis_connection()
+
+
+def parse_params(data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ This function will validate the input request parameters and do some additional parsing/tranformation of the params.
+ """
+
+ # filter out empty values from the data; this makes parsing and setting defaults a bit easier
+ non_empty_data = {
+ key: value for key, value in data.items() if value not in [None, ""]
+ }
+
+ global_tokens = get_global_tokens()
+ params_schema = {
+ # --- The following parameters are populated in the code based on request data, settings, etc.
+ "owner": { # owner username, we set this by splitting the value of "slug" on "/" if provided
+ "type": "string",
+ "nullable": True,
+ "default_setter": (
+ lambda document: (
+ document.get("slug")
+ .rsplit("/", 1)[0]
+ .replace(
+ "/", ":"
+ ) # we use ':' as separator for gitlab subgroups internally
+ if document.get("slug")
+ and len(document.get("slug").rsplit("/", 1)) == 2
+ else None
+ )
+ ),
+ },
+ # repo name, we set this by parsing the value of "slug" if provided
+ "repo": {
+ "type": "string",
+ "nullable": True,
+ "default_setter": (
+ lambda document: (
+ document.get("slug").rsplit("/", 1)[1]
+ if document.get("slug")
+ and len(document.get("slug").rsplit("/", 1)) == 2
+ else None
+ )
+ ),
+ },
+ # indicates whether the token provided is a global upload token rather than a repository upload token
+ # note: this needs to go before the "service" field in the schema so we can use is when determining the service value to use
+ "using_global_token": {
+ "type": "boolean",
+ "default_setter": (
+ lambda document: (
+ True
+ if document.get("token") and document.get("token") in global_tokens
+ else False
+ )
+ ),
+ },
+ # --- The following parameters are expected to be provided in the upload request.
+ "version": {"type": "string", "required": True, "allowed": ["v2", "v4"]},
+ # commit SHA
+ "commit": {
+ "type": "string",
+ "required": True,
+ "regex": r"^\d+:\w{12}|\w{40}$",
+ "coerce": lambda value: value.lower(),
+ },
+ # if this is true, then we won't do any merge commit parsing
+ "_did_change_merge_commit": {"type": "boolean"},
+ "slug": {"type": "string", "regex": r"^[\w\-\.\~\/]+\/[\w\-\.]{1,255}$"},
+ # repository upload token
+ "token": {
+ "type": "string",
+ "anyof": [
+ {"regex": r"^[0-9a-f]{8}(-?[0-9a-f]{4}){3}-?[0-9a-f]{12}$"}, # UUID
+ {"regex": r"(^[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$)"}, # JWT
+ {"allowed": list(global_tokens.keys())},
+ ],
+ },
+ # name of the CI service used, must be a name in the list of CI services we support
+ "service": {
+ "type": "string",
+ "nullable": True,
+ "allowed": list(ci.keys()) + list(global_tokens.values()),
+ "coerce": (
+ lambda value: "travis" if value == "travis-org" else value,
+ ), # if "travis-org" was passed as the service rename it to "travis" before validating
+ "default_setter": (
+ lambda document: (
+ global_tokens[document.get("token")]
+ if document.get("using_global_token")
+ else None
+ )
+ ),
+ },
+ # pull request number
+ # if a value is passed to the "pull_request" field and not to "pr", we'll use that to set the value of this field
+ "pr": {
+ "type": "string",
+ "regex": r"^(\d+|false|null|undefined|true)$",
+ "nullable": True,
+ "default_setter": (lambda document: document.get("pull_request")),
+ "coerce": (
+ lambda value: None if value in ["false", "null", "undefined"] else value
+ ),
+ },
+ # pull request number
+ # "deprecated" in the sense that if a value is passed to this field, we'll use it to set "pr" and use that field instead
+ "pull_request": { # pull request number
+ "type": "string",
+ "regex": r"^(\d+|false|null|undefined|true)$",
+ "nullable": True,
+ "coerce": (
+ lambda value: (
+ None if value in ["false", "null", "undefined", "true"] else value
+ )
+ ),
+ },
+ "build_url": {"type": "string", "regex": r"^https?\:\/\/(.{,200})"},
+ "flags": {"type": "string", "regex": r"^[\w\.\-\,]+$"},
+ "branch": {
+ "type": "string",
+ "nullable": True,
+ "coerce": (
+ lambda value: (
+ None
+ if value == "HEAD"
+ # if prefixed with "origin/" or "refs/heads", the prefix will be removed
+ else (
+ value[7:]
+ if value[:7] == "origin/"
+ else value[11:]
+ if value[:11] == "refs/heads/"
+ else value
+ )
+ ),
+ ),
+ },
+ "tag": {"type": "string"},
+ # if a value is passed to "travis_job_id" and not to "job", we'll use that to set the value of this field
+ "job": {
+ "type": "string",
+ "nullable": True,
+ "default_setter": (lambda document: document.get("travis_job_id")),
+ },
+ # "deprecated" in the sense that if a value is passed to this field, we'll use it to set "job" and use that field instead
+ "travis_job_id": {"type": "string", "nullable": True, "empty": True},
+ "build": {
+ "type": "string",
+ "nullable": True,
+ "coerce": (
+ lambda value: (
+ None if value in ["null", "undefined", "none", "nil"] else value
+ )
+ ),
+ },
+ "name": {"type": "string"},
+ "package": {"type": "string"},
+ "s3": {"type": "integer"},
+ "yaml": {
+ "type": "string"
+ }, # file path to custom location of codecov.yml in repo
+ "url": {"type": "string"}, # custom location where report is found
+ "parent": {"type": "string"},
+ "project": {"type": "string"},
+ "server_uri": {
+ "type": "string",
+ "regex": _valid_azure_server_uri,
+ },
+ "root": {"type": "string"}, # deprecated
+ "storage_path": {"type": "string"},
+ }
+
+ v = Validator(params_schema, allow_unknown=True)
+ if not v.validate(non_empty_data):
+ raise ValidationError(v.errors)
+ # override service to the one from the global token if global token is in use
+ if v.document.get("using_global_token"):
+ v.document["service"] = global_tokens[v.document.get("token")]
+ # return validated data, including coerced values
+ return v.document
+
+
+def get_repo_with_github_actions_oidc_token(token: str) -> Repository:
+ unverified_contents = jwt.decode(token, options={"verify_signature": False})
+ token_issuer = str(unverified_contents.get("iss"))
+ parsed_url = urlparse(token_issuer)
+ if parsed_url.hostname == "token.actions.githubusercontent.com":
+ service = "github"
+ jwks_url = "https://token.actions.githubusercontent.com/.well-known/jwks"
+ else:
+ service = "github_enterprise"
+ github_enterprise_url = get_config("github_enterprise", "url")
+ if not github_enterprise_url:
+ raise ValidationError("GitHub Enterprise URL configuration is not set")
+ # remove trailing slashes if present
+ github_enterprise_url = re.sub(r"/+$", "", github_enterprise_url)
+ jwks_url = f"{github_enterprise_url}/_services/token/.well-known/jwks"
+ jwks_client = PyJWKClient(jwks_url)
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
+ data = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256"],
+ audience=[settings.CODECOV_API_URL, settings.CODECOV_URL],
+ )
+ repo = str(data.get("repository")).split("/")[-1]
+ repository = Repository.objects.get(
+ author__service=service,
+ name=repo,
+ author__username=data.get("repository_owner"),
+ )
+ return repository
+
+
+def determine_repo_for_upload(upload_params: Dict[str, Any]) -> Repository:
+ token = upload_params.get("token")
+ using_global_token = upload_params.get("using_global_token")
+ service = upload_params.get("service")
+
+ if token and not using_global_token:
+ if is_uuid(token):
+ try:
+ repository = Repository.objects.get(upload_token=token)
+ except ObjectDoesNotExist:
+ raise NotFound(
+ f"Could not find a repository associated with upload token {token}"
+ )
+ elif service == "github-actions":
+ try:
+ repository = get_repo_with_github_actions_oidc_token(token)
+ except PyJWTError:
+ raise ValidationError(
+ "Could not validate upload request using Github token"
+ )
+ elif service:
+ if using_global_token:
+ git_service = service
+ else:
+ git_service = TokenlessUploadHandler(service, upload_params).verify_upload()
+ try:
+ repository = Repository.objects.get(
+ author__service=git_service,
+ name=upload_params.get("repo"),
+ author__username=upload_params.get("owner"),
+ )
+ except ObjectDoesNotExist:
+ raise NotFound("Could not find a repository, try using repo upload token")
+ else:
+ raise ValidationError(
+ "Need either a token or service to determine target repository"
+ )
+
+ return repository
+
+ """
+ TODO: add CI verification and repo retrieval from CI
+ elif service:
+ if not using_global_token:
+ # verify CI TODO
+
+ # Get repo info from CI TODO
+ """
+
+
+def determine_upload_branch_to_use(
+ upload_params: Dict[str, Any], repo_default_branch: str
+) -> str | None:
+ """
+ Do processing on the upload request parameters to determine which branch to use for the upload:
+ - If no branch or PR were provided, use the default branch for the repository.
+ - If a branch was provided and the branch name contains "pull" or "pr" followed by digits, don't use the branch name.
+ In "determine_upload_pr_to_use" we'll extract the digits from the branch name and use that as the pr number.
+ - Otherwise, use the value provided in the request parameters.
+ """
+ upload_params_branch = upload_params.get("branch")
+ upload_params_pr = upload_params.get("pr")
+
+ if not upload_params_branch and not upload_params_pr:
+ return repo_default_branch
+ elif upload_params_branch and not is_pull_noted_in_branch.match(
+ upload_params_branch
+ ):
+ return upload_params_branch
+ else:
+ return None
+
+
+def determine_upload_pr_to_use(upload_params: Dict[str, Any]) -> str | None:
+ """
+ Do processing on the upload request parameters to determine which PR to use for the upload:
+ - If a branch was provided and the branch name contains "pull" or "pr" followed by digits, extract the digits and use that as the PR number.
+ - Otherwise, use the value provided in the request parameters.
+ """
+ pullid = is_pull_noted_in_branch.match(upload_params.get("branch") or "")
+ if pullid:
+ return pullid.groups()[1]
+ # The value of pr can be "true" and we use that info when determining upload branch, however we don't want to save that value to the db
+ elif upload_params.get("pr") == "true":
+ return None
+ else:
+ return upload_params.get("pr")
+
+
+def ghapp_installation_id_to_use(repository: Repository) -> Optional[str]:
+ if (
+ repository.service != SERVICE_GITHUB
+ and repository.service != SERVICE_GITHUB_ENTERPRISE
+ ):
+ return None
+
+ gh_app_default_installation: GithubAppInstallation = (
+ repository.author.github_app_installations.filter(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ ).first()
+ )
+ if (
+ gh_app_default_installation
+ and gh_app_default_installation.is_repo_covered_by_integration(repository)
+ ):
+ return gh_app_default_installation.installation_id
+ elif repository.using_integration and repository.author.integration_id:
+ # THIS FLOW IS DEPRECATED
+ # it will (hopefully) be removed after the ghapp installation work is complete
+ # and the data is backfilles appropriately
+ return repository.author.integration_id
+
+
+def try_to_get_best_possible_bot_token(
+ repository: Repository,
+) -> OauthConsumerToken | Dict:
+ ghapp_installation_id = ghapp_installation_id_to_use(repository)
+ if ghapp_installation_id is not None:
+ try:
+ github_token = get_github_integration_token(
+ repository.author.service,
+ integration_id=ghapp_installation_id,
+ )
+ return dict(key=github_token)
+ except InvalidInstallationError:
+ log.warning(
+ "Invalid installation error",
+ extra=dict(
+ service=repository.author.service,
+ integration_id=ghapp_installation_id,
+ ),
+ )
+ # now we'll fallback to trying an OAuth token
+ service = repository.author.service
+ if repository.bot is not None and repository.bot.oauth_token is not None:
+ log.info(
+ "Repo has specific bot",
+ extra=dict(repoid=repository.repoid, botid=repository.bot.ownerid),
+ )
+ return encryptor.decrypt_token(repository.bot.oauth_token)
+ if (
+ repository.author.bot is not None
+ and repository.author.bot.oauth_token is not None
+ ):
+ log.info(
+ "Repo Owner has specific bot",
+ extra=dict(
+ repoid=repository.repoid,
+ botid=repository.author.bot.ownerid,
+ ownerid=repository.author.ownerid,
+ ),
+ )
+ return encryptor.decrypt_token(repository.author.bot.oauth_token)
+ if repository.author.oauth_token is not None:
+ log.info(
+ "Using repository owner as bot fallback",
+ extra=dict(repoid=repository.repoid, ownerid=repository.author.ownerid),
+ )
+ return encryptor.decrypt_token(repository.author.oauth_token)
+ if not repository.private:
+ log.info(
+ "Using tokenless bot as bot fallback",
+ extra=dict(repoid=repository.repoid, ownerid=repository.author.ownerid),
+ )
+ return get_config(service, "bots", "tokenless")
+ return None
+
+
+@async_to_sync
+async def _get_git_commit_data(
+ adapter: TorngitBaseAdapter, commit: str, token: Optional[OauthConsumerToken | Dict]
+) -> Dict[str, Any]:
+ return await adapter.get_commit(commit, token)
+
+
+def determine_upload_commit_to_use(
+ upload_params: Dict[str, Any], repository: Repository
+) -> str:
+ """
+ Do processing on the upload request parameters to determine which commit to use for the upload:
+ - If this is a merge commit on github, use the first commit SHA in the merge commit message.
+ - Otherwise, use the value provided in the request parameters.
+ """
+ # Check if this is a merge commit and, if so, use the commitid of the commit being merged into per the merge commit message.
+ # See https://docs.codecov.io/docs/merge-commits for more context.
+ service = repository.author.service
+ commitid = upload_params.get("commit", "")
+ if service.startswith("github") and not upload_params.get(
+ "_did_change_merge_commit"
+ ):
+ token = try_to_get_best_possible_bot_token(repository)
+ if token is None:
+ return commitid
+ # Get the commit message from the git provider and check if it's structured like a merge commit message
+ try:
+ adapter = RepoProviderService().get_adapter(
+ repository.author, repository, use_ssl=True, token=token
+ )
+ git_commit_data = _get_git_commit_data(adapter, commitid, token)
+ except TorngitObjectNotFoundError:
+ log.warning(
+ "Unable to fetch commit. Not found",
+ extra=dict(commit=commitid),
+ )
+ return commitid
+ except TorngitClientError:
+ log.warning("Unable to fetch commit", extra=dict(commit=commitid))
+ return commitid
+
+ git_commit_message = git_commit_data.get("message", "").strip()
+ is_merge_commit = re.match(r"^Merge\s\w{40}\sinto\s\w{40}$", git_commit_message)
+
+ if is_merge_commit:
+ # If the commit message says "Merge A into B", we'll extract A and use that as the commitid for this upload
+ new_commit_id = git_commit_message.split(" ")[1]
+ log.info(
+ "Upload is for a merge commit, updating commit id for upload",
+ extra=dict(
+ commit=commitid,
+ commit_message=git_commit_message,
+ new_commit=new_commit_id,
+ ),
+ )
+ return new_commit_id
+
+ # If it's not a merge commit we'll just use the commitid provided in the upload parameters
+ return commitid
+
+
+def insert_commit(
+ commitid: str,
+ branch: str,
+ pr: int,
+ repository: Repository,
+ owner: Owner,
+ parent_commit_id: Optional[str] = None,
+) -> Commit:
+ commit, was_created = Commit.objects.defer("_report").get_or_create(
+ commitid=commitid,
+ repository=repository,
+ defaults={
+ "branch": branch,
+ "pullid": pr,
+ "merged": False if pr is not None else None,
+ "parent_commit_id": parent_commit_id,
+ "state": "pending",
+ },
+ )
+
+ edited = False
+ if parent_commit_id and commit.parent_commit_id is None:
+ commit.parent_commit_id = parent_commit_id
+ edited = True
+ if branch and commit.branch != branch:
+ # A branch head may have been moved; this allows commits to be "moved"
+ commit.branch = branch
+ edited = True
+ if edited:
+ commit.save(update_fields=["parent_commit_id", "branch"])
+ return commit
+
+
+def get_global_tokens() -> Dict[str | None, Any]:
+ """
+ Enterprise only: check the config to see if global tokens were set for this organization's uploads.
+
+ Returns dict with structure {: }
+ """
+ tokens = {
+ get_config(service, "global_upload_token"): service
+ for service in global_upload_token_providers
+ if get_config(service, "global_upload_token")
+ } # should be empty if we're not in enterprise
+ return tokens
+
+
+def check_commit_upload_constraints(commit: Commit) -> None:
+ if settings.UPLOAD_THROTTLING_ENABLED and commit.repository.private:
+ owner = _determine_responsible_owner(commit.repository)
+ plan_service = PlanService(current_org=owner)
+ limit = plan_service.monthly_uploads_limit
+ if limit is not None:
+ did_commit_uploads_start_already = ReportSession.objects.filter(
+ report__commit=commit
+ ).exists()
+ if not did_commit_uploads_start_already:
+ if (
+ query_monthly_coverage_measurements(plan_service=plan_service)
+ >= limit
+ ):
+ log.warning(
+ "User exceeded its limits for usage",
+ extra=dict(ownerid=owner.ownerid, repoid=commit.repository_id),
+ )
+ message = "Request was throttled. Throttled due to limit on private repository coverage uploads to Codecov on a free plan. Please upgrade your plan if you require additional uploads this month."
+ raise Throttled(detail=message)
+
+
+def validate_upload(
+ upload_params: Dict[str, Any], repository: Repository, redis: Redis
+) -> None:
+ """
+ Make sure the upload can proceed and, if so, activate the repository if needed.
+ """
+
+ validate_activated_repo(repository)
+ # Make sure repo hasn't moved
+ if not repository.name:
+ raise ValidationError(
+ "This repository has moved or was deleted. Please login to Codecov to retrieve a new upload token."
+ )
+
+ # Check if there are already too many sessions associated with this commit
+ try:
+ commit = Commit.objects.get(
+ commitid=upload_params.get("commit"), repository=repository
+ )
+ new_session_count = ReportSession.objects.filter(
+ ~Q(state="error"),
+ ~Q(upload_type=UploadType.CARRIEDFORWARD.db_name),
+ report__commit=commit,
+ ).count()
+ session_count = (commit.totals.get("s") if commit.totals else 0) or 0
+ current_upload_limit = get_config("setup", "max_sessions") or 150
+ if new_session_count > current_upload_limit:
+ if session_count <= current_upload_limit:
+ log.info(
+ "Old session count would not have blocked this upload",
+ extra=dict(
+ commit=upload_params.get("commit"),
+ session_count=session_count,
+ repoid=repository.repoid,
+ old_session_count=session_count,
+ new_session_count=new_session_count,
+ ),
+ )
+ log.warning(
+ "Too many uploads to this commit",
+ extra=dict(
+ commit=upload_params.get("commit"),
+ session_count=session_count,
+ repoid=repository.repoid,
+ ),
+ )
+ raise ValidationError("Too many uploads to this commit.")
+ elif session_count > current_upload_limit:
+ log.info(
+ "Old session count would block this upload",
+ extra=dict(
+ commit=upload_params.get("commit"),
+ session_count=session_count,
+ repoid=repository.repoid,
+ old_session_count=session_count,
+ new_session_count=new_session_count,
+ ),
+ )
+ except Commit.DoesNotExist:
+ pass
+
+ # Check if this repository is blacklisted and not allowed to upload
+ if redis.sismember("flags.disable_tasks", repository.repoid):
+ raise ValidationError(
+ "Uploads rejected for this project. Please contact Codecov staff for more details. Sorry for the inconvenience."
+ )
+
+ # Make sure the repository author has enough repo credits to upload reports
+ if (
+ repository.private
+ and not repository.activated
+ and not bool(get_config("setup", "enterprise_license", default=False))
+ ):
+ owner = _determine_responsible_owner(repository)
+
+ # If author is on per repo billing, check their repo credits
+ if (
+ owner.plan not in Plan.objects.values_list("name", flat=True)
+ and owner.repo_credits <= 0
+ ):
+ raise ValidationError(
+ "Sorry, but this team has no private repository credits left."
+ )
+
+ if not repository.activated:
+ AnalyticsService().account_activated_repository_on_upload(
+ repository.author.ownerid, repository
+ )
+
+ if (
+ not repository.activated
+ or not repository.active
+ or repository.deleted
+ or not repository.coverage_enabled
+ ):
+ # Activate the repository
+ repository.activated = True
+ repository.active = True
+ repository.deleted = False
+ repository.coverage_enabled = True
+ repository.save(
+ update_fields=["activated", "active", "deleted", "coverage_enabled"]
+ )
+
+
+def _determine_responsible_owner(repository: Repository) -> Owner:
+ owner = repository.author
+
+ if owner.service == "gitlab":
+ # Gitlab authors have a "subgroup" structure, so find the parent group before checking repo credits
+ while owner.parent_service_id is not None:
+ owner = Owner.objects.get(
+ service_id=owner.parent_service_id, service=owner.service
+ )
+ return owner
+
+
+def parse_headers(
+ headers: Dict[str, Any], upload_params: Dict[str, Any]
+) -> Dict[str, Any]:
+ version = upload_params.get("version")
+
+ # Content disposition header
+ if headers.get("Content_Disposition") not in (None, "inline"):
+ raise ValidationError("Setting Content-Disposition is not supported")
+
+ # Content type
+ if version == "v2":
+ content_type = "application/x-gzip"
+ reduced_redundancy = False
+ else:
+ content_type = (
+ "text/plain"
+ if headers.get("X_Content_Type", "") in ("", "text/html")
+ else headers.get("X_Content_Type", "")
+ )
+ reduced_redundancy = (
+ False
+ if "node" in upload_params.get("package", "")
+ else headers.get("X_Reduced_Redundancy") in ("true", None)
+ )
+
+ if content_type not in ("text/plain", "application/x-gzip", "plain/text"):
+ # Prevent customers from setting headers that could result in a XSS attack
+ content_type = "text/plain"
+
+ return {"content_type": content_type, "reduced_redundancy": reduced_redundancy}
+
+
+def dispatch_upload_task(
+ task_arguments: Dict[str, Any],
+ repository: Repository,
+ redis: Redis,
+ report_type: Optional[CommitReport.ReportType] = CommitReport.ReportType.COVERAGE,
+) -> None:
+ # Store task arguments in redis
+ cache_uploads_eta = get_config(("setup", "cache", "uploads"), default=86400)
+ if report_type == CommitReport.ReportType.COVERAGE:
+ repo_queue_key = f"uploads/{repository.repoid}/{task_arguments.get('commit')}"
+ else:
+ repo_queue_key = (
+ f"uploads/{repository.repoid}/{task_arguments.get('commit')}/{report_type}"
+ )
+
+ countdown = 0
+ if task_arguments.get("version") == "v4":
+ countdown = 4
+ if (
+ report_type == CommitReport.ReportType.BUNDLE_ANALYSIS
+ or CommitReport.ReportType.TEST_RESULTS
+ ):
+ countdown = 4
+
+ redis.rpush(repo_queue_key, dumps(task_arguments))
+ redis.expire(
+ repo_queue_key, cache_uploads_eta if cache_uploads_eta is not True else 86400
+ )
+
+ if report_type == CommitReport.ReportType.COVERAGE:
+ latest_upload_key = (
+ f"latest_upload/{repository.repoid}/{task_arguments.get('commit')}"
+ )
+ else:
+ latest_upload_key = f"latest_upload/{repository.repoid}/{task_arguments.get('commit')}/{report_type}"
+ redis.setex(
+ latest_upload_key,
+ 3600,
+ timezone.now().timestamp(),
+ )
+ commitid = task_arguments.get("commit")
+
+ TaskService().upload(
+ repoid=repository.repoid,
+ commitid=commitid,
+ report_type=str(report_type),
+ report_code=task_arguments.get("report_code"),
+ arguments=task_arguments,
+ countdown=max(
+ countdown, int(get_config("setup", "upload_processing_delay") or 0)
+ ),
+ )
+
+
+def validate_activated_repo(repository: Repository) -> None:
+ if repository.active and not repository.activated:
+ config_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/config/general"
+ raise ValidationError(
+ f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {config_url}"
+ )
+
+
+# headers["User-Agent"] should look something like this: codecov-cli/0.4.7 or codecov-uploader/0.7.1
+def get_agent_from_headers(headers: Dict[str, Any]) -> str:
+ try:
+ return headers["User-Agent"].split("/")[0].split("-")[1]
+ except Exception as e:
+ log.warning(
+ "Error getting agent from user agent header",
+ extra=dict(
+ err=str(e),
+ ),
+ )
+ return "unknown-user-agent"
+
+
+def get_version_from_headers(headers: Dict[str, Any]) -> str:
+ try:
+ return headers["User-Agent"].split("/")[1]
+ except Exception as e:
+ log.warning(
+ "Error getting version from user agent header",
+ extra=dict(
+ err=str(e),
+ ),
+ )
+ return "unknown-user-agent"
+
+
+def generate_upload_prometheus_metrics_labels(
+ action: str,
+ request: HttpRequest,
+ is_shelter_request: bool,
+ endpoint: Optional[str] = None,
+ repository: Optional[Repository] = None,
+ position: Optional[str] = None,
+ upload_version: Optional[str] = None,
+ include_empty_labels: bool = True,
+) -> Dict[str, Any]:
+ metrics_tags = dict(
+ agent=get_agent_from_headers(request.headers),
+ version=get_version_from_headers(request.headers),
+ action=action,
+ endpoint=endpoint,
+ is_using_shelter="yes" if is_shelter_request else "no",
+ )
+
+ repo_visibility = None
+ if repository:
+ repo_visibility = "private" if repository.private else "public"
+
+ optional_fields = {
+ "repo_visibility": repo_visibility,
+ "position": position,
+ "upload_version": upload_version,
+ }
+
+ metrics_tags.update(
+ {
+ field: value
+ for field, value in optional_fields.items()
+ if value or include_empty_labels
+ }
+ )
+
+ return metrics_tags
diff --git a/apps/codecov-api/upload/metrics.py b/apps/codecov-api/upload/metrics.py
new file mode 100644
index 0000000000..378459e54a
--- /dev/null
+++ b/apps/codecov-api/upload/metrics.py
@@ -0,0 +1,16 @@
+from shared.metrics import Counter
+
+API_UPLOAD_COUNTER = Counter(
+ "api_upload",
+ "Total API upload endpoint requests",
+ [
+ "agent",
+ "version",
+ "action",
+ "endpoint",
+ "is_using_shelter",
+ "repo_visibility",
+ "position",
+ "upload_version",
+ ],
+)
diff --git a/apps/codecov-api/upload/serializers.py b/apps/codecov-api/upload/serializers.py
new file mode 100644
index 0000000000..f092c6f7d5
--- /dev/null
+++ b/apps/codecov-api/upload/serializers.py
@@ -0,0 +1,196 @@
+from typing import Any, Dict, List
+
+from django.conf import settings
+from django.db.models import QuerySet
+from rest_framework import serializers
+from shared.api_archive.archive import ArchiveService
+
+from codecov_auth.models import Owner
+from core.models import Commit, Repository
+from reports.models import CommitReport, ReportResults, ReportSession, RepositoryFlag
+from services.task import TaskService
+
+
+class FlagListField(serializers.ListField):
+ child = serializers.CharField()
+
+ def to_representation(self, data: QuerySet) -> List[str | None]:
+ return [item.flag_name if item is not None else None for item in data.all()]
+
+
+class UploadSerializer(serializers.ModelSerializer):
+ flags = FlagListField(required=False)
+ ci_url = serializers.CharField(source="build_url", required=False, allow_null=True)
+ version = serializers.CharField(write_only=True, required=False)
+ url = serializers.SerializerMethodField()
+ storage_path = serializers.CharField(write_only=True, required=False)
+ ci_service = serializers.CharField(write_only=True, required=False)
+
+ class Meta:
+ read_only_fields = (
+ "external_id",
+ "created_at",
+ "raw_upload_location",
+ "state",
+ "provider",
+ "upload_type",
+ "url",
+ )
+ fields = read_only_fields + (
+ "ci_url",
+ "flags",
+ "env",
+ "name",
+ "job_code",
+ "version",
+ "storage_path",
+ "ci_service",
+ )
+ model = ReportSession
+
+ raw_upload_location = serializers.SerializerMethodField()
+
+ def get_raw_upload_location(self, obj: ReportSession) -> str:
+ repo = obj.report.commit.repository
+ archive_service = ArchiveService(repo)
+ return archive_service.create_presigned_put(obj.storage_path)
+
+ def get_url(self, obj: ReportSession) -> str:
+ repository = obj.report.commit.repository
+ commit = obj.report.commit
+ return f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+
+ def _create_existing_flags_map(self, repoid: int) -> dict:
+ existing_flags = RepositoryFlag.objects.filter(repository_id=repoid).all()
+ existing_flags_map = {}
+ for flag_obj in existing_flags:
+ existing_flags_map[flag_obj.flag_name] = flag_obj
+ return existing_flags_map
+
+ def create(self, validated_data: Dict[str, Any]) -> ReportSession | None:
+ flag_names = (
+ validated_data.pop("flags") if "flags" in validated_data.keys() else []
+ )
+ repoid = validated_data.pop("repo_id", None)
+
+ # default is necessary here, or else if the key is not in the dict
+ # the below will throw a KeyError
+ validated_data.pop("version", None)
+ validated_data.pop("ci_service", None)
+
+ upload = ReportSession.objects.create(**validated_data)
+ flags = []
+
+ if upload:
+ existing_flags_map = self._create_existing_flags_map(repoid)
+ for individual_flag in flag_names:
+ flag_obj = existing_flags_map.get(individual_flag, None)
+ if flag_obj is None:
+ flag_obj = RepositoryFlag.objects.create(
+ repository_id=repoid, flag_name=individual_flag
+ )
+ flags.append(flag_obj)
+ upload.flags.set(flags)
+ return upload
+
+
+class OwnerSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Owner
+ fields = (
+ "avatar_url",
+ "service",
+ "username",
+ "name",
+ "ownerid",
+ )
+ read_only_fields = fields
+
+
+class RepositorySerializer(serializers.ModelSerializer):
+ is_private = serializers.BooleanField(source="private")
+
+ class Meta:
+ model = Repository
+ fields = ("name", "is_private", "active", "language", "yaml")
+ read_only_fields = fields
+
+
+class CommitSerializer(serializers.ModelSerializer):
+ author = OwnerSerializer(read_only=True)
+ repository = RepositorySerializer(read_only=True)
+
+ class Meta:
+ model = Commit
+ read_only_fields = (
+ "message",
+ "timestamp",
+ "ci_passed",
+ "state",
+ "repository",
+ "author",
+ )
+ fields = read_only_fields + (
+ "commitid",
+ "parent_commit_id",
+ "pullid",
+ "branch",
+ )
+
+ def create(self, validated_data: Dict[str, Any]) -> Commit:
+ repo = validated_data.pop("repository", None)
+ commitid = validated_data.pop("commitid", None)
+ commit, created = Commit.objects.get_or_create(
+ repository=repo, commitid=commitid, defaults=validated_data
+ )
+
+ if created:
+ TaskService().update_commit(
+ commitid=commit.commitid, repoid=commit.repository.repoid
+ )
+
+ return commit
+
+
+class CommitReportSerializer(serializers.ModelSerializer):
+ commit_sha = serializers.CharField(source="commit.commitid", read_only=True)
+
+ class Meta:
+ model = CommitReport
+ read_only_fields = (
+ "external_id",
+ "created_at",
+ "commit_sha",
+ )
+ fields = read_only_fields + ("code",)
+
+ def create(self, validated_data: Dict[str, Any]) -> tuple[CommitReport, bool]:
+ report = (
+ CommitReport.objects.coverage_reports()
+ .filter(
+ code=validated_data.get("code"),
+ commit_id=validated_data.get("commit_id"),
+ )
+ .first()
+ )
+ if report:
+ if report.report_type is None:
+ report.report_type = CommitReport.ReportType.COVERAGE
+ report.save()
+ return report, False
+ return super().create(validated_data), True
+
+
+class ReportResultsSerializer(serializers.ModelSerializer):
+ report = CommitReportSerializer(read_only=True)
+
+ class Meta:
+ model = ReportResults
+ read_only_fields = (
+ "external_id",
+ "report",
+ "state",
+ "result",
+ "completed_at",
+ )
+ fields = read_only_fields
diff --git a/apps/codecov-api/upload/tests/__init__.py b/apps/codecov-api/upload/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/upload/tests/cassetes/test_helpers/test_determine_repo_for_upload_github_actions.yaml b/apps/codecov-api/upload/tests/cassetes/test_helpers/test_determine_repo_for_upload_github_actions.yaml
new file mode 100644
index 0000000000..7611f58e45
--- /dev/null
+++ b/apps/codecov-api/upload/tests/cassetes/test_helpers/test_determine_repo_for_upload_github_actions.yaml
@@ -0,0 +1,48 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Connection:
+ - close
+ Host:
+ - token.actions.githubusercontent.com
+ User-Agent:
+ - Python-urllib/3.9
+ method: GET
+ uri: https://token.actions.githubusercontent.com/.well-known/jwks
+ response:
+ body:
+ string: '{"keys":[{"n":"u1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmw==","kty":"RSA","kid":"78167F727DEC5D801DD1C8784C704A1C880EC0E1","alg":"RS256","e":"AQAB","use":"sig"}]}'
+ headers:
+ ActivityId:
+ - 4183b59f-ea2d-4bc5-ae32-dc091fb49be2
+ Cache-Control:
+ - no-store,no-cache
+ Connection:
+ - close
+ Content-Length:
+ - '3522'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 04 Oct 2023 14:34:40 GMT
+ Pragma:
+ - no-cache
+ Strict-Transport-Security:
+ - max-age=2592000
+ X-Cache:
+ - CONFIG_NOCACHE
+ X-MSEdge-Ref:
+ - 'Ref A: F7396FF450344B50A669BB5F69F76217 Ref B: CHGEDGE1206 Ref C: 2023-10-04T14:34:40Z'
+ X-TFS-ProcessId:
+ - f0fc6568-a025-4825-be20-f73b4d89a942
+ X-TFS-Session:
+ - 4183b59f-ea2d-4bc5-ae32-dc091fb49be2
+ X-VSS-E2EID:
+ - 4183b59f-ea2d-4bc5-ae32-dc091fb49be2
+ X-VSS-SenderDeploymentId:
+ - 84ff2294-8721-ab49-db5a-db17b0f6c2de
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/apps/codecov-api/upload/tests/test_helpers.py b/apps/codecov-api/upload/tests/test_helpers.py
new file mode 100644
index 0000000000..b0540466e3
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_helpers.py
@@ -0,0 +1,348 @@
+from contextlib import nullcontext
+from unittest.mock import patch
+
+import jwt
+import pytest
+from django.conf import settings
+from django.test import TestCase
+from rest_framework.exceptions import Throttled, ValidationError
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.django_apps.reports.models import ReportType
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.upload.utils import UploaderType, insert_coverage_measurement
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.models import GithubAppInstallation, Service
+from reports.tests.factories import CommitReportFactory, UploadFactory
+from upload.helpers import (
+ check_commit_upload_constraints,
+ determine_repo_for_upload,
+ ghapp_installation_id_to_use,
+ try_to_get_best_possible_bot_token,
+ validate_activated_repo,
+ validate_upload,
+)
+
+
+class TestGithubAppInstallationUsage(TestCase):
+ def test_not_github_provider(self):
+ repo = RepositoryFactory(author__service=Service.GITLAB.value)
+ assert ghapp_installation_id_to_use(repo) is None
+
+ def test_github_app_installation_flow(self):
+ owner = OwnerFactory(service=Service.GITHUB.value, integration_id=None)
+ covered_repo = RepositoryFactory(author=owner)
+ not_covered_repo = RepositoryFactory(author=owner)
+ ghapp_installation = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=[covered_repo.service_id],
+ installation_id=200,
+ )
+ ghapp_installation.save()
+ assert ghapp_installation_id_to_use(covered_repo) == 200
+ assert ghapp_installation_id_to_use(not_covered_repo) is None
+
+
+def test_try_to_get_best_possible_bot_token_no_repobot_no_ownerbot(db):
+ owner = OwnerFactory.create(unencrypted_oauth_token="super")
+ owner.save()
+ repository = RepositoryFactory.create(author=owner)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) == {
+ "key": "super",
+ "secret": None,
+ }
+
+
+def test_try_to_get_best_possible_bot_token_no_repobot_yes_ownerbot(db):
+ bot = OwnerFactory.create(unencrypted_oauth_token="bornana")
+ bot.save()
+ owner = OwnerFactory.create(unencrypted_oauth_token="super", bot=bot)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) == {
+ "key": "bornana",
+ "secret": None,
+ }
+
+
+def test_try_to_get_best_possible_bot_token_yes_repobot(db):
+ bot = OwnerFactory.create(unencrypted_oauth_token="bornana")
+ bot.save()
+ another_bot = OwnerFactory.create(unencrypted_oauth_token="anotha_one")
+ another_bot.save()
+ owner = OwnerFactory.create(unencrypted_oauth_token="super", bot=bot)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner, bot=another_bot)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) == {
+ "key": "anotha_one",
+ "secret": None,
+ }
+
+
+@patch("upload.helpers.get_github_integration_token")
+@pytest.mark.django_db
+def test_try_to_get_best_possible_bot_token_using_integration(
+ get_github_integration_token,
+):
+ get_github_integration_token.return_value = "test-token"
+ owner = OwnerFactory.create(integration_id=12345)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner, using_integration=True)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) == {
+ "key": "test-token",
+ }
+ get_github_integration_token.assert_called_once_with("github", integration_id=12345)
+
+
+@patch("upload.helpers.get_github_integration_token")
+@pytest.mark.django_db
+def test_try_to_get_best_possible_bot_token_using_invalid_integration(
+ get_github_integration_token,
+):
+ from shared.github import InvalidInstallationError # circular imports
+
+ get_github_integration_token.side_effect = InvalidInstallationError(
+ error_cause="installation_not_found"
+ )
+ bot = OwnerFactory.create(unencrypted_oauth_token="bornana")
+ bot.save()
+ owner = OwnerFactory.create(integration_id=12345, bot=bot)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner, using_integration=True)
+ repository.save()
+ # falls back to bot token
+ assert try_to_get_best_possible_bot_token(repository) == {
+ "key": "bornana",
+ "secret": None,
+ }
+ get_github_integration_token.assert_called_once_with("github", integration_id=12345)
+
+
+def test_try_to_get_best_possible_nothing_and_is_private(db):
+ owner = OwnerFactory.create(oauth_token=None)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner, bot=None, private=True)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) is None
+
+
+def test_try_to_get_best_possible_nothing_and_not_private(db, mocker):
+ something = mocker.MagicMock()
+ mock_get_config = mocker.patch("upload.helpers.get_config", return_value=something)
+ owner = OwnerFactory.create(service="github", oauth_token=None)
+ owner.save()
+ repository = RepositoryFactory.create(author=owner, bot=None, private=False)
+ repository.save()
+ assert try_to_get_best_possible_bot_token(repository) is something
+ mock_get_config.assert_called_with("github", "bots", "tokenless")
+
+
+def test_check_commit_constraints_settings_disabled(db, settings):
+ settings.UPLOAD_THROTTLING_ENABLED = False
+ repository = RepositoryFactory.create(author__plan=DEFAULT_FREE_PLAN, private=True)
+ first_commit = CommitFactory.create(repository=repository)
+ second_commit = CommitFactory.create(repository=repository)
+ third_commit = CommitFactory.create(repository__author=repository.author)
+ unrelated_commit = CommitFactory.create()
+ report = CommitReportFactory.create(commit=first_commit)
+ for i in range(300):
+ UploadFactory.create(report=report)
+ # no commit should be throttled
+ check_commit_upload_constraints(first_commit)
+ check_commit_upload_constraints(unrelated_commit)
+ check_commit_upload_constraints(second_commit)
+ check_commit_upload_constraints(third_commit)
+
+
+def test_check_commit_constraints_settings_enabled(db, settings, mocker):
+ settings.UPLOAD_THROTTLING_ENABLED = True
+ mock_all_plans_and_tiers()
+ author = OwnerFactory.create(plan=DEFAULT_FREE_PLAN)
+ repository = RepositoryFactory.create(author=author, private=True)
+ public_repository = RepositoryFactory.create(author=author, private=False)
+ first_commit = CommitFactory.create(repository=repository)
+ second_commit = CommitFactory.create(repository=repository)
+ third_commit = CommitFactory.create(repository__author=repository.author)
+ fourth_commit = CommitFactory.create(repository=repository)
+ public_repository_commit = CommitFactory.create(repository=public_repository)
+ unrelated_commit = CommitFactory.create()
+ first_report = CommitReportFactory.create(
+ commit=first_commit, report_type=ReportType.COVERAGE.value
+ )
+ fourth_report = CommitReportFactory.create(
+ commit=fourth_commit, report_type=ReportType.COVERAGE.value
+ )
+ check_commit_upload_constraints(second_commit)
+ for i in range(300):
+ UploadFactory.create(report__commit__repository=public_repository)
+ first_upload = UploadFactory(report=first_report)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=public_repository.repoid,
+ commit_id=public_repository_commit.id,
+ upload_id=first_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=public_repository.private,
+ report_type=first_report.report_type,
+ )
+ # ensuring public repos counts don't count towards the quota
+ check_commit_upload_constraints(second_commit)
+ for i in range(150):
+ another_first_upload = UploadFactory.create(report=first_report)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=repository.repoid,
+ commit_id=first_commit.id,
+ upload_id=another_first_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=first_report.report_type,
+ )
+ fourth_upload = UploadFactory.create(report=fourth_report)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=repository.repoid,
+ commit_id=fourth_commit.id,
+ upload_id=fourth_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=fourth_report.report_type,
+ )
+ # first and fourth commit already has uploads made, we won't block uploads to them
+ check_commit_upload_constraints(first_commit)
+ check_commit_upload_constraints(fourth_commit)
+ # unrelated commit belongs to a different user. Ensuring we don't block it
+ check_commit_upload_constraints(unrelated_commit)
+ # public repositories commit should never be throttled
+ check_commit_upload_constraints(public_repository_commit)
+ with pytest.raises(Throttled):
+ # second commit does not have uploads made, so we block it
+ check_commit_upload_constraints(second_commit)
+ with pytest.raises(Throttled) as excinfo:
+ # third commit belongs to a different repo, but same user
+ check_commit_upload_constraints(third_commit)
+ assert (
+ "Throttled due to limit on private repository coverage uploads"
+ in excinfo.value.detail
+ )
+
+
+@pytest.mark.parametrize(
+ "totals_column_count, rows_count, should_raise",
+ [(151, 0, False), (151, 151, True), (0, 0, False), (0, 200, True)],
+)
+def test_validate_upload_too_many_uploads_for_commit(
+ db, totals_column_count, rows_count, should_raise, mocker
+):
+ redis = mocker.MagicMock(sismember=mocker.MagicMock(return_value=False))
+ owner = OwnerFactory.create(plan="users-free")
+ repo = RepositoryFactory.create(author=owner)
+ commit = CommitFactory.create(totals={"s": totals_column_count}, repository=repo)
+ report = CommitReportFactory.create(commit=commit)
+ for i in range(rows_count):
+ UploadFactory.create(report=report)
+ with pytest.raises(ValidationError) if should_raise else nullcontext():
+ validate_upload({"commit": commit.commitid}, repo, redis)
+
+
+def test_deactivated_repo(db, mocker):
+ repository = RepositoryFactory.create(active=True, activated=False)
+ config_url = f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/config/general"
+
+ with pytest.raises(ValidationError) as exp:
+ validate_activated_repo(repository)
+ assert exp.match(
+ f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {config_url}"
+ )
+
+
+@pytest.mark.django_db
+def test_determine_repo_for_upload_token():
+ token = "80cd8016-5d26-40e5-8c71-f1c44e04aba0"
+ repository = RepositoryFactory.create(upload_token=token)
+ assert determine_repo_for_upload({"token": token}) == repository
+
+
+# random keypair for RS256 JWTs used below
+public_key = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo
+4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u
++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh
+kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ
+0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg
+cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc
+mwIDAQAB
+-----END PUBLIC KEY-----"""
+private_key = """-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj
+MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu
+NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ
+qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg
+p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR
+ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi
+VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV
+laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8
+sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H
+mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY
+dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw
+ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ
+DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T
+N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t
+0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv
+t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU
+AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk
+48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL
+DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK
+xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA
+mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh
+2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz
+et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr
+VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD
+TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc
+dn/RsYEONbwQSjIfMPkvxF+8HQ==
+-----END PRIVATE KEY-----"""
+
+
+@pytest.mark.django_db
+def test_determine_repo_for_upload_github_actions(codecov_vcr):
+ # This test recorded a VCR cassette while making a request to
+ # https://token.actions.githubusercontent.com/.well-known/jwks
+ #
+ # I modified this request to include the modulus and exponent corresponding
+ # to the random keypair I generated above.
+ #
+ # I did this offline so as not to need an additional dependency - here's the code
+ # if we ever need to regenerate these:
+ #
+ # from Crypto.PublicKey import RSA
+ # import base64
+ # pub = RSA.importKey(public_key)
+ # modulus = base64.b64encode(pub.n.to_bytes(256, "big")).decode("ascii")
+ # exponent = base64.b64encode(pub.e.to_bytes(3, "big")).decode("ascii")
+
+ repository = RepositoryFactory.create()
+ token = jwt.encode(
+ {
+ "iss": "https://token.actions.githubusercontent.com/abcdefg",
+ "aud": [f"{settings.CODECOV_API_URL}"],
+ "repository": f"{repository.author.username}/{repository.name}",
+ "repository_owner": repository.author.username,
+ },
+ private_key,
+ algorithm="RS256",
+ headers={
+ "kid": "78167F727DEC5D801DD1C8784C704A1C880EC0E1"
+ }, # from the JWKS response
+ )
+ assert (
+ determine_repo_for_upload({"token": token, "service": "github-actions"})
+ == repository
+ )
diff --git a/apps/codecov-api/upload/tests/test_serializers.py b/apps/codecov-api/upload/tests/test_serializers.py
new file mode 100644
index 0000000000..8d58606153
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_serializers.py
@@ -0,0 +1,259 @@
+from django.conf import settings
+from rest_framework.exceptions import ErrorDetail
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from billing.helpers import mock_all_plans_and_tiers
+from reports.tests.factories import (
+ CommitReportFactory,
+ ReportResultsFactory,
+ RepositoryFlagFactory,
+ UploadFactory,
+)
+from upload.serializers import (
+ CommitReportSerializer,
+ CommitSerializer,
+ ReportResultsSerializer,
+ UploadSerializer,
+)
+
+
+def get_fake_upload():
+ OwnerFactory()
+ user_with_uploads = OwnerFactory()
+ repo = RepositoryFactory.create(author=user_with_uploads, private=True)
+ RepositoryFactory.create(author=user_with_uploads, private=False)
+ commit = CommitFactory.create(repository=repo)
+ report = CommitReportFactory.create(commit=commit)
+
+ return UploadFactory.create(report=report)
+
+
+def get_fake_upload_with_flags():
+ upload = get_fake_upload()
+ flag1 = RepositoryFlagFactory(
+ repository=upload.report.commit.repository, flag_name="flag1"
+ )
+ flag2 = RepositoryFlagFactory(
+ repository=upload.report.commit.repository, flag_name="flag2"
+ )
+ upload.flags.set([flag1, flag2])
+ return upload
+
+
+def test_serialize_upload(transactional_db, mocker):
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ fake_upload = get_fake_upload()
+ serializer = UploadSerializer(instance=fake_upload)
+ assert (
+ "upload_type" in serializer.data
+ and serializer.data["upload_type"] == "uploaded"
+ )
+ new_data = {"env": {"some_var": "some_value"}, "name": "upload name...?"}
+ res = serializer.update(fake_upload, new_data)
+ assert res == fake_upload
+ assert fake_upload.name == "upload name...?"
+
+
+def test_upload_serializer_contains_expected_fields_no_flags(transactional_db, mocker):
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload = get_fake_upload()
+ serializer = UploadSerializer(instance=upload)
+ repo = upload.report.commit.repository
+ expected_data = {
+ "external_id": str(upload.external_id),
+ "created_at": upload.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "raw_upload_location": "presigned put",
+ "state": upload.state,
+ "provider": upload.provider,
+ "upload_type": upload.upload_type,
+ "ci_url": upload.build_url,
+ "flags": [],
+ "job_code": upload.job_code,
+ "env": upload.env,
+ "name": upload.name,
+ "url": f"{settings.CODECOV_DASHBOARD_URL}/{repo.author.service}/{repo.author.username}/{repo.name}/commit/{upload.report.commit.commitid}",
+ }
+ assert serializer.data == expected_data
+
+
+def test_upload_serializer_contains_expected_fields_with_flags(
+ transactional_db, mocker
+):
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload = get_fake_upload_with_flags()
+ serializer = UploadSerializer(instance=upload)
+ repo = upload.report.commit.repository
+ expected_data = {
+ "external_id": str(upload.external_id),
+ "created_at": upload.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "raw_upload_location": "presigned put",
+ "state": upload.state,
+ "provider": upload.provider,
+ "upload_type": upload.upload_type,
+ "ci_url": upload.build_url,
+ "flags": ["flag1", "flag2"],
+ "job_code": upload.job_code,
+ "env": upload.env,
+ "name": upload.name,
+ "url": f"{settings.CODECOV_DASHBOARD_URL}/{repo.author.service}/{repo.author.username}/{repo.name}/commit/{upload.report.commit.commitid}",
+ }
+ assert serializer.data == expected_data
+
+
+def test_upload_serializer_null_build_url_empty_flags(transactional_db, mocker):
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ data = {
+ "ci_url": None,
+ "flags": [],
+ "env": "env",
+ "name": "name",
+ "job_code": "job_code",
+ }
+
+ serializer = UploadSerializer(data=data)
+ assert serializer.is_valid()
+
+
+def test__create_existing_flags_map(transactional_db, mocker):
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload = get_fake_upload_with_flags()
+ serializer = UploadSerializer(instance=upload)
+ flags_map = serializer._create_existing_flags_map(
+ upload.report.commit.repository.repoid
+ )
+ upload_flags = upload.flags.all()
+ flag1 = list(filter(lambda flag: flag.flag_name == "flag1", upload_flags))[0]
+ flag2 = list(filter(lambda flag: flag.flag_name == "flag2", upload_flags))[0]
+ assert flags_map == {
+ "flag1": flag1,
+ "flag2": flag2,
+ }
+
+
+def test_commit_serializer_contains_expected_fields(transactional_db, mocker):
+ commit = CommitFactory.create()
+ serializer = CommitSerializer(commit)
+ expected_data = {
+ "message": commit.message,
+ "ci_passed": commit.ci_passed,
+ "state": commit.state,
+ "repository": {
+ "name": commit.repository.name,
+ "is_private": commit.repository.private,
+ "active": commit.repository.active,
+ "language": commit.repository.language,
+ "yaml": commit.repository.yaml,
+ },
+ "author": {
+ "avatar_url": commit.author.avatar_url,
+ "service": commit.author.service,
+ "username": commit.author.username,
+ "name": commit.author.name,
+ "ownerid": commit.author.ownerid,
+ },
+ "commitid": commit.commitid,
+ "parent_commit_id": commit.parent_commit_id,
+ "pullid": commit.pullid,
+ "branch": commit.branch,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert serializer.data == expected_data
+
+
+def test_commit_serializer_does_not_duplicate(transactional_db, mocker):
+ mock_all_plans_and_tiers()
+ repository = RepositoryFactory()
+ serializer = CommitSerializer()
+
+ saved_commit1 = serializer.create(
+ {
+ "repository": repository,
+ "commitid": "1234567",
+ "parent_commit_id": "2345678",
+ "pullid": 1,
+ "branch": "test_branch",
+ }
+ )
+
+ saved_commit2 = serializer.create(
+ {
+ "repository": repository,
+ "commitid": "1234567",
+ "parent_commit_id": "2345678",
+ "pullid": 1,
+ "branch": "test_branch",
+ }
+ )
+
+ assert saved_commit1 == saved_commit2
+
+
+def test_invalid_update_data(transactional_db, mocker):
+ commit = CommitFactory.create()
+ new_data = {"pullid": "1"}
+ serializer = CommitSerializer(commit, new_data)
+ assert not serializer.is_valid()
+ assert serializer.errors == {
+ "commitid": [ErrorDetail(string="This field is required.", code="required")]
+ }
+
+
+def test_valid_update_data(transactional_db, mocker):
+ commit = CommitFactory.create(pullid=1)
+ new_data = {"pullid": "20", "commitid": "abc"}
+ serializer = CommitSerializer(commit)
+ res = serializer.update(commit, new_data)
+ assert commit.pullid == "20"
+ assert commit.commitid == "abc"
+ assert commit == res
+
+
+def test_commit_report_serializer(transactional_db, mocker):
+ report = CommitReportFactory.create()
+ serializer = CommitReportSerializer(report)
+ expected_data = {
+ "commit_sha": report.commit.commitid,
+ "external_id": str(report.external_id),
+ "created_at": report.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "code": report.code,
+ }
+ assert serializer.data == expected_data
+
+
+def test_report_results_serializer(transactional_db, mocker):
+ report_result = ReportResultsFactory.create()
+ serializer = ReportResultsSerializer(report_result)
+ expected_data = {
+ "external_id": str(report_result.external_id),
+ "report": {
+ "external_id": str(report_result.report.external_id),
+ "created_at": report_result.report.created_at.strftime(
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ),
+ "commit_sha": report_result.report.commit.commitid,
+ "code": report_result.report.code,
+ },
+ "state": report_result.state,
+ "result": report_result.result,
+ "completed_at": report_result.completed_at,
+ }
+ assert serializer.data == expected_data
diff --git a/apps/codecov-api/upload/tests/test_throttles.py b/apps/codecov-api/upload/tests/test_throttles.py
new file mode 100644
index 0000000000..ea1b85d124
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_throttles.py
@@ -0,0 +1,202 @@
+from unittest.mock import MagicMock, Mock
+
+from django.test import override_settings
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.django_apps.reports.models import ReportType
+from shared.helpers.redis import get_redis_connection
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.upload.utils import UploaderType, insert_coverage_measurement
+
+from billing.helpers import mock_all_plans_and_tiers
+from reports.tests.factories import CommitReportFactory, UploadFactory
+from upload.throttles import UploadsPerCommitThrottle, UploadsPerWindowThrottle
+
+
+class ThrottlesUnitTests(APITestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ mock_all_plans_and_tiers()
+ cls.owner = OwnerFactory(plan=DEFAULT_FREE_PLAN, max_upload_limit=150)
+
+ def request_should_not_throttle(self, commit):
+ self.uploads_per_window_not_throttled(commit)
+ self.uploads_per_commit_not_throttled(commit)
+
+ def set_view_obj(self, commit):
+ view = MagicMock()
+ view.get_repo.return_value = commit.repository
+ view.get_commit.return_value = commit
+ return view
+
+ def uploads_per_commit_throttled(self, commit):
+ throttle_class = UploadsPerCommitThrottle()
+ view = self.set_view_obj(commit)
+ assert not throttle_class.allow_request(Mock(), view)
+
+ def uploads_per_window_throttled(self, commit):
+ throttle_class = UploadsPerWindowThrottle()
+ view = self.set_view_obj(commit)
+ assert not throttle_class.allow_request(Mock(), view)
+
+ def uploads_per_commit_not_throttled(self, commit):
+ throttle_class = UploadsPerCommitThrottle()
+ view = self.set_view_obj(commit)
+ assert throttle_class.allow_request(Mock(), view)
+
+ def uploads_per_window_not_throttled(self, commit):
+ throttle_class = UploadsPerWindowThrottle()
+ view = self.set_view_obj(commit)
+ assert throttle_class.allow_request(Mock(), view)
+
+ @override_settings(UPLOAD_THROTTLING_ENABLED=False)
+ def test_check_commit_constraints_settings_disabled(self):
+ repository = RepositoryFactory(
+ author__plan=DEFAULT_FREE_PLAN,
+ private=True,
+ author=self.owner,
+ )
+ first_commit = CommitFactory(repository=repository)
+ second_commit = CommitFactory(repository=repository)
+ third_commit = CommitFactory(repository__author=repository.author)
+ unrelated_commit = CommitFactory()
+
+ first_report = CommitReportFactory(
+ commit=first_commit, report_type=ReportType.COVERAGE.value
+ )
+ sec_report = CommitReportFactory(
+ commit=second_commit, report_type=ReportType.COVERAGE.value
+ )
+
+ for i in range(150):
+ first_upload = UploadFactory(report=first_report)
+ insert_coverage_measurement(
+ owner_id=self.owner.ownerid,
+ repo_id=repository.repoid,
+ commit_id=first_commit.id,
+ upload_id=first_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=first_report.report_type,
+ )
+ second_upload = UploadFactory(report=sec_report)
+ insert_coverage_measurement(
+ owner_id=self.owner.ownerid,
+ repo_id=repository.repoid,
+ commit_id=second_commit.id,
+ upload_id=second_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=sec_report.report_type,
+ )
+
+ # no commit should be throttled
+ self.request_should_not_throttle(first_commit)
+ self.request_should_not_throttle(second_commit)
+ self.request_should_not_throttle(third_commit)
+ self.request_should_not_throttle(unrelated_commit)
+
+ @override_settings(UPLOAD_THROTTLING_ENABLED=True)
+ def test_throttle_check_commit_constraints_settings_enabled(self):
+ author = self.owner
+ first_commit = CommitFactory.create(repository__author=author)
+
+ repository = RepositoryFactory.create(author=author, private=True)
+ second_commit = CommitFactory.create(repository=repository)
+ third_commit = CommitFactory.create(repository=repository)
+ fourth_commit = CommitFactory.create(repository=repository)
+
+ public_repository = RepositoryFactory.create(author=author, private=False)
+ public_repository_commit = CommitFactory.create(repository=public_repository)
+
+ unrelated_commit = CommitFactory.create()
+
+ second_report = CommitReportFactory.create(
+ commit=second_commit, report_type=ReportType.COVERAGE.value
+ )
+ fourth_report = CommitReportFactory.create(
+ commit=fourth_commit, report_type=ReportType.COVERAGE.value
+ )
+ self.request_should_not_throttle(third_commit)
+
+ for i in range(300):
+ upload = UploadFactory.create(report__commit__repository=public_repository)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=public_repository.repoid,
+ commit_id=second_commit.id,
+ upload_id=upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=public_repository.private,
+ report_type=second_report.report_type,
+ )
+ # ensuring public repos counts don't count towards the quota
+ self.request_should_not_throttle(third_commit)
+
+ for i in range(150):
+ second_upload = UploadFactory.create(report=second_report)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=repository.repoid,
+ commit_id=second_commit.id,
+ upload_id=second_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=second_report.report_type,
+ )
+ fourth_upload = UploadFactory.create(report=fourth_report)
+ insert_coverage_measurement(
+ owner_id=author.ownerid,
+ repo_id=repository.repoid,
+ commit_id=fourth_commit.id,
+ upload_id=fourth_upload.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=fourth_report.report_type,
+ )
+ # second and fourth commit already has uploads made, we won't block uploads to them
+ self.request_should_not_throttle(second_commit)
+ self.request_should_not_throttle(fourth_commit)
+
+ # unrelated commit belongs to a different user. Ensuring we don't block it
+ self.request_should_not_throttle(unrelated_commit)
+ # public repositories commit should never be throttled
+ self.request_should_not_throttle(public_repository_commit)
+
+ # third commit does not have uploads made, so we block it
+ self.uploads_per_window_throttled(third_commit)
+ # first commit belongs to a different repo, but same user
+ self.uploads_per_window_throttled(first_commit)
+
+ def test_validate_upload_too_many_uploads_for_commit(self):
+ par = [(151, 0, False), (151, 151, True), (0, 0, False), (0, 200, True)]
+ for totals_column_count, rows_count, should_raise in par:
+ owner = self.owner
+ repo = RepositoryFactory.create(author=owner)
+ commit = CommitFactory.create(
+ totals={"s": totals_column_count}, repository=repo
+ )
+ report = CommitReportFactory.create(commit=commit)
+ for i in range(rows_count):
+ UploadFactory.create(report=report)
+
+ if should_raise:
+ self.uploads_per_commit_throttled(commit)
+ else:
+ self.request_should_not_throttle(commit)
+
+ def test_validate_redis_counter(self):
+ redis = get_redis_connection()
+ owner = self.owner
+ cache_key = f"monthly_upload_usage_{owner.ownerid}"
+ redis.set(cache_key, 1, ex=259200)
+ repo = RepositoryFactory.create(author=owner)
+ commit = CommitFactory.create(totals={}, repository=repo)
+ self.request_should_not_throttle(commit)
+ assert redis.get(cache_key) == b"1"
+ redis.delete(cache_key)
diff --git a/apps/codecov-api/upload/tests/test_tokenless_azure.py b/apps/codecov-api/upload/tests/test_tokenless_azure.py
new file mode 100644
index 0000000000..536e591f4c
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_tokenless_azure.py
@@ -0,0 +1,90 @@
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from rest_framework.exceptions import NotFound
+
+from upload.tokenless.azure import TokenlessAzureHandler
+
+
+@pytest.fixture
+def upload_params():
+ return {
+ "job": "899861",
+ "project": "public",
+ "server_uri": "https://dev.azure.com/dnceng-public/",
+ "build": "20241219.14",
+ "commit": "0f6e31fec5876be932f9e52f739ce1a2e04f11e3",
+ }
+
+
+def test_verify_handles_nanosecond_timestamp(upload_params):
+ """
+ Test that the handler correctly processes timestamps with nanosecond precision
+ from the Azure DevOps API.
+ """
+ handler = TokenlessAzureHandler(upload_params)
+
+ # Mock a response with nanosecond precision timestamp (7 digits after decimal)
+ current_time = datetime.now()
+ timestamp = current_time.strftime("%Y-%m-%dT%H:%M:%S.1234567Z")
+
+ mock_build_response = {
+ "status": "completed",
+ "finishTime": timestamp,
+ "buildNumber": "20241219.14",
+ "sourceVersion": "0f6e31fec5876be932f9e52f739ce1a2e04f11e3",
+ "repository": {"type": "GitHub"},
+ }
+
+ with patch.object(handler, "get_build", return_value=mock_build_response):
+ service = handler.verify()
+ assert service == "github"
+
+
+def test_verify_handles_microsecond_timestamp(upload_params):
+ """
+ Test that the handler still works correctly with regular microsecond precision
+ timestamps.
+ """
+ handler = TokenlessAzureHandler(upload_params)
+
+ # Mock a response with microsecond precision (6 digits after decimal)
+ current_time = datetime.now()
+ timestamp = current_time.strftime("%Y-%m-%dT%H:%M:%S.123456Z")
+
+ mock_build_response = {
+ "status": "completed",
+ "finishTime": timestamp,
+ "buildNumber": "20241219.14",
+ "sourceVersion": "0f6e31fec5876be932f9e52f739ce1a2e04f11e3",
+ "repository": {"type": "GitHub"},
+ }
+
+ with patch.object(handler, "get_build", return_value=mock_build_response):
+ service = handler.verify()
+ assert service == "github"
+
+
+def test_verify_rejects_old_timestamp(upload_params):
+ """
+ Test that the handler correctly rejects timestamps older than 4 minutes,
+ even with nanosecond precision.
+ """
+ handler = TokenlessAzureHandler(upload_params)
+
+ # Create a timestamp that's more than 4 minutes old
+ old_time = datetime.now() - timedelta(minutes=5)
+ timestamp = old_time.strftime("%Y-%m-%dT%H:%M:%S.1234567Z")
+
+ mock_build_response = {
+ "status": "completed",
+ "finishTime": timestamp,
+ "buildNumber": "20241219.14",
+ "sourceVersion": "0f6e31fec5876be932f9e52f739ce1a2e04f11e3",
+ "repository": {"type": "GitHub"},
+ }
+
+ with patch.object(handler, "get_build", return_value=mock_build_response):
+ with pytest.raises(NotFound, match="Azure build has already finished"):
+ handler.verify()
diff --git a/apps/codecov-api/upload/tests/test_upload.py b/apps/codecov-api/upload/tests/test_upload.py
new file mode 100644
index 0000000000..5c6e3a9eb1
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_upload.py
@@ -0,0 +1,3226 @@
+import time
+from datetime import datetime, timedelta
+from json import dumps, loads
+from unittest.mock import ANY, PropertyMock, patch
+from urllib.parse import urlencode
+
+import pytest
+import requests
+import rest_framework
+from django.core.exceptions import MultipleObjectsReturned
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from freezegun import freeze_time
+from rest_framework import status
+from rest_framework.exceptions import NotFound, ValidationError
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.api_archive.archive import ArchiveService
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.plan.constants import TierName
+from shared.torngit.exceptions import (
+ TorngitClientGeneralError,
+ TorngitObjectNotFoundError,
+ TorngitRateLimitError,
+)
+from simplejson import JSONDecodeError
+
+from core.models import Commit
+from reports.tests.factories import CommitReportFactory, UploadFactory
+from upload.helpers import (
+ determine_repo_for_upload,
+ determine_upload_branch_to_use,
+ determine_upload_commit_to_use,
+ determine_upload_pr_to_use,
+ dispatch_upload_task,
+ get_global_tokens,
+ insert_commit,
+ parse_headers,
+ parse_params,
+ validate_upload,
+)
+from upload.tokenless.tokenless import TokenlessUploadHandler
+from utils.encryption import encryptor
+
+
+def mock_get_config_global_upload_tokens(*args):
+ if args == ("github", "global_upload_token"):
+ return "githubuploadtoken"
+ if args == ("gitlab", "global_upload_token"):
+ return "gitlabuploadtoken"
+ if args == ("bitbucket_server", "global_upload_token"):
+ return "bitbucketserveruploadtoken"
+
+
+class MockRedis:
+ def __init__(self, blacklisted=False, *args, **kwargs):
+ self.blacklisted = blacklisted
+ self.expected_task_key = kwargs.get("expected_task_key")
+ self.expected_task_arguments = kwargs.get("expected_task_arguments")
+ self.expected_expire_time = kwargs.get("expected_expire_time")
+
+ def rpush(self, key, value):
+ assert key == self.expected_task_key
+ assert value == dumps(self.expected_task_arguments)
+
+ def expire(self, key, expire_time):
+ assert key == self.expected_task_key
+ assert expire_time == self.expected_expire_time
+
+ def sismember(self, key, repoid):
+ return self.blacklisted
+
+ def get(self, key):
+ return 10
+
+ def setex(self, redis_key, expire_time, report):
+ return
+
+ def set(self, redis_key, value, **kwargs):
+ # This is only used when setting the cache key for the number of uploads. Will need to be refactored if we use it for something else.
+ assert self.get(redis_key) + 1 == value
+
+
+class UploadHandlerHelpersTest(TestCase):
+ def test_parse_params_validates_valid_input(self):
+ request_params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pull_request": "undefined",
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "name": "",
+ "branch": "HEAD",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": None,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ def test_parse_params_errors_for_invalid_input(self):
+ request_params = {
+ "version": "v5",
+ "slug": "not-a-valid-slug",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": 123,
+ "flags": "not_a_valid_flag!!?!",
+ "s3": "this should be an integer",
+ "build_url": "not a valid url!",
+ "_did_change_merge_commit": "yup",
+ "parent": 123,
+ }
+
+ with self.assertRaises(ValidationError) as err:
+ parse_params(request_params)
+
+ assert len(err.exception.detail) == 9
+
+ def test_parse_params_transforms_input(self):
+ request_params = {
+ "version": "v4",
+ "commit": "3BE5C52BD748C508a7e96993c02cf3518c816e84",
+ "slug": "codecov/subgroup/codecov-api",
+ "service": "travis-org",
+ "pull_request": "439",
+ "pr": "",
+ "branch": "origin/test-branch",
+ "travis_job_id": "travis-jobID",
+ "build": "nil",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84", # converted to lower case
+ "slug": "codecov/subgroup/codecov-api",
+ "owner": "codecov:subgroup", # extracted from slug
+ "repo": "codecov-api", # extracted from slug
+ "service": "travis", # "travis-org" converted to "travis"
+ "pr": "439", # populated from "pull_request" field since none was provided
+ "pull_request": "439",
+ "branch": "test-branch", # "origin/" removed from name
+ "job": "travis-jobID", # populated from "travis_job_id" since none was provided
+ "travis_job_id": "travis-jobID",
+ "build": None, # "nil" coerced to None
+ "using_global_token": False,
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ request_params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "pr": "967",
+ "pull_request": "null",
+ "branch": "refs/heads/another-test-branch",
+ "job": "jobID",
+ "travis_job_id": "travis-jobID",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "owner": None, # set to "None" since slug wasn't provided
+ "repo": None, # set to "None" since slug wasn't provided
+ "pr": "967", # not populated from "pull_request"
+ "pull_request": None, # "null" coerced to None
+ "branch": "another-test-branch", # "refs/heads" removed
+ "job": "jobID", # not populated from "travis_job_id"
+ "travis_job_id": "travis-jobID",
+ "using_global_token": False,
+ "service": None, # defaulted to None if not provided and not using global upload token
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ request_params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "",
+ "pr": "",
+ "pull_request": "156",
+ "job": None,
+ "travis_job_id": "travis-jobID",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "owner": None, # set to "None" since slug wasn't provided
+ "repo": None, # set to "None" since slug wasn't provided
+ "pr": "156", # populated from "pull_request"
+ "pull_request": "156",
+ "job": "travis-jobID", # populated from "travis_job_id"
+ "travis_job_id": "travis-jobID",
+ "service": None, # defaulted to None if not provided and not using global upload token
+ "using_global_token": False,
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ @patch("upload.helpers.get_config")
+ def test_parse_params_recognizes_global_token(self, mock_get_config):
+ mock_get_config.side_effect = mock_get_config_global_upload_tokens
+
+ request_params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "token": "bitbucketserveruploadtoken",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "token": "bitbucketserveruploadtoken",
+ "using_global_token": True,
+ "service": "bitbucket_server",
+ "job": None,
+ "owner": None,
+ "pr": None,
+ "repo": None,
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ @patch("upload.helpers.get_config")
+ def test_parse_params_recognizes_global_token_overrides_service(
+ self, mock_get_config
+ ):
+ mock_get_config.side_effect = mock_get_config_global_upload_tokens
+
+ request_params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "token": "bitbucketserveruploadtoken",
+ "service": "jenkins",
+ }
+
+ expected_result = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "token": "bitbucketserveruploadtoken",
+ "using_global_token": True,
+ "service": "bitbucket_server",
+ "job": None,
+ "owner": None,
+ "pr": None,
+ "repo": None,
+ }
+
+ parsed_params = parse_params(request_params)
+ assert expected_result == parsed_params
+
+ @patch("upload.helpers.get_config")
+ def test_get_global_tokens(self, mock_get_config):
+ mock_get_config.side_effect = mock_get_config_global_upload_tokens
+
+ expected_result = {
+ "githubuploadtoken": "github",
+ "gitlabuploadtoken": "gitlab",
+ "bitbucketserveruploadtoken": "bitbucket_server",
+ }
+
+ global_tokens = get_global_tokens()
+ assert expected_result == global_tokens
+
+ def test_determine_repo_upload(self):
+ with self.subTest("token found"):
+ org = OwnerFactory()
+ repo = RepositoryFactory(author=org)
+
+ params = {
+ "version": "v4",
+ "using_global_token": False,
+ "token": repo.upload_token,
+ }
+
+ assert repo == determine_repo_for_upload(params)
+
+ with self.subTest("token not found"):
+ org = OwnerFactory()
+ repo = RepositoryFactory(author=org)
+
+ params = {
+ "version": "v4",
+ "using_global_token": False,
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ }
+
+ with self.assertRaises(NotFound):
+ determine_repo_for_upload(params)
+
+ with self.subTest("missing token or service"):
+ params = {"version": "v4", "using_global_token": False, "service": None}
+
+ with self.assertRaises(ValidationError):
+ determine_repo_for_upload(params)
+
+ @patch.object(requests, "get")
+ def test_determine_repo_upload_tokenless(self, mock_get):
+ org = OwnerFactory(username="codecov", service="github")
+ repo = RepositoryFactory(author=org)
+ expected_response = {
+ "id": 732059764,
+ "finishTime": f"{datetime.now()}",
+ "status": "inProgress",
+ "sourceVersion": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "buildNumber": "732059764",
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:02:55Z",
+ "finished_at": f"{datetime.now()}".split(".")[0],
+ "project": {"visibility": "public", "repositoryType": "github"},
+ "triggerInfo": {"pr.sourceSha": "3be5c52bd748c508a7e96993c02cf3518c816e84"},
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ "jobs": [{"jobId": "732059764"}],
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "type": "GitHub",
+ "name": "python-standard",
+ "slug": f"{org.username}/{repo.name}",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": f"{org.username}/{repo.name}",
+ "owner": org.username,
+ "repo": repo.name,
+ "service": "travis",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": "732059764",
+ "build": "732059764",
+ "using_global_token": False,
+ "branch": None,
+ "project": "p12",
+ "server_uri": "https://dev.azure.com/example/",
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ assert repo == determine_repo_for_upload(params)
+
+ params["service"] = "appveyor"
+
+ assert repo == determine_repo_for_upload(params)
+
+ params["service"] = "azure_pipelines"
+
+ assert repo == determine_repo_for_upload(params)
+
+ def test_determine_upload_branch_to_use(self):
+ with self.subTest("no branch and no pr provided"):
+ upload_params = {"branch": None, "pr": None}
+ repo_default_branch = "defaultbranch"
+
+ expected_value = "defaultbranch"
+ assert expected_value == determine_upload_branch_to_use(
+ upload_params, repo_default_branch
+ )
+
+ with self.subTest("pullid in branch name"):
+ upload_params = {"branch": "pr/123", "pr": None}
+ repo_default_branch = "defaultbranch"
+
+ expected_value = None
+ assert expected_value == determine_upload_branch_to_use(
+ upload_params, repo_default_branch
+ )
+
+ with self.subTest("branch and no pr provided"):
+ upload_params = {"branch": "uploadbranch", "pr": None}
+ repo_default_branch = "defaultbranch"
+
+ expected_value = "uploadbranch"
+ assert expected_value == determine_upload_branch_to_use(
+ upload_params, repo_default_branch
+ )
+
+ with self.subTest("branch and pr provided"):
+ upload_params = {"branch": "uploadbranch", "pr": "123"}
+ repo_default_branch = "defaultbranch"
+
+ expected_value = "uploadbranch"
+ assert expected_value == determine_upload_branch_to_use(
+ upload_params, repo_default_branch
+ )
+
+ def test_determine_upload_pr_to_use(self):
+ with self.subTest("pullid in branch"):
+ upload_params = {"branch": "pr/123", "pr": "456"}
+
+ expected_value = "123"
+ assert expected_value == determine_upload_pr_to_use(upload_params)
+
+ with self.subTest("pullid in arguments, no pullid in branch"):
+ upload_params = {"branch": "uploadbranch", "pr": "456"}
+
+ expected_value = "456"
+ assert expected_value == determine_upload_pr_to_use(upload_params)
+
+ with self.subTest("pullid not provided"):
+ upload_params = {"branch": "uploadbranch", "pr": None}
+
+ expected_value = None
+ assert expected_value == determine_upload_pr_to_use(upload_params)
+
+ with self.subTest("pullid not provided, branch not provided"):
+ upload_params = {"branch": None, "pr": None}
+
+ expected_value = None
+ assert expected_value == determine_upload_pr_to_use(upload_params)
+
+ with self.subTest("pullid set to true"):
+ upload_params = {"branch": None, "pr": "true"}
+
+ expected_value = None
+ assert expected_value == determine_upload_pr_to_use(upload_params)
+
+ @patch("upload.helpers.RepoProviderService")
+ @patch("upload.helpers._get_git_commit_data")
+ def test_determine_upload_commit_to_use(
+ self, mock_repo_provider_service, mock_async
+ ):
+ mock_repo_provider_service.return_value = {
+ "message": "Merge 1c78206f1a46dc6db8412a491fc770eb7d0f8a47 into 261aa931e8e3801ad95a31bbc3529de2bba436c8"
+ }
+
+ with self.subTest("not a github commit"):
+ org = OwnerFactory(
+ service="bitbucket",
+ oauth_token=encryptor.encode("hahahahaha").decode(),
+ )
+ repo = RepositoryFactory(author=org)
+ upload_params = {
+ "service": "bitbucket",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ }
+ assert (
+ "3be5c52bd748c508a7e96993c02cf3518c816e84"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ with self.subTest("merge commit"):
+ org = OwnerFactory(
+ service="github",
+ oauth_token=encryptor.encode("hahahahaha").decode(),
+ )
+ repo = RepositoryFactory(author=org)
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ }
+ # Should use id from merge commit message, not from params
+ assert (
+ "1c78206f1a46dc6db8412a491fc770eb7d0f8a47"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ with self.subTest("just no bot available"):
+ org = OwnerFactory(service="github", oauth_token=None)
+ repo = RepositoryFactory(author=org, private=True)
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ }
+ # Should use id from merge commit message, not from params
+ assert (
+ "3084886b7ff869dcf327ad1d28a8b7d34adc7584"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ with self.subTest("merge commit with did_change_merge_commit argument"):
+ org = OwnerFactory(
+ service="github",
+ oauth_token=encryptor.encode("hahahahaha").decode(),
+ )
+ repo = RepositoryFactory(author=org)
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ "_did_change_merge_commit": True,
+ }
+ # Should use the commit id provided in params, not the one from the commit message
+ assert (
+ "3084886b7ff869dcf327ad1d28a8b7d34adc7584"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ with self.subTest("use repo bot token when available"):
+ bot = OwnerFactory()
+ org = OwnerFactory(service="github")
+ repo = RepositoryFactory(author=org, bot=bot)
+
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ }
+
+ determine_upload_commit_to_use(upload_params, repo)
+
+ assert (
+ "1c78206f1a46dc6db8412a491fc770eb7d0f8a47"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ mock_async.side_effect = [TorngitClientGeneralError(500, None, None)]
+
+ with self.subTest("HTTP error"):
+ org = OwnerFactory(service="github")
+ repo = RepositoryFactory(author=org)
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ "_did_change_merge_commit": False,
+ }
+ assert (
+ "3084886b7ff869dcf327ad1d28a8b7d34adc7584"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ mock_async.side_effect = [TorngitObjectNotFoundError(500, None)]
+
+ with self.subTest("HTTP error"):
+ org = OwnerFactory(service="github")
+ repo = RepositoryFactory(author=org)
+ upload_params = {
+ "service": "github",
+ "commit": "3084886b7ff869dcf327ad1d28a8b7d34adc7584",
+ "_did_change_merge_commit": False,
+ }
+ assert (
+ "3084886b7ff869dcf327ad1d28a8b7d34adc7584"
+ == determine_upload_commit_to_use(upload_params, repo)
+ )
+
+ def test_insert_commit(self):
+ org = OwnerFactory()
+ repo = RepositoryFactory(author=org)
+
+ with self.subTest("newly created"):
+ insert_commit(
+ "3084886b7ff869dcf327ad1d28a8b7d34adc7584", "test", "123", repo, org
+ )
+
+ commit = Commit.objects.get(
+ commitid="3084886b7ff869dcf327ad1d28a8b7d34adc7584"
+ )
+ assert commit.repository == repo
+ assert commit.state == "pending"
+ assert commit.branch == "test"
+ assert commit.pullid == 123
+ assert commit.merged == False
+ assert commit.parent_commit_id is None
+
+ with self.subTest("commit already in database"):
+ CommitFactory(
+ commitid="1c78206f1a46dc6db8412a491fc770eb7d0f8a47",
+ branch="apples",
+ pullid="456",
+ repository=repo,
+ parent_commit_id=None,
+ )
+ # parent_commit_id and branch should be updated
+ insert_commit(
+ "1c78206f1a46dc6db8412a491fc770eb7d0f8a47",
+ "oranges",
+ "123",
+ repo,
+ org,
+ parent_commit_id="different_parent_commit",
+ )
+
+ commit = Commit.objects.get(
+ commitid="1c78206f1a46dc6db8412a491fc770eb7d0f8a47"
+ )
+ assert commit.repository == repo
+ assert commit.branch == "oranges"
+ assert commit.pullid == 456
+ assert commit.merged is None
+ assert commit.parent_commit_id == "different_parent_commit"
+
+ with self.subTest("parent provided"):
+ parent = CommitFactory()
+ insert_commit(
+ "8458a8c72aafb5fb4c5cd58f467a2f71298f1b61",
+ "test",
+ None,
+ repo,
+ org,
+ parent_commit_id=parent.commitid,
+ )
+
+ commit = Commit.objects.get(
+ commitid="8458a8c72aafb5fb4c5cd58f467a2f71298f1b61"
+ )
+ assert commit.repository == repo
+ assert commit.branch == "test"
+ assert commit.pullid is None
+ assert commit.merged is None
+ assert commit.parent_commit_id == parent.commitid
+
+ def test_parse_request_headers(self):
+ with self.subTest("Invalid content disposition"):
+ with self.assertRaises(ValidationError):
+ parse_headers({"Content_Disposition": "not inline"}, {"version": "v2"})
+
+ with self.subTest("v2"):
+ assert parse_headers(
+ {"Content-Disposition": "inline"}, {"version": "v2"}
+ ) == {"content_type": "application/x-gzip", "reduced_redundancy": False}
+
+ with self.subTest("v4"):
+ assert parse_headers(
+ {"X_Content_Type": "text/html", "X_Reduced_Redundancy": "false"},
+ {"version": "v4"},
+ ) == {"content_type": "text/plain", "reduced_redundancy": False}
+
+ assert parse_headers(
+ {"X_Content_Type": "plain/text", "X_Reduced_Redundancy": "true"},
+ {"version": "v4"},
+ ) == {"content_type": "plain/text", "reduced_redundancy": True}
+
+ assert parse_headers(
+ {
+ "X_Content_Type": "application/x-gzip",
+ "X_Reduced_Redundancy": "true",
+ },
+ {"version": "v4", "package": "node"},
+ ) == {"content_type": "application/x-gzip", "reduced_redundancy": False}
+
+ with self.subTest("Unsafe content type"):
+ assert parse_headers(
+ {
+ "Content_Disposition": None,
+ "X_Content_Type": "multipart/form-data",
+ },
+ {"version": "v4"},
+ ) == {"content_type": "text/plain", "reduced_redundancy": True}
+
+ def test_validate_upload_repository_moved(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="users-free")
+ repo = RepositoryFactory(author=owner, name="")
+ commit = CommitFactory()
+
+ with self.assertRaises(ValidationError) as err:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+
+ assert (
+ err.exception.detail[0]
+ == "This repository has moved or was deleted. Please login to Codecov to retrieve a new upload token."
+ )
+
+ def test_validate_upload_empty_totals(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="5m")
+ repo = RepositoryFactory(author=owner)
+ commit = CommitFactory(totals=None, repository=repo)
+
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ repo.refresh_from_db()
+ assert repo.activated == True
+ assert repo.active == True
+ assert repo.deleted == False
+
+ def test_validate_upload_too_many_uploads_for_commit(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="users-free")
+ repo = RepositoryFactory(author=owner)
+ commit = CommitFactory(totals={"s": 151}, repository=repo)
+ report = CommitReportFactory.create(commit=commit)
+ for i in range(151):
+ UploadFactory.create(report=report)
+
+ with self.assertRaises(ValidationError) as err:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert err.exception.detail[0] == "Too many uploads to this commit."
+
+ def test_validate_upload_repository_blacklisted(self):
+ redis = MockRedis(blacklisted=True)
+ owner = OwnerFactory(plan="users-free")
+ repo = RepositoryFactory(author=owner)
+ commit = CommitFactory()
+
+ with self.assertRaises(ValidationError) as err:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert (
+ err.exception.detail[0]
+ == "Uploads rejected for this project. Please contact Codecov staff for more details. Sorry for the inconvenience."
+ )
+
+ def test_validate_upload_per_repo_billing_invalid(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="1m")
+ RepositoryFactory(author=owner, private=True, activated=True, active=True)
+ repo = RepositoryFactory(
+ author=owner, private=True, activated=False, active=False
+ )
+ commit = CommitFactory()
+
+ with self.assertRaises(ValidationError) as err:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert (
+ err.exception.detail[0]
+ == "Sorry, but this team has no private repository credits left."
+ )
+
+ def test_validate_upload_gitlab_subgroups(self):
+ redis = MockRedis()
+ parent_group = OwnerFactory(plan="1m", parent_service_id=None, service="gitlab")
+ top_subgroup = OwnerFactory(
+ plan="1m",
+ parent_service_id=parent_group.service_id,
+ service="gitlab",
+ )
+ bottom_subgroup = OwnerFactory(
+ plan="1m",
+ parent_service_id=top_subgroup.service_id,
+ service="gitlab",
+ )
+ RepositoryFactory(
+ author=parent_group, private=True, activated=True, active=True
+ )
+ repo = RepositoryFactory(author=bottom_subgroup, private=True, activated=False)
+ commit = CommitFactory()
+
+ with self.assertRaises(ValidationError) as err:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert (
+ err.exception.detail[0]
+ == "Sorry, but this team has no private repository credits left."
+ )
+
+ def test_validate_upload_valid_upload_repo_not_activated(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="users-free")
+ repo = RepositoryFactory(
+ author=owner,
+ private=True,
+ activated=False,
+ deleted=False,
+ active=False,
+ )
+ commit = CommitFactory()
+
+ with patch(
+ "services.analytics.AnalyticsService.account_activated_repository_on_upload"
+ ) as mock_segment_event:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert mock_segment_event.called
+
+ repo.refresh_from_db()
+ assert repo.activated == True
+ assert repo.active == True
+ assert repo.deleted == False
+
+ def test_validate_upload_valid_upload_repo_activated(self):
+ redis = MockRedis()
+ owner = OwnerFactory(plan="5m")
+ repo = RepositoryFactory(author=owner, private=True, activated=True)
+ commit = CommitFactory()
+
+ with patch(
+ "services.analytics.AnalyticsService.account_activated_repository_on_upload"
+ ) as mock_segment_event:
+ validate_upload({"commit": commit.commitid}, repo, redis)
+ assert not mock_segment_event.called
+
+ repo.refresh_from_db()
+ assert repo.activated == True
+ assert repo.active == True
+ assert repo.deleted == False
+
+ @freeze_time("2023-01-01T00:00:00")
+ @patch("services.task.TaskService.upload")
+ def test_dispatch_upload_task(self, upload):
+ repo = RepositoryFactory()
+ task_arguments = {
+ "commit": "commit123",
+ "version": "v4",
+ "report_code": "local_report",
+ }
+
+ expected_key = f"uploads/{repo.repoid}/commit123"
+
+ redis = MockRedis(
+ expected_task_key=expected_key,
+ expected_task_arguments=task_arguments,
+ expected_expire_time=86400,
+ )
+
+ dispatch_upload_task(task_arguments, repo, redis)
+ upload.assert_called_once_with(
+ repoid=repo.repoid,
+ commitid=task_arguments.get("commit"),
+ report_type="coverage",
+ report_code="local_report",
+ arguments=task_arguments,
+ countdown=4,
+ )
+
+
+class UploadHandlerRouteTest(APITestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(self, mocker):
+ self.mocker = mocker
+
+ # Wrap client calls
+ def _get(self, kwargs=None):
+ return self.client.get(reverse("upload-handler", kwargs=kwargs))
+
+ def _options(self, kwargs=None, data=None):
+ return self.client.options(reverse("upload-handler", kwargs=kwargs))
+
+ def _post(
+ self,
+ kwargs=None,
+ data=None,
+ query=None,
+ content_type="application/json",
+ headers=None,
+ ):
+ headers = headers or {}
+ query_string = f"?{urlencode(query)}" if query else ""
+ url = reverse("upload-handler", kwargs=kwargs) + query_string
+ return self.client.post(url, data=data, content_type=content_type, **headers)
+
+ def _post_slash(
+ self,
+ kwargs=None,
+ data=None,
+ query=None,
+ content_type="application/json",
+ headers=None,
+ ):
+ headers = headers or {}
+ query_string = f"?{urlencode(query)}" if query else ""
+ url = "/upload/v2/" + query_string
+ return self.client.post(url, data=data, content_type=content_type, **headers)
+
+ def setUp(self):
+ tier = TierFactory(tier_name=TierName.BASIC.value)
+ plan = PlanFactory(tier=tier, is_active=True)
+ self.org = OwnerFactory(
+ plan=plan.name, username="codecovtest", service="github"
+ )
+ self.repo = RepositoryFactory(
+ author=self.org,
+ name="upload-test-repo",
+ upload_token="a03e5d02-9495-4413-b0d8-05651bb2e842",
+ )
+
+ def test_get_request_returns_405(self):
+ response = self._get(kwargs={"version": "v4"})
+
+ assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+
+ # Test headers
+ def test_options_headers(self):
+ response = self._options(kwargs={"version": "v2"})
+
+ headers = response.headers
+
+ assert headers["accept"] == "text/*"
+ assert headers["access-control-allow-origin"] == "*"
+ assert headers["access-control-allow-method"] == "POST"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+
+ def test_invalid_request_params(self):
+ query_params = {"pr": 9838, "flags": "flags!!!", "package": "codecov-cli/0.0.0"}
+
+ response = self._post(kwargs={"version": "v5"}, query=query_params)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_invalid_request_params_uploader_package(self):
+ query_params = {"pr": 9838, "flags": "flags!!!", "package": "uploader-0.0.0"}
+
+ response = self._post(kwargs={"version": "v5"}, query=query_params)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_invalid_request_params_invalid_package(self):
+ query_params = {"pr": 9838, "flags": "flags!!!", "package": ""}
+
+ response = self._post(kwargs={"version": "v5"}, query=query_params)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("shared.api_archive.archive.ArchiveService.write_file")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @override_settings(CODECOV_DASHBOARD_URL="https://app.codecov.io")
+ def test_successful_upload_v2(
+ self,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_write_file,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post(
+ kwargs={"version": "v2"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 200
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ archive_service = ArchiveService(self.repo)
+ datetime = timezone.now().strftime("%Y-%m-%d")
+ repo_hash = archive_service.get_archive_hash(self.repo)
+ expected_url = f"v4/raw/{datetime}/{repo_hash}/b521e55aef79b101f48e2544837ca99a7fa3bf6b/dec1f00b-1883-40d0-afd6-6dcb876510be.txt"
+
+ mock_write_file.assert_called_with(
+ expected_url, b"coverage report", is_already_gzipped=False
+ )
+ assert mock_dispatch_upload.call_args[0][0] == {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "version": "v2",
+ "service": None,
+ "owner": None,
+ "repo": None,
+ "using_global_token": False,
+ "build_url": None,
+ "branch": None,
+ "reportid": "dec1f00b-1883-40d0-afd6-6dcb876510be",
+ "url": expected_url,
+ "job": None,
+ }
+
+ result = loads(response.content)
+ assert result["message"] == "Coverage reports upload successfully"
+ assert result["uploaded"] == True
+ assert result["queued"] == True
+ assert result["id"] == "dec1f00b-1883-40d0-afd6-6dcb876510be"
+ assert (
+ result["url"]
+ == "https://app.codecov.io/github/codecovtest/upload-test-repo/commit/b521e55aef79b101f48e2544837ca99a7fa3bf6b"
+ )
+
+ @patch("shared.api_archive.archive.ArchiveService.write_file")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @override_settings(CODECOV_DASHBOARD_URL="https://app.codecov.io")
+ def test_successful_upload_v2_slash(
+ self,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_write_file,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post_slash(
+ kwargs={"version": "v2"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 200
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ archive_service = ArchiveService(self.repo)
+ datetime = timezone.now().strftime("%Y-%m-%d")
+ repo_hash = archive_service.get_archive_hash(self.repo)
+ expected_url = f"v4/raw/{datetime}/{repo_hash}/b521e55aef79b101f48e2544837ca99a7fa3bf6b/dec1f00b-1883-40d0-afd6-6dcb876510be.txt"
+
+ mock_write_file.assert_called_with(
+ expected_url, b"coverage report", is_already_gzipped=False
+ )
+ assert mock_dispatch_upload.call_args[0][0] == {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "version": "v2",
+ "service": None,
+ "owner": None,
+ "repo": None,
+ "using_global_token": False,
+ "build_url": None,
+ "branch": None,
+ "reportid": "dec1f00b-1883-40d0-afd6-6dcb876510be",
+ "url": expected_url,
+ "job": None,
+ }
+
+ result = loads(response.content)
+ assert result["message"] == "Coverage reports upload successfully"
+ assert result["uploaded"] == True
+ assert result["queued"] == True
+ assert result["id"] == "dec1f00b-1883-40d0-afd6-6dcb876510be"
+ assert (
+ result["url"]
+ == "https://app.codecov.io/github/codecovtest/upload-test-repo/commit/b521e55aef79b101f48e2544837ca99a7fa3bf6b"
+ )
+
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.determine_repo_for_upload")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @override_settings(CODECOV_DASHBOARD_URL="https://app.codecov.io")
+ def test_repo_validation_error_v2(
+ self,
+ mock_repo_provider_service,
+ mock_determine_repo_for_upload,
+ mock_uuid4,
+ mock_get_redis,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_determine_repo_for_upload.side_effect = ValidationError(
+ "Unable to determine repo and owner"
+ )
+
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post_slash(
+ kwargs={"version": "v2"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 400
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ assert response.content == b"Could not determine repo and owner"
+
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.determine_repo_for_upload")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @override_settings(CODECOV_DASHBOARD_URL="https://app.codecov.io")
+ def test_too_many_repos_found_v2(
+ self,
+ mock_repo_provider_service,
+ mock_determine_repo_for_upload,
+ mock_uuid4,
+ mock_get_redis,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_determine_repo_for_upload.side_effect = MultipleObjectsReturned(
+ "Found too many repos"
+ )
+
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post_slash(
+ kwargs={"version": "v2"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 400
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ assert response.content == b"Found too many repos"
+
+ @patch("shared.api_archive.archive.ArchiveService.create_presigned_put")
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_upload_v4(
+ self,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_hash,
+ mock_storage_put,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ path = "/".join(
+ (
+ "v4/raw",
+ timezone.now().strftime("%Y-%m-%d"),
+ "awawaw",
+ "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ )
+ )
+
+ mock_storage_put.return_value = path + "?AWS=PARAMS"
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_hash.return_value = "awawaw"
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post(
+ kwargs={"version": "v4"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 200
+
+ @patch("shared.api_archive.archive.ArchiveService.create_presigned_put")
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ def test_upload_v4_with_upload_token_header(
+ self,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_hash,
+ mock_storage_put,
+ ):
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ path = "/".join(
+ (
+ "v4/raw",
+ timezone.now().strftime("%Y-%m-%d"),
+ "awawaw",
+ "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ )
+ )
+
+ mock_storage_put.return_value = path + "?AWS=PARAMS"
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_hash.return_value = "awawaw"
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post(
+ kwargs={"version": "v4"},
+ query=query_params,
+ data="coverage report",
+ headers={"HTTP_X_UPLOAD_TOKEN": "a03e5d02-9495-4413-b0d8-05651bb2e842"},
+ )
+
+ assert response.status_code == 200
+
+ @patch("shared.api_archive.archive.ArchiveService.create_presigned_put")
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @patch("upload.views.legacy.determine_repo_for_upload")
+ def test_repo_validation_error_v4(
+ self,
+ mock_determine_repo_for_upload,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_hash,
+ mock_storage_put,
+ ):
+ mock_determine_repo_for_upload.side_effect = ValidationError(
+ "Unable to determine repo and owner"
+ )
+
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ path = "/".join(
+ (
+ "v4/raw",
+ timezone.now().strftime("%Y-%m-%d"),
+ "awawaw",
+ "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ )
+ )
+
+ mock_storage_put.return_value = path + "?AWS=PARAMS"
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_hash.return_value = "awawaw"
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post(
+ kwargs={"version": "v4"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 400
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ assert response.content == b"Could not determine repo and owner"
+
+ @patch("shared.api_archive.archive.ArchiveService.create_presigned_put")
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("upload.views.legacy.get_redis_connection")
+ @patch("upload.views.legacy.uuid4")
+ @patch("upload.views.legacy.dispatch_upload_task")
+ @patch("services.repo_providers.RepoProviderService.get_adapter")
+ @patch("upload.views.legacy.determine_repo_for_upload")
+ def test_too_many_repos_found_v4(
+ self,
+ mock_determine_repo_for_upload,
+ mock_repo_provider_service,
+ mock_dispatch_upload,
+ mock_uuid4,
+ mock_get_redis,
+ mock_hash,
+ mock_storage_put,
+ ):
+ mock_determine_repo_for_upload.side_effect = MultipleObjectsReturned(
+ "Found too many repos"
+ )
+
+ class MockRepoProviderAdapter:
+ async def get_commit(self, commit, token):
+ return {"message": "This is not a merge commit"}
+
+ path = "/".join(
+ (
+ "v4/raw",
+ timezone.now().strftime("%Y-%m-%d"),
+ "awawaw",
+ "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ )
+ )
+
+ mock_storage_put.return_value = path + "?AWS=PARAMS"
+ mock_get_redis.return_value = MockRedis()
+ mock_repo_provider_service.return_value = MockRepoProviderAdapter()
+ mock_uuid4.return_value = (
+ "dec1f00b-1883-40d0-afd6-6dcb876510be" # this will be the reportid
+ )
+ mock_hash.return_value = "awawaw"
+ query_params = {
+ "commit": "b521e55aef79b101f48e2544837ca99a7fa3bf6b",
+ "token": "a03e5d02-9495-4413-b0d8-05651bb2e842",
+ "pr": "456",
+ "branch": "",
+ "flags": "",
+ "build_url": "",
+ "package": "",
+ }
+
+ response = self._post(
+ kwargs={"version": "v4"}, query=query_params, data="coverage report"
+ )
+
+ assert response.status_code == 400
+
+ headers = response.headers
+
+ assert headers["access-control-allow-origin"] == "*"
+ assert (
+ headers["access-control-allow-headers"]
+ == "Origin, Content-Type, Accept, X-User-Agent"
+ )
+ assert headers["content-type"] != "text/plain"
+
+ assert response.content == b"Found too many repos"
+
+
+class UploadHandlerTravisTokenlessTest(TestCase):
+ @patch.object(requests, "get")
+ def test_travis_no_slug_match(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/python-standard",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "2485b28f9862e98bcee576f02d8b37e6433f8c30",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("something", params).verify_upload()
+
+ @patch.object(requests, "get")
+ def test_travis_no_sha_match(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/codecov-api",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "2485b28f9862e98bcee576f02d8b37e6433f8c30",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_travis_no_event_match(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/codecov-api",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "2485b28f9862e98bcee576f02d8b37e6433f8c30",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_travis_failed_requests(self, mock_get):
+ mock_get.side_effect = [
+ requests.exceptions.ConnectionError("Not found"),
+ requests.exceptions.ConnectionError("Not found"),
+ ]
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_travis_failed_requests_connection_error(self, mock_get):
+ mock_get.side_effect = [
+ requests.exceptions.HTTPError("Not found"),
+ requests.exceptions.HTTPError("Not found"),
+ ]
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_travis_failed_requests_connection_error_ex(self, mock_get):
+ mock_get.side_effect = [
+ Exception("Not found"),
+ requests.exceptions.HTTPError("Not found"),
+ ]
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_build_not_in_progress(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": None,
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/codecov-api",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: The build status does not indicate that the current build is in progress. Please make sure the build is in progress or was finished within the past 4 minutes to ensure reports upload properly."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_travis_no_job(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.HTTPError("Not found"), None]
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: Tokenless uploads are only supported for public repositories on Travis that can be verified through the Travis API. Please use an upload token if your repository is private and specify it via the -t flag. You can find the token for this repository at the url below on codecov.io (login required):
+
+ Repo token: https://codecov.io/gh/codecov/codecov-api/settings
+ Documentation: https://docs.codecov.io/docs/about-the-codecov-bash-uploader#section-upload-token"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_success(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:02:55Z",
+ "finished_at": f"{datetime.now()}".split(".")[0],
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/codecov-api",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ res = TokenlessUploadHandler("travis", params).verify_upload()
+
+ assert res == "github"
+
+ @patch.object(requests, "get")
+ def test_expired_build(self, mock_get):
+ expected_response = {
+ "id": 732059764,
+ "allow_failure": None,
+ "number": "498.1",
+ "state": "passed",
+ "started_at": "2020-10-01T20:02:55Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "build": {
+ "@type": "build",
+ "@href": "/build/732059763",
+ "@representation": "minimal",
+ "id": 732059763,
+ "number": "498",
+ "state": "passed",
+ "duration": 84,
+ "event_type": "push",
+ "previous_state": "passed",
+ "pull_request_title": None,
+ "pull_request_number": None,
+ "started_at": "2020-10-01T20:01:31Z",
+ "finished_at": "2020-10-01T20:02:55Z",
+ "private": False,
+ "priority": False,
+ },
+ "queue": "builds.gce",
+ "repository": {
+ "@type": "repository",
+ "@href": "/repo/25205338",
+ "@representation": "minimal",
+ "id": 25205338,
+ "name": "python-standard",
+ "slug": "codecov/codecov-api",
+ },
+ "commit": {
+ "@type": "commit",
+ "@representation": "minimal",
+ "id": 226208830,
+ "sha": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "ref": "refs/heads/master",
+ "message": "New Build: 10/01/20 20:00:54",
+ "compare_url": "https://github.com/codecov/python-standard/compare/28392734979c...2485b28f9862",
+ "committed_at": "2020-10-01T20:00:55Z",
+ },
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "version": "v4",
+ "commit": "3be5c52bd748c508a7e96993c02cf3518c816e84",
+ "slug": "codecov/codecov-api",
+ "owner": "codecov",
+ "repo": "codecov-api",
+ "token": "4a24929b-9276-4784-8e85-a7a008a32037",
+ "service": "circleci",
+ "pr": None,
+ "pull_request": None,
+ "flags": "this-is-a-flag,this-is-another-flag",
+ "param_doesn't_exist_but_still_should_not_error": True,
+ "s3": 123,
+ "build_url": "https://thisisabuildurl.com",
+ "job": 732059764,
+ "using_global_token": False,
+ "branch": None,
+ "_did_change_merge_commit": False,
+ "parent": "123abc",
+ }
+
+ expected_error = """
+ ERROR: The coverage upload was rejected because the build is out of date. Please make sure the build is not stale for uploads to process correctly."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("travis", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+
+class UploadHandlerAzureTokenlessTest(TestCase):
+ def test_azure_no_job(self):
+ params = {}
+
+ expected_error = """Missing "job" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_azure_no_project(self):
+ params = {"job": 732059764}
+
+ expected_error = """Missing "project" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_azure_no_server_uri(self):
+ params = {"project": "project123", "job": 732059764}
+
+ expected_error = """Missing "server_uri" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_azure_invalid_server_uri(self):
+ expected_error = """Unable to locate build via Azure API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/missing_trailing_slash",
+ }
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ params["server_uri"] = "https://missing_trailing_slash.visualstudio.com"
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ params["server_uri"] = "https://example.visualstudio.com.attacker.com/"
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ params["server_uri"] = "https://dev.azure.com.attacker.com/example"
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ params["server_uri"] = "https://dev.azure.attacker.com/example"
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_http_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.HTTPError("Not found")]
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ }
+
+ expected_error = """Unable to locate build via Azure API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_connection_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.ConnectionError("Not found")]
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ }
+
+ expected_error = """Unable to locate build via Azure API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_no_errors(self, mock_get):
+ expected_response = {
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public"},
+ "repository": {"type": "GitHub"},
+ "triggerInfo": {"pr.sourceSha": "c739768fcac68144a3a6d82305b9c4106934d31a"},
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ res = TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+
+ assert res == "github"
+
+ @patch.object(requests, "get")
+ def test_azure_wrong_build_number(self, mock_get):
+ expected_response = {
+ "finishTime": f"{datetime.now()}",
+ "buildNumber": "BADBUILDNUM",
+ "status": "completed",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public"},
+ "repository": {"type": "GitHub"},
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Build numbers do not match. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_expired_build(self, mock_get):
+ expected_response = {
+ "finishTime": f"{datetime.now() - timedelta(minutes=4)}",
+ "buildNumber": "20190725.8",
+ "status": "completed",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public"},
+ "repository": {"type": "GitHub"},
+ }
+
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Azure build has already finished. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_invalid_status(self, mock_get):
+ expected_response = {
+ "finishTime": f"{datetime.now()}",
+ "buildNumber": "20190725.8",
+ "status": "BADSTATUS",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public"},
+ "repository": {"type": "GitHub"},
+ }
+
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Azure build has already finished. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_wrong_commit(self, mock_get):
+ expected_response = {
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "BADSHA",
+ "project": {"visibility": "public"},
+ "repository": {"type": "GitHub"},
+ }
+
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Commit sha does not match Azure build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_not_public(self, mock_get):
+ expected_response = """
+
+
+ Azure DevOps Services | Sign In
+
+ """
+
+ mock_get.return_value.status_code.return_value = 203
+ mock_get.return_value.json.side_effect = JSONDecodeError(
+ "Expecting value: line 1 column 1", expected_response, 0
+ )
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Unable to locate build via Azure API. Project is likely private, please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_azure_wrong_service_type(self, mock_get):
+ expected_response = {
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public"},
+ "repository": {"type": "BADREPOTYPE"},
+ }
+
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": 732059764,
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Sorry this service is not supported. Codecov currently only works with GitHub, GitLab, and BitBucket repositories"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("azure_pipelines", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+
+class UploadHandlerAppveyorTokenlessTest(TestCase):
+ def test_appveyor_no_job(self):
+ params = {}
+
+ expected_error = """Missing "job" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("appveyor", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_appveyor_http_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.HTTPError("Not found")]
+
+ params = {
+ "project": "project123",
+ "job": "732059764",
+ "server_uri": "https://dev.azure.com/example/",
+ }
+
+ expected_error = """Unable to locate build via Appveyor API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("appveyor", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_appveyor_connection_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.ConnectionError("Not found")]
+
+ params = {
+ "project": "project123",
+ "job": "something/else/732059764",
+ "server_uri": "https://dev.azure.com/example/",
+ }
+
+ expected_error = """Unable to locate build via Appveyor API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("appveyor", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_appveyor_finished_build(self, mock_get):
+ expected_response = {
+ "build": {"jobs": [{"jobId": "732059764"}]},
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public", "repositoryType": "github"},
+ "repository": {"type": "GitHub"},
+ "triggerInfo": {"pr.sourceSha": "c739768fcac68144a3a6d82305b9c4106934d31a"},
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": "732059764",
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "20190725.8",
+ }
+
+ expected_error = """Build already finished, unable to accept new reports. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("appveyor", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_appveyor_no_errors(self, mock_get):
+ expected_response = {
+ "build": {"jobs": [{"jobId": "732059764"}]},
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public", "repositoryType": "github"},
+ "repository": {"type": "GitHub"},
+ "triggerInfo": {"pr.sourceSha": "c739768fcac68144a3a6d82305b9c4106934d31a"},
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": "732059764",
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "732059764",
+ }
+
+ res = TokenlessUploadHandler("appveyor", params).verify_upload()
+
+ assert res == "github"
+
+ @patch.object(requests, "get")
+ def test_appveyor_invalid_service(self, mock_get):
+ expected_response = {
+ "build": {"jobs": [{"jobId": "732059764"}]},
+ "finishTime": "NOW",
+ "buildNumber": "20190725.8",
+ "status": "inProgress",
+ "sourceVersion": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "project": {"visibility": "public", "repositoryType": "gitthub"},
+ "repository": {"type": "GittHub"},
+ "triggerInfo": {"pr.sourceSha": "c739768fcac68144a3a6d82305b9c4106934d31a"},
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "project": "project123",
+ "job": "732059764",
+ "server_uri": "https://dev.azure.com/example/",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "build": "732059764",
+ }
+ expected_error = """Sorry this service is not supported. Codecov currently only works with GitHub, GitLab, and BitBucket repositories"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("appveyor", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+
+class UploadHandlerCircleciTokenlessTest(TestCase):
+ def test_circleci_no_build(self):
+ params = {}
+
+ expected_error = """Missing "build" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_circleci_no_owner(self):
+ params = {"build": 1234}
+
+ expected_error = """Missing "owner" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_circleci_no_repo(self):
+ params = {"build": "12.34", "owner": "owner"}
+
+ expected_error = """Missing "repo" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_circleci_http_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.HTTPError("Not found")]
+
+ params = {"build": "12.34", "owner": "owner", "repo": "repo"}
+
+ expected_error = """Unable to locate build via CircleCI API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_circleci_connection_error(self, mock_get):
+ mock_get.side_effect = [requests.exceptions.ConnectionError("Not found")]
+
+ params = {"build": "12.34", "owner": "owner", "repo": "repo"}
+
+ expected_error = """Unable to locate build via CircleCI API. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_circleci_invalid_commit(self, mock_get):
+ expected_response = {"vcs_revision": "739768fcac68144a3a6d82305b9c4106934d31a"}
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+ expected_error = """Commit sha does not match Circle build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_circleci_invalid_stop_time(self, mock_get):
+ expected_response = {
+ "vcs_revision": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "stop_time": "",
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+ expected_error = """Build has already finished, uploads rejected."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("circleci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch.object(requests, "get")
+ def test_circleci_invalid_stop_time_gh(self, mock_get):
+ expected_response = {
+ "vcs_revision": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "vcs_type": "github",
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.json.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ assert TokenlessUploadHandler("circleci", params).verify_upload() == "github"
+
+
+class UploadHandlerGithubActionsTokenlessTest(TestCase):
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_underscore_replace(self, mock_get):
+ expected_response = {
+ "commit_sha": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "slug": "owner/repo",
+ "public": True,
+ "finish_time": f"{datetime.now()}".split(".")[0],
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ assert (
+ TokenlessUploadHandler("github-actions", params).verify_upload() == "github"
+ )
+
+ def test_github_actions_no_owner(self):
+ params = {}
+
+ expected_error = """Missing "owner" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ def test_github_actions_no_repo(self):
+ params = {"owner": "owner"}
+
+ expected_error = """Missing "repo" argument. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch("upload.tokenless.github_actions.get", new_callable=PropertyMock)
+ def test_github_actions_client_error(self, mock_get_torngit):
+ mock_get = mock_get_torngit.return_value.get_workflow_run
+ mock_get.side_effect = [TorngitClientGeneralError(500, None, None)]
+
+ params = {"build": "12.34", "owner": "owner", "repo": "repo"}
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert (
+ e.value.args[0]
+ == "Unable to locate build via Github Actions API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+ mock_get_torngit.assert_called_with(
+ "github",
+ token={"key": ANY},
+ repo={"name": "repo"},
+ owner={"username": "owner"},
+ oauth_consumer_token={"key": ANY, "secret": ANY},
+ )
+ mock_get.assert_called_with("12.34")
+ mock_get.reset_mock()
+ mock_get.side_effect = [Exception("Not Found")]
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert (
+ e.value.args[0]
+ == "Unable to locate build via Github Actions API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+ mock_get.assert_called_with("12.34")
+
+ @freeze_time("2024-04-25T00:00:00")
+ @patch("upload.tokenless.github_actions.get", new_callable=PropertyMock)
+ def test_github_actions_rate_limit_error(self, mock_get_torngit):
+ mock_get = mock_get_torngit.return_value.get_workflow_run
+ in_10_s = datetime.now() + timedelta(seconds=10)
+ mock_get.side_effect = [
+ TorngitRateLimitError("error", "err msg", in_10_s.timestamp(), 20)
+ ]
+
+ params = {"build": "12.34", "owner": "owner", "repo": "repo"}
+
+ with self.assertRaises(rest_framework.exceptions.Throttled) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ self.assertEqual(
+ str(e.exception.detail),
+ "Rate limit reached. Please upload with the Codecov repository upload token to resolve issue. Expected time to availability: 10s.",
+ )
+
+ mock_get.reset_mock()
+ mock_get.side_effect = [TorngitRateLimitError("error", "err msg", None, 20)]
+
+ with self.assertRaises(rest_framework.exceptions.Throttled) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ self.assertEqual(
+ str(e.exception.detail),
+ "Rate limit reached. Please upload with the Codecov repository upload token to resolve issue. Expected time to availability: 20s.",
+ )
+
+ mock_get.reset_mock()
+ mock_get.side_effect = [TorngitRateLimitError("error", "err msg", None, None)]
+
+ with self.assertRaises(rest_framework.exceptions.Throttled) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ self.assertEqual(
+ str(e.exception.detail),
+ "Rate limit reached. Please upload with the Codecov repository upload token to resolve issue.",
+ )
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_non_public(self, mock_get):
+ expected_response = {"public": False, "slug": "slug", "commit_sha": "abc"}
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ expected_error = """Repository slug or commit sha do not match Github actions build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_wrong_slug(self, mock_get):
+ expected_response = {"slug": "slug", "public": True, "commit_sha": "abc"}
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ expected_error = """Repository slug or commit sha do not match Github actions build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_wrong_commit(self, mock_get):
+ expected_response = {"commit_sha": "abc", "slug": "owner/repo", "public": True}
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ expected_error = """Repository slug or commit sha do not match Github actions build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_no_build_status(self, mock_get):
+ expected_response = {
+ "commit_sha": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "slug": "owner/repo",
+ "public": True,
+ "finish_time": f"{datetime.now() - timedelta(minutes=10)}".split(".")[0],
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ expected_error = """Actions workflow run is stale"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("github_actions", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions(self, mock_get):
+ expected_response = {
+ "commit_sha": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "slug": "owner/repo",
+ "public": True,
+ "finish_time": f"{datetime.now()}".split(".")[0],
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+
+ assert (
+ TokenlessUploadHandler("github_actions", params).verify_upload() == "github"
+ )
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_in_progress(self, mock_get):
+ expected_response = {
+ "commit_sha": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "slug": "owner/repo",
+ "public": True,
+ "status": "in_progress",
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+ assert (
+ TokenlessUploadHandler("github_actions", params).verify_upload() == "github"
+ )
+
+ @patch(
+ "upload.tokenless.github_actions.TokenlessGithubActionsHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_github_actions_queued(self, mock_get):
+ expected_response = {
+ "commit_sha": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ "slug": "owner/repo",
+ "public": True,
+ "status": "queued",
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "12.34",
+ "owner": "owner",
+ "repo": "repo",
+ "commit": "c739768fcac68144a3a6d82305b9c4106934d31a",
+ }
+ assert (
+ TokenlessUploadHandler("github_actions", params).verify_upload() == "github"
+ )
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"name": "mtail", "owner": "google"},
+ "status": "COMPLETED",
+ "buildCreatedTimestamp": time.time() - 90,
+ "durationInSeconds": 90,
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ assert TokenlessUploadHandler("cirrus_ci", params).verify_upload() == "github"
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_executing(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"name": "mtail", "owner": "google"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ assert TokenlessUploadHandler("cirrus_ci", params).verify_upload() == "github"
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_no_owner(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"owner": "google", "name": "mtail"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ expected_error = """Missing "owner" argument. Please upload with the Codecov repository upload token to resolve this issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_no_repo(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"owner": "google", "name": "mtail"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ expected_error = """Missing "repo" argument. Please upload with the Codecov repository upload token to resolve this issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_no_commit(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"owner": "google", "name": "mtail"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {"build": "5699563004624896", "owner": "google", "repo": "mtail"}
+
+ expected_error = """Missing "commit" argument. Please upload with the Codecov repository upload token to resolve this issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_wrong_repository(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"owner": "test", "name": "test"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ expected_error = """Repository slug does not match Cirrus CI build. Please upload with the Codecov repository upload token to resolve this issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_wrong_commit(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "testtesttesttest",
+ "repository": {"owner": "google", "name": "mtail"},
+ "status": "EXECUTING",
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ expected_error = """Commit sha does not match Cirrus CI build. Please upload with the Codecov repository upload token to resolve issue."""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
+
+ @patch(
+ "upload.tokenless.cirrus.TokenlessCirrusHandler.get_build",
+ new_callable=PropertyMock,
+ )
+ def test_cirrus_ci_stale(self, mock_get):
+ expected_response = {
+ "data": {
+ "build": {
+ "changeIdInRepo": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ "repository": {"name": "mtail", "owner": "google"},
+ "status": "COMPLETED",
+ "buildCreatedTimestamp": time.time() - 100000,
+ "durationInSeconds": 1,
+ }
+ }
+ }
+ mock_get.return_value.status_code.return_value = 200
+ mock_get.return_value.return_value = expected_response
+
+ params = {
+ "build": "5699563004624896",
+ "owner": "google",
+ "repo": "mtail",
+ "commit": "bbeefc070d847ff1ed526d412b7f97c5e743b1c1",
+ }
+
+ expected_error = """Cirrus run is stale"""
+
+ with pytest.raises(NotFound) as e:
+ TokenlessUploadHandler("cirrus_ci", params).verify_upload()
+ assert [line.strip() for line in e.value.args[0].split("\n")] == [
+ line.strip() for line in expected_error.split("\n")
+ ]
diff --git a/apps/codecov-api/upload/tests/test_upload_download.py b/apps/codecov-api/upload/tests/test_upload_download.py
new file mode 100644
index 0000000000..1bb66674e9
--- /dev/null
+++ b/apps/codecov-api/upload/tests/test_upload_download.py
@@ -0,0 +1,149 @@
+from unittest.mock import patch
+
+import minio
+from rest_framework.test import APITestCase
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from shared.django_apps.core.tests.factories import RepositoryFactory
+
+from utils.test_utils import Client
+
+
+class UploadDownloadHelperTest(APITestCase):
+ def _get(self, kwargs={}, data={}):
+ path = f"/upload/{kwargs.get('service')}/{kwargs.get('owner_username')}/{kwargs.get('repo_name')}/download"
+ return self.client.get(path, data=data)
+
+ def setUp(self):
+ self.org = OwnerFactory(username="codecovtest", service="github")
+ self.repo = RepositoryFactory(
+ author=self.org,
+ name="upload-test-repo",
+ upload_token="a03e5d02-9495-4413-b0d8-05651bb2e842",
+ )
+ self.repo = RepositoryFactory(
+ author=self.org, name="private-upload-test-repo", private=True
+ )
+ self.client = Client()
+ self.client.force_login_owner(self.org)
+
+ def test_no_path_param(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "invalid",
+ },
+ )
+ assert response.status_code == 404
+
+ def test_invalid_path_param(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "invalid",
+ },
+ data={"path": "v2"},
+ )
+ assert response.status_code == 404
+
+ def test_invalid_owner(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "invalid",
+ "repo_name": "invalid",
+ },
+ data={"path": "v4/raw"},
+ )
+ assert response.status_code == 404
+
+ def test_invalid_repo(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "invalid",
+ },
+ data={"path": "v4/raw"},
+ )
+ assert response.status_code == 404
+
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("shared.storage.MinioStorageService.create_presigned_get")
+ def test_invalid_archive_path(self, create_presigned_get, get_archive_hash):
+ create_presigned_get.side_effect = [
+ minio.error.S3Error(
+ code="NoSuchKey",
+ message=None,
+ resource=None,
+ request_id=None,
+ host_id=None,
+ response=None,
+ )
+ ]
+ get_archive_hash.return_value = "path"
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "upload-test-repo",
+ },
+ data={"path": "v4/raw/path"},
+ )
+ assert response.status_code == 404
+
+ @patch("shared.api_archive.archive.ArchiveService.get_archive_hash")
+ @patch("shared.storage.MinioStorageService.create_presigned_get")
+ def test_valid_repo_archive_path(self, create_presigned_get, get_archive_hash):
+ create_presigned_get.return_value = "presigned-url"
+ get_archive_hash.return_value = "hasssshhh"
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "upload-test-repo",
+ },
+ data={"path": "v4/raw/hasssshhh"},
+ )
+ assert response.status_code == 302
+ headers = response.headers
+ assert headers["location"] == "presigned-url"
+ create_presigned_get.assert_called_once_with(
+ "archive", "v4/raw/hasssshhh", expires=30
+ )
+
+ @patch("shared.api_archive.archive.ArchiveService.read_file")
+ def test_invalid_repo_archive_path(self, mock_read_file):
+ mock_read_file.return_value = "Report!"
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "upload-test-repo",
+ },
+ data={"path": "v4/raw"},
+ )
+ assert response.status_code == 404
+
+ def test_private_valid_archive_path(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "private-upload-test-repo",
+ },
+ data={"path": "v4/raw"},
+ )
+ assert response.status_code == 404
+
+ def test_invalid_shelter_path(self):
+ response = self._get(
+ kwargs={
+ "service": "gh",
+ "owner_username": "codecovtest",
+ "repo_name": "upload-test-repo",
+ },
+ data={"path": "shelter/github/codecovtest::::some-other-repo"},
+ )
+ assert response.status_code == 404
diff --git a/apps/codecov-api/upload/tests/views/__init__.py b/apps/codecov-api/upload/tests/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/upload/tests/views/test_base.py b/apps/codecov-api/upload/tests/views/test_base.py
new file mode 100644
index 0000000000..8e8e71519e
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_base.py
@@ -0,0 +1,83 @@
+import pytest
+from rest_framework.exceptions import ValidationError
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from reports.models import CommitReport
+from upload.views.base import GetterMixin
+
+
+def test_get_repo(db):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ repository.save()
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(repo="codecov::::the_repo", service="github")
+ recovered_repo = generic_class.get_repo()
+ assert recovered_repo == repository
+
+
+def test_get_repo_with_invalid_service(db):
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(repo="repo", service="wrong service")
+ with pytest.raises(ValidationError) as exp:
+ generic_class.get_repo()
+ assert exp.match("Service not found: wrong service")
+
+
+def test_get_repo_not_found(db):
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(repo="repo", service="github")
+ with pytest.raises(ValidationError) as exp:
+ generic_class.get_repo()
+ assert exp.match("Repository not found")
+
+
+def test_get_commit(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(repo=repository.name, commit_sha=commit.commitid)
+ recovered_commit = generic_class.get_commit(repository)
+ assert recovered_commit == commit
+
+
+def test_get_commit_error(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ repository.save()
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(repo=repository.name, commit_sha="missing_commit")
+ with pytest.raises(ValidationError) as exp:
+ generic_class.get_commit(repository)
+ assert exp.match("Commit SHA not found")
+
+
+def test_get_report(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ report = CommitReport(commit=commit)
+ repository.save()
+ commit.save()
+ report.save()
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(
+ repo=repository.name, commit_sha=commit.commitid, report_code=report.code
+ )
+ recovered_report = generic_class.get_report(commit)
+ assert recovered_report == report
+
+
+def test_get_report_error(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+ generic_class = GetterMixin()
+ generic_class.kwargs = dict(
+ repo=repository.name, commit_sha=commit.commitid, report_code="random_code"
+ )
+ with pytest.raises(ValidationError) as exp:
+ generic_class.get_report(commit)
+ assert exp.match("Report not found")
diff --git a/apps/codecov-api/upload/tests/views/test_bundle_analysis.py b/apps/codecov-api/upload/tests/views/test_bundle_analysis.py
new file mode 100644
index 0000000000..20510fb16c
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_bundle_analysis.py
@@ -0,0 +1,853 @@
+import json
+import re
+from unittest.mock import ANY, patch
+
+import pytest
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+)
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+from shared.events.amplitude import UNKNOWN_USER_OWNERID
+from shared.helpers.redis import get_redis_connection
+
+from core.models import Commit
+from services.task import TaskService
+from timeseries.models import Dataset, MeasurementName
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_success(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_amplitude = mocker.patch(
+ "shared.events.amplitude.AmplitudeEventPublisher.publish"
+ )
+
+ repository = RepositoryFactory.create()
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ # returns presigned storage URL
+ assert res.json() == {"url": "test-presigned-put"}
+
+ create_presigned_put.assert_called_once_with("bundle-analysis", ANY, 30)
+ call = create_presigned_put.mock_calls[0]
+ _, storage_path, _ = call.args
+ match = re.match(r"v1/uploads/([\d\w\-]+)\.json", storage_path)
+ assert match
+ (reportid,) = match.groups()
+
+ # creates commit
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+
+ # saves args in Redis
+ redis = get_redis_connection()
+ args = redis.rpop(f"uploads/{repository.repoid}/{commit_sha}/bundle_analysis")
+ assert json.loads(args) == {
+ "reportid": reportid,
+ "build": "test-build",
+ "build_url": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "url": f"v1/uploads/{reportid}.json",
+ "commit": commit_sha,
+ "report_code": None,
+ "bundle_analysis_compare_sha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ }
+
+ # sets latest upload timestamp
+ ts = redis.get(f"latest_upload/{repository.repoid}/{commit_sha}/bundle_analysis")
+ assert ts
+
+ # triggers upload task
+ upload.assert_called_with(
+ commitid=commit_sha,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="bundle_analysis",
+ arguments=ANY,
+ countdown=4,
+ )
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+ # emits Amplitude event
+ mock_amplitude.assert_called_with(
+ "Upload Received",
+ {
+ "user_ownerid": UNKNOWN_USER_OWNERID,
+ "ownerid": commit.repository.author.ownerid,
+ "repoid": commit.repository.repoid,
+ "commitid": commit.id,
+ "pullid": commit.pullid,
+ "upload_type": "Bundle",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+@override_settings(SHELTER_SHARED_SECRET="shelter-shared-secret")
+def test_upload_bundle_analysis_success_shelter(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "storage_path": "shelter/test/path.txt",
+ "upload_external_id": "test-47078f85-2cee-4511-b38d-183c334ef43b",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ # returns presigned storage URL
+ assert res.json() == {"url": "test-presigned-put"}
+
+ create_presigned_put.assert_called_once_with("bundle-analysis", ANY, 30)
+ call = create_presigned_put.mock_calls[0]
+ _, storage_path, _ = call.args
+ match = re.match(r"v1/uploads/([\d\w\-]+)\.json", storage_path)
+ assert match
+ (reportid,) = match.groups()
+
+ # creates commit
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+
+ # saves args in Redis
+ redis = get_redis_connection()
+ args = redis.rpop(f"uploads/{repository.repoid}/{commit_sha}/bundle_analysis")
+ assert json.loads(args) == {
+ "reportid": reportid,
+ "build": "test-build",
+ "build_url": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "url": f"v1/uploads/{reportid}.json",
+ "commit": commit_sha,
+ "report_code": None,
+ "bundle_analysis_compare_sha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ }
+
+ # sets latest upload timestamp
+ ts = redis.get(f"latest_upload/{repository.repoid}/{commit_sha}/bundle_analysis")
+ assert ts
+
+ # triggers upload task
+ upload.assert_called_with(
+ commitid=commit_sha,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="bundle_analysis",
+ arguments=ANY,
+ countdown=4,
+ )
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_org_token(db, client, mocker, mock_redis):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ org_token = OrganizationLevelTokenFactory.create(owner=repository.author)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {org_token.token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "unknown-user-agent",
+ "version": "unknown-user-agent",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_existing_commit(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=repository)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit.commitid,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+ upload.assert_called_with(
+ commitid=commit.commitid,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="bundle_analysis",
+ arguments=ANY,
+ countdown=4,
+ )
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "unknown-user-agent",
+ "version": "unknown-user-agent",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+def test_upload_bundle_analysis_missing_args(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=repository)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit.commitid,
+ },
+ format="json",
+ )
+ assert res.status_code == 400
+ assert res.json() == {"slug": ["This field is required."]}
+ assert not upload.called
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 400
+ assert res.json() == {"commit": ["This field is required."]}
+ assert not upload.called
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "unknown-user-agent",
+ "version": "unknown-user-agent",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "bad_request",
+ },
+ )
+
+
+def test_upload_bundle_analysis_invalid_token(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=repository)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token 2a869881-9c0f-4754-b790-3f5920be3605")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit.commitid,
+ },
+ format="json",
+ )
+ assert res.status_code == 401
+ assert res.json() == {"detail": "Not valid tokenless upload"}
+ assert not upload.called
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_upload_bundle_analysis_github_oidc_auth(
+ mock_jwks_client, mock_jwt_decode, db, mocker
+):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+ repository = RepositoryFactory()
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "unknown-user-agent",
+ "version": "unknown-user-agent",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_measurement_datasets_created(
+ db, client, mocker, mock_redis
+):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ supported_bundle_analysis_measurement_types = [
+ MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_FONT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_IMAGE_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_JAVASCRIPT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_REPORT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_STYLESHEET_SIZE,
+ ]
+ for measurement_type in supported_bundle_analysis_measurement_types:
+ assert Dataset.objects.filter(
+ name=measurement_type.value,
+ repository_id=repository.pk,
+ ).exists()
+
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@override_settings(TIMESERIES_ENABLED=False)
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_measurement_timeseries_disabled(
+ db, client, mocker, mock_redis
+):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ supported_bundle_analysis_measurement_types = [
+ MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_FONT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_IMAGE_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_JAVASCRIPT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_REPORT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_STYLESHEET_SIZE,
+ ]
+ for measurement_type in supported_bundle_analysis_measurement_types:
+ assert not Dataset.objects.filter(
+ name=measurement_type.value,
+ repository_id=repository.pk,
+ ).exists()
+
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_no_repo(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ repository = RepositoryFactory.create()
+ org_token = OrganizationLevelTokenFactory.create(owner=repository.author)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {org_token.token}")
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": "FakeUser::::NonExistentName",
+ },
+ format="json",
+ )
+ assert res.status_code == 404
+ assert res.json() == {"detail": "Repository not found."}
+ assert not upload.called
+
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "unknown-user-agent",
+ "version": "unknown-user-agent",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "error",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_tokenless_success(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mock_metrics = mocker.patch(
+ "upload.views.bundle_analysis.BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER.labels"
+ )
+
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create(private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "github",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 201
+
+ # returns presigned storage URL
+ assert res.json() == {"url": "test-presigned-put"}
+
+ assert upload.called
+ create_presigned_put.assert_called_once_with("bundle-analysis", ANY, 30)
+
+ mock_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "bundle_analysis",
+ "endpoint": "bundle_analysis",
+ "is_using_shelter": "no",
+ "position": "end",
+ "result": "success",
+ },
+ )
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_true_tokenless_success(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create(
+ private=False,
+ author__upload_token_required_for_public_repos=False,
+ author__service="github",
+ )
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": "any",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "github",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 201
+
+ # returns presigned storage URL
+ assert res.json() == {"url": "test-presigned-put"}
+
+ assert upload.called
+ create_presigned_put.assert_called_once_with("bundle-analysis", ANY, 30)
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_tokenless_no_repo(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+
+ repository = RepositoryFactory.create(private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"fakerepo::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "github",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 401
+ assert res.json() == {"detail": "Not valid tokenless upload"}
+ assert not upload.called
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_tokenless_no_git_service(
+ db, client, mocker, mock_redis
+):
+ upload = mocker.patch.object(TaskService, "upload")
+
+ repository = RepositoryFactory.create(private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "fakegitservice",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 401
+ assert res.json() == {"detail": "Not valid tokenless upload"}
+ assert not upload.called
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_tokenless_bad_json(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+
+ repository = RepositoryFactory.create(private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ from json import JSONDecodeError
+
+ with patch(
+ "codecov_auth.authentication.repo_auth.json.loads",
+ side_effect=JSONDecodeError("mocked error", doc="doc", pos=0),
+ ):
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "github",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 401
+ assert not upload.called
+
+
+@pytest.mark.django_db(databases={"default", "timeseries"})
+def test_upload_bundle_analysis_tokenless_mismatched_branch(
+ db, client, mocker, mock_redis
+):
+ upload = mocker.patch.object(TaskService, "upload")
+
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+ repository = RepositoryFactory.create(
+ private=False,
+ author__upload_token_required_for_public_repos=True,
+ )
+ CommitFactory.create(repository=repository, commitid=commit_sha, branch="main")
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "test-service",
+ "compareSha": "6fd5b89357fc8cdf34d6197549ac7c6d7e5aaaaa",
+ "branch": "f1:main",
+ "git_service": "github",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert res.status_code == 401
+ assert res.json() == {"detail": "Not valid tokenless upload"}
+ assert not upload.called
+
+
+def test_upload_bundle_analysis_view_exception_handling(db, client, mocker, mock_redis):
+ try:
+ with patch(
+ "upload.views.bundle_analysis.BundleAnalysisView._handle_upload",
+ side_effect=Exception("Test Exception"),
+ ):
+ client = APIClient()
+ repository = RepositoryFactory.create()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ mock_inc_counter = mocker.patch("upload.views.bundle_analysis.inc_counter")
+
+ client.post(
+ reverse("upload-bundle-analysis"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ except Exception as e:
+ # Check that the Test Exception was raised and the inc_counter went up
+ assert str(e) == "Test Exception"
+ mock_inc_counter.assert_any_call(
+ ANY,
+ labels=mocker.ANY,
+ )
+ labels = mock_inc_counter.call_args[1]["labels"]
+ assert labels["result"] == "error"
diff --git a/apps/codecov-api/upload/tests/views/test_commits.py b/apps/codecov-api/upload/tests/views/test_commits.py
new file mode 100644
index 0000000000..b433ba8081
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_commits.py
@@ -0,0 +1,421 @@
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.urls import reverse
+from rest_framework.exceptions import ValidationError
+from rest_framework.test import APIClient
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from core.models import Commit
+from services.task import TaskService
+from upload.views.commits import CommitViews
+
+
+def test_get_repo(db):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ repository.save()
+ upload_views = CommitViews()
+ upload_views.kwargs = dict(repo="codecov::::the_repo", service="github")
+ recovered_repo = upload_views.get_repo()
+ assert recovered_repo == repository
+
+
+def test_get_repo_with_invalid_service():
+ upload_views = CommitViews()
+ upload_views.kwargs = dict(repo="repo", service="wrong service")
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_repo()
+ assert exp.match("Service not found: wrong service")
+
+
+def test_get_repo_not_found(db):
+ # Making sure that owner has different repos and getting none when the name of the repo isn't correct
+ RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ upload_views = CommitViews()
+ upload_views.kwargs = dict(repo="codecov::::wrong-repo-name", service="github")
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_repo()
+ assert exp.match("Repository not found")
+
+
+def test_deactivated_repo(db):
+ repo = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ active=True,
+ activated=False,
+ )
+ repo.save()
+ repo_slug = f"{repo.author.username}::::{repo.name}"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repo.upload_token)
+ url = reverse(
+ "new_upload.commits",
+ args=[repo.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {"commitid": "commit_sha"},
+ format="json",
+ )
+ response_json = response.json()
+ assert response.status_code == 400
+ assert response_json == [
+ f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
+ ]
+
+
+def test_get_queryset(db):
+ target_repo = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ random_repo = RepositoryFactory()
+ target_commit_1 = CommitFactory(repository=target_repo)
+ target_commit_2 = CommitFactory(repository=target_repo)
+ random_commit = CommitFactory(repository=random_repo)
+ upload_views = CommitViews()
+ upload_views.kwargs = dict(repo="codecov::::the_repo", service="github")
+ recovered_commits = upload_views.get_queryset()
+ assert target_commit_1 in recovered_commits
+ assert target_commit_2 in recovered_commits
+ assert random_commit not in recovered_commits
+
+
+def test_commits_get(client, db):
+ repo = RepositoryFactory(name="the-repo")
+ commit_1 = CommitFactory(repository=repo)
+ commit_2 = CommitFactory(repository=repo)
+ # Some other commit in the DB that doens't belong to repo
+ # It should not be returned in the response
+ CommitFactory()
+ repo_slug = f"{repo.author.username}::::{repo.name}"
+ url = reverse("new_upload.commits", args=[repo.author.service, repo_slug])
+ assert url == f"/upload/{repo.author.service}/{repo_slug}/commits"
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repo.upload_token)
+ res = client.get(url, format="json")
+ assert res.status_code == 200
+ content = res.json()
+ assert content.get("count") == 2
+ # Test that we get the correct commits back, regardless of order
+ assert content.get("results")[0].get("commitid") in [
+ commit_1.commitid,
+ commit_2.commitid,
+ ]
+ assert content.get("results")[1].get("commitid") in [
+ commit_1.commitid,
+ commit_2.commitid,
+ ]
+ assert content.get("results")[0].get("commitid") != content.get("results")[1].get(
+ "commitid"
+ )
+
+
+@pytest.mark.parametrize(
+ "repo_privacy,status_code,detail",
+ [
+ (
+ True,
+ 401,
+ "Not valid tokenless upload",
+ ),
+ (
+ False,
+ 200,
+ None,
+ ),
+ ],
+)
+def test_commits_get_no_auth(client, db, repo_privacy, status_code, detail):
+ repo = RepositoryFactory(name="the-repo")
+ repo.private = repo_privacy
+ repo.save()
+ CommitFactory(repository=repo)
+ CommitFactory(repository=repo)
+ repo_slug = f"{repo.author.username}::::{repo.name}"
+ url = reverse("new_upload.commits", args=[repo.author.service, repo_slug])
+ assert url == f"/upload/{repo.author.service}/{repo_slug}/commits"
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token BAD")
+ res = client.get(url, format="json")
+ assert res.status_code == status_code
+ assert res.json().get("detail") == detail
+
+
+def test_commit_post_empty(db, client, mocker):
+ mocked_call = mocker.patch.object(TaskService, "update_commit")
+ repository = RepositoryFactory.create()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.commits",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {"commitid": "commit_sha", "pullid": "4", "branch": "abc"},
+ format="json",
+ )
+ response_json = response.json()
+ commit = Commit.objects.get(commitid="commit_sha")
+ expected_response = {
+ "author": None,
+ "branch": "abc",
+ "ci_passed": None,
+ "commitid": "commit_sha",
+ "message": None,
+ "parent_commit_id": None,
+ "repository": {
+ "name": repository.name,
+ "is_private": repository.private,
+ "active": repository.active,
+ "language": repository.language,
+ "yaml": repository.yaml,
+ },
+ "pullid": 4,
+ "state": None,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert response.status_code == 201
+ assert expected_response == response_json
+ mocked_call.assert_called_with(commitid="commit_sha", repoid=repository.repoid)
+
+
+def test_create_commit_already_exists(db, client, mocker):
+ mocked_call = mocker.patch.object(TaskService, "update_commit")
+ repository = RepositoryFactory.create()
+ commit = CommitFactory(repository=repository, author=None)
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.commits",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {"commitid": commit.commitid, "pullid": "4", "branch": "abc"},
+ format="json",
+ )
+ response_json = response.json()
+ expected_response = {
+ "author": None,
+ "branch": commit.branch,
+ "ci_passed": commit.ci_passed,
+ "commitid": commit.commitid,
+ "message": commit.message,
+ "parent_commit_id": commit.parent_commit_id,
+ "repository": {
+ "name": repository.name,
+ "is_private": repository.private,
+ "active": repository.active,
+ "language": repository.language,
+ "yaml": repository.yaml,
+ },
+ "pullid": commit.pullid,
+ "state": commit.state,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert response.status_code == 201
+ assert expected_response == response_json
+ mocked_call.assert_not_called()
+
+
+@pytest.mark.parametrize("branch", ["main", "someone:main", "someone/fork:main"])
+@pytest.mark.parametrize("private", [True, False])
+def test_commit_tokenless(db, client, mocker, branch, private):
+ repository = RepositoryFactory.create(
+ private=private,
+ author__username="codecov",
+ name="the_repo",
+ author__upload_token_required_for_public_repos=True,
+ )
+ mocked_call = mocker.patch.object(TaskService, "update_commit")
+
+ client = APIClient()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.commits",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {
+ "commitid": "commit_sha",
+ "pullid": "4",
+ "branch": branch,
+ },
+ format="json",
+ )
+
+ if ":" in branch and private == False:
+ assert response.status_code == 201
+ response_json = response.json()
+ commit = Commit.objects.get(commitid="commit_sha")
+ expected_response = {
+ "author": None,
+ "branch": f"{branch}",
+ "ci_passed": None,
+ "commitid": "commit_sha",
+ "message": None,
+ "parent_commit_id": None,
+ "repository": {
+ "name": repository.name,
+ "is_private": repository.private,
+ "active": repository.active,
+ "language": repository.language,
+ "yaml": repository.yaml,
+ },
+ "pullid": 4,
+ "state": None,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert expected_response == response_json
+ mocked_call.assert_called_with(commitid="commit_sha", repoid=repository.repoid)
+ else:
+ assert response.status_code == 401
+ commit = Commit.objects.filter(commitid="commit_sha").first()
+ assert commit is None
+
+
+@pytest.mark.parametrize("branch", ["main", "someone:main", "someone/fork:main"])
+@pytest.mark.parametrize("private", [True, False])
+@pytest.mark.parametrize("upload_token_required_for_public_repos", [True, False])
+def test_commit_upload_token_required_auth_check(
+ db, client, mocker, branch, private, upload_token_required_for_public_repos
+):
+ repository = RepositoryFactory(
+ private=private,
+ author__username="codecov",
+ name="the_repo",
+ author__upload_token_required_for_public_repos=upload_token_required_for_public_repos,
+ )
+ mocked_call = mocker.patch.object(TaskService, "update_commit")
+
+ client = APIClient()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.commits",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {
+ "commitid": "commit_sha",
+ "pullid": "4",
+ "branch": branch,
+ },
+ format="json",
+ )
+
+ # when TokenlessAuthentication is removed, this test should use `if private == False and upload_token_required_for_public_repos == False:`
+ # but TokenlessAuthentication lets some additional uploads through.
+ authorized_by_tokenless_auth_class = ":" in branch
+
+ if private == False and (
+ upload_token_required_for_public_repos == False
+ or authorized_by_tokenless_auth_class
+ ):
+ assert response.status_code == 201
+ response_json = response.json()
+ commit = Commit.objects.get(commitid="commit_sha")
+ expected_response = {
+ "author": None,
+ "branch": f"{branch}",
+ "ci_passed": None,
+ "commitid": "commit_sha",
+ "message": None,
+ "parent_commit_id": None,
+ "repository": {
+ "name": repository.name,
+ "is_private": repository.private,
+ "active": repository.active,
+ "language": repository.language,
+ "yaml": repository.yaml,
+ },
+ "pullid": 4,
+ "state": None,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert expected_response == response_json
+ mocked_call.assert_called_with(commitid="commit_sha", repoid=repository.repoid)
+ else:
+ assert response.status_code == 401
+ commit = Commit.objects.filter(commitid="commit_sha").first()
+ assert commit is None
+
+
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_commit_github_oidc_auth(mock_jwks_client, mock_jwt_decode, db, mocker):
+ repository = RepositoryFactory.create(
+ private=False, author__username="codecov", name="the_repo"
+ )
+ mocked_call = mocker.patch.object(TaskService, "update_commit")
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ client = APIClient()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.commits",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {
+ "commitid": "commit_sha",
+ "pullid": "4",
+ },
+ format="json",
+ headers={"Authorization": f"token {token}", "User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert response.status_code == 201
+ response_json = response.json()
+ commit = Commit.objects.get(commitid="commit_sha")
+ expected_response = {
+ "author": None,
+ "branch": None,
+ "ci_passed": None,
+ "commitid": "commit_sha",
+ "message": None,
+ "parent_commit_id": None,
+ "repository": {
+ "name": repository.name,
+ "is_private": repository.private,
+ "active": repository.active,
+ "language": repository.language,
+ "yaml": repository.yaml,
+ },
+ "pullid": 4,
+ "state": None,
+ "timestamp": commit.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ }
+ assert expected_response == response_json
+ mocked_call.assert_called_with(commitid="commit_sha", repoid=repository.repoid)
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "create_commit",
+ "repo_visibility": "public",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
diff --git a/apps/codecov-api/upload/tests/views/test_empty_upload.py b/apps/codecov-api/upload/tests/views/test_empty_upload.py
new file mode 100644
index 0000000000..7ad51f3a46
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_empty_upload.py
@@ -0,0 +1,584 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+)
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+from shared.yaml.user_yaml import UserYaml
+
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+
+class MockedProviderAdapter:
+ def __init__(self, changed_files) -> None:
+ self.changed_files = changed_files
+
+ async def find_pull_request(self, commit):
+ # Random value
+ return "5"
+
+ async def get_pull_request_files(self, pull_id):
+ return self.changed_files
+
+
+def test_uploads_get_not_allowed(client, db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the-repo", author__username="codecov", author__service="github"
+ )
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=["github", "codecov::::the-repo", "commit-sha"],
+ )
+ assert url == "/upload/github/codecov::::the-repo/commits/commit-sha/empty-upload"
+ res = client.get(url)
+ assert res.status_code == 405
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_with_yaml_ignored_files(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "ignore": [
+ "file.py",
+ "another_file.py",
+ ]
+ }
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ [
+ "file.py",
+ ]
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(url, headers={"User-Agent": "codecov-cli/0.4.7"})
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "All changed files are ignored. Triggering passing notifications."
+ )
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "empty_upload",
+ "repo_visibility": "private",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_non_testable_files(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "ignore": [
+ "file.py",
+ "another_file.py",
+ ]
+ }
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ [
+ "README.md",
+ "codecov.yml",
+ "template.txt",
+ "dir/sub-dir/codecov.yml",
+ ".circleci/config.yml",
+ ]
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "All changed files are ignored. Triggering passing notifications."
+ )
+ assert response_json.get("non_ignored_files") == []
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_with_testable_file(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {"ignore": ["file.py", "another_file.py", "README.md"]}
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ ["README.md", "codecov.yml", "template.txt", "base.py"]
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "Some files cannot be ignored. Triggering failing notifications."
+ )
+ assert response_json.get("non_ignored_files") == ["base.py"]
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="fail"
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_with_testable_file_with_force(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {"ignore": ["file.py", "another_file.py", "README.md"]}
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ ["README.md", "codecov.yml", "template.txt", "base.py"]
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(url, data={"should_force": True})
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "Force option was enabled. Triggering passing notifications."
+ )
+ assert response_json.get("non_ignored_files") == []
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_with_testable_file_invalid_serializer(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {"ignore": ["file.py", "another_file.py", "README.md"]}
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ ["README.md", "codecov.yml", "template.txt", "base.py"]
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(url, data={"should_force": "hello world"})
+ assert response.status_code == 400
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_no_changed_files_in_pr(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "ignore": [
+ "file.py",
+ "another_file.py",
+ ]
+ }
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter([])
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "All changed files are ignored. Triggering passing notifications."
+ )
+ assert response_json.get("non_ignored_files") == []
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_empty_upload_no_changed_files_in_pr_github_oidc_auth(
+ mock_jwks_client,
+ mock_jwt_decode,
+ mock_repo_provider_service,
+ mock_final_yaml,
+ notify_mock,
+ db,
+ mocker,
+):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "ignore": [
+ "file.py",
+ "another_file.py",
+ ]
+ }
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter([])
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ headers={"Authorization": f"token {token}"},
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "All changed files are ignored. Triggering passing notifications."
+ )
+ assert response_json.get("non_ignored_files") == []
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+
+
+@patch("services.task.TaskService.notify")
+@patch("upload.views.empty_upload.final_commit_yaml")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_no_commit_pr_id(
+ mock_repo_provider_service, mock_final_yaml, notify_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_final_yaml.return_value = UserYaml(
+ {
+ "ignore": [
+ "file.py",
+ "another_file.py",
+ ]
+ }
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter([])
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository, pullid=None)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "All changed files are ignored. Triggering passing notifications."
+ )
+ assert response_json.get("non_ignored_files") == []
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+
+
+def test_empty_upload_no_auth(db, mocker):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ token = "BAD"
+ client = APIClient()
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ headers={"Authorization": f"token {token}"},
+ )
+ response_json = response.json()
+ assert response.status_code == 401
+ assert (
+ response_json.get("detail")
+ == "Failed token authentication, please double-check that your repository token matches in the Codecov UI, "
+ "or review the docs https://docs.codecov.com/docs/adding-the-codecov-token"
+ )
+
+
+@patch("services.yaml.fetch_commit_yaml")
+@patch("services.task.TaskService.notify")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_commit_yaml_org_token(
+ mock_repo_provider_service, notify_mock, fetch_yaml_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ ["README.md", "codecov.yml", "template.txt", "base.py"]
+ )
+ fetch_yaml_mock.return_value = None
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ org_token = OrganizationLevelTokenFactory.create(owner=repository.author)
+ repository.save()
+ commit.save()
+ org_token.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ headers={"Authorization": f"token {org_token.token}"},
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "Some files cannot be ignored. Triggering failing notifications."
+ )
+ assert response_json.get("non_ignored_files") == ["base.py"]
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="fail"
+ )
+
+ fetch_yaml_mock.assert_called_once_with(commit, repository.author)
+
+
+@patch("services.yaml.fetch_commit_yaml")
+@patch("services.task.TaskService.notify")
+@patch("services.repo_providers.RepoProviderService.get_adapter")
+def test_empty_upload_ommit_yaml_repo_token(
+ mock_repo_provider_service, notify_mock, fetch_yaml_mock, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_repo_provider_service.return_value = MockedProviderAdapter(
+ ["README.md", "codecov.yml", "template.txt", "base.py"]
+ )
+ fetch_yaml_mock.return_value = None
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.empty_upload",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ headers={"Authorization": f"token {repository.upload_token}"},
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert (
+ response_json.get("result")
+ == "Some files cannot be ignored. Triggering failing notifications."
+ )
+ assert response_json.get("non_ignored_files") == ["base.py"]
+ notify_mock.assert_called_once_with(
+ repoid=repository.repoid, commitid=commit.commitid, empty_upload="fail"
+ )
+
+ fetch_yaml_mock.assert_called_once_with(commit, repository.author)
diff --git a/apps/codecov-api/upload/tests/views/test_helpers.py b/apps/codecov-api/upload/tests/views/test_helpers.py
new file mode 100644
index 0000000000..4950ea9b25
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_helpers.py
@@ -0,0 +1,151 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from codecov_auth.models import Service
+from upload.views.helpers import (
+ get_repository_and_owner_from_string,
+ get_repository_from_string,
+)
+
+
+class ViewHelpersTest(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.gh = Service.GITHUB
+ cls.bb = Service.BITBUCKET
+ cls.gl = Service.GITLAB
+ cls.uname = "simpleusername"
+ cls.repo_1_name = "simplerepo"
+ cls.repo_2_name = "anotherrepo"
+ cls.group = "somegroup"
+ cls.subgroup = "somesubgroup"
+ cls.sub_subgroup = "subsub"
+ owners_to_repos_mapping = {
+ cls.gh: {cls.uname: [cls.repo_1_name, cls.repo_2_name]},
+ cls.bb: {cls.uname: [cls.repo_1_name, cls.repo_2_name]},
+ cls.gl: {
+ cls.uname: [cls.repo_1_name, cls.repo_2_name],
+ cls.group: [cls.repo_1_name],
+ f"{cls.group}:{cls.subgroup}": [cls.repo_1_name],
+ f"{cls.group}:{cls.subgroup}:{cls.sub_subgroup}": [
+ cls.repo_1_name,
+ cls.repo_2_name,
+ ],
+ },
+ }
+ for service, owner_data in owners_to_repos_mapping.items():
+ for username, repo_names in owner_data.items():
+ RepositoryFactory()
+ o = OwnerFactory(service=service, username=username)
+ for r_name in repo_names:
+ RepositoryFactory(author=o, name=r_name)
+
+ def test_get_repository_from_string(self):
+ first_result = get_repository_from_string(
+ self.gh, f"{self.uname}::::{self.repo_1_name}"
+ )
+ assert first_result.name == self.repo_1_name
+ assert first_result.author.username == self.uname
+ assert first_result.author.service == self.gh
+ second_result = get_repository_from_string(
+ self.bb, f"{self.uname}::::{self.repo_1_name}"
+ )
+ assert second_result.name == self.repo_1_name
+ assert second_result.author.username == self.uname
+ assert second_result.author.service == self.bb
+ assert first_result != second_result
+ assert (
+ get_repository_from_string(
+ self.gh, f"somerandomlalala::::{self.repo_1_name}"
+ )
+ is None
+ )
+ assert (
+ get_repository_from_string(
+ self.gh, f"{self.uname}::::{self.repo_1_name}wrongname"
+ )
+ is None
+ )
+ assert (
+ get_repository_from_string(
+ "badgithub", f"{self.uname}::::{self.repo_1_name}"
+ )
+ is None
+ )
+ first_gitlab_result = get_repository_from_string(
+ self.gl, f"{self.group}::::{self.repo_1_name}"
+ )
+ assert first_gitlab_result.name == self.repo_1_name
+ assert first_gitlab_result.author.username == self.group
+ assert first_gitlab_result.author.service == self.gl
+ second_gitlab_result = get_repository_from_string(
+ self.gl, f"{self.group}:::{self.subgroup}::::{self.repo_1_name}"
+ )
+ assert second_gitlab_result.name == self.repo_1_name
+ assert second_gitlab_result.author.username == f"{self.group}:{self.subgroup}"
+ assert second_gitlab_result.author.service == self.gl
+ assert (
+ get_repository_from_string(
+ self.gl, f"{self.group}:::somebadsubgroup::::{self.repo_1_name}"
+ )
+ is None
+ )
+
+ def test_get_repository_and_owner_from_string(self):
+ first_result_repository, first_result_owner = (
+ get_repository_and_owner_from_string(
+ self.gh, f"{self.uname}::::{self.repo_1_name}"
+ )
+ )
+ assert first_result_repository.name == self.repo_1_name
+ assert first_result_repository.author.username == self.uname
+ assert first_result_repository.author.service == self.gh
+ assert first_result_repository.author == first_result_owner
+
+ second_result_repository, second_result_owner = (
+ get_repository_and_owner_from_string(
+ self.bb, f"{self.uname}::::{self.repo_1_name}"
+ )
+ )
+ assert second_result_repository.name == self.repo_1_name
+ assert second_result_repository.author.username == self.uname
+ assert second_result_repository.author.service == self.bb
+ assert first_result_repository != second_result_repository
+ assert second_result_repository.author == second_result_owner
+
+ assert get_repository_and_owner_from_string(
+ self.gh, f"somerandomlalala::::{self.repo_1_name}"
+ ) == (None, None)
+ assert get_repository_and_owner_from_string(
+ self.gh, f"{self.uname}::::{self.repo_1_name}wrongname"
+ ) == (None, None)
+ assert get_repository_and_owner_from_string(
+ "badgithub", f"{self.uname}::::{self.repo_1_name}"
+ ) == (None, None)
+
+ first_gitlab_result_repository, first_gitlab_result_owner = (
+ get_repository_and_owner_from_string(
+ self.gl, f"{self.group}::::{self.repo_1_name}"
+ )
+ )
+ assert first_gitlab_result_repository.name == self.repo_1_name
+ assert first_gitlab_result_repository.author.username == self.group
+ assert first_gitlab_result_repository.author.service == self.gl
+ assert first_gitlab_result_repository.author == first_gitlab_result_owner
+
+ second_gitlab_result_repository, second_gitlab_result_owner = (
+ get_repository_and_owner_from_string(
+ self.gl, f"{self.group}:::{self.subgroup}::::{self.repo_1_name}"
+ )
+ )
+ assert second_gitlab_result_repository.name == self.repo_1_name
+ assert (
+ second_gitlab_result_repository.author.username
+ == f"{self.group}:{self.subgroup}"
+ )
+ assert second_gitlab_result_repository.author.service == self.gl
+ assert second_gitlab_result_owner == second_gitlab_result_repository.author
+ assert get_repository_and_owner_from_string(
+ self.gl, f"{self.group}:::somebadsubgroup::::{self.repo_1_name}"
+ ) == (None, None)
diff --git a/apps/codecov-api/upload/tests/views/test_reports.py b/apps/codecov-api/upload/tests/views/test_reports.py
new file mode 100644
index 0000000000..22f1f25c34
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_reports.py
@@ -0,0 +1,609 @@
+from unittest.mock import patch
+
+import pytest
+from django.conf import settings
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+
+from reports.models import CommitReport, ReportResults
+from reports.tests.factories import ReportResultsFactory
+from services.task.task import TaskService
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+
+def test_reports_get_not_allowed(client, mocker, db):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ owner = OwnerFactory(service="github")
+ repo = RepositoryFactory(name="the_repo", private=False, author=owner)
+ commit = CommitFactory(repository=repo)
+ commit.branch = "someone:branch"
+ owner.save()
+ repo.save()
+ commit.save()
+ headers = {}
+ url = reverse(
+ "new_upload.reports",
+ args=["github", f"{owner.username}::::the_repo", commit.commitid],
+ )
+ assert (
+ url
+ == f"/upload/github/{owner.username}::::the_repo/commits/{commit.commitid}/reports"
+ )
+ res = client.get(url, **headers)
+ assert res.status_code == 405
+
+
+def test_deactivated_repo(db):
+ repo = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ active=True,
+ activated=False,
+ )
+ commit = CommitFactory(repository=repo)
+ repo.save()
+ commit.save()
+ repo_slug = f"{repo.author.username}::::{repo.name}"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repo.upload_token)
+ url = reverse(
+ "new_upload.reports",
+ args=["github", repo_slug, commit.commitid],
+ )
+ response = client.post(
+ url, data={"code": "code1"}, headers={"User-Agent": "codecov-cli/0.4.7"}
+ )
+ response_json = response.json()
+ assert response.status_code == 400
+ assert response_json == [
+ f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
+ ]
+
+
+def test_reports_post(client, db, mocker):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+ response = client.post(
+ url, data={"code": "code1"}, headers={"User-Agent": "codecov-cli/0.4.7"}
+ )
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id, code="code1", report_type=CommitReport.ReportType.COVERAGE
+ ).exists()
+ mocked_call.assert_called_with(repository.repoid, commit.commitid, "code1")
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "create_report",
+ "repo_visibility": "private",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_reports_post_github_oidc_auth(
+ mock_jwks_client, mock_jwt_decode, client, db, mocker
+):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + token)
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+ response = client.post(url, data={"code": "code1"})
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id, code="code1", report_type=CommitReport.ReportType.COVERAGE
+ ).exists()
+ mocked_call.assert_called_with(repository.repoid, commit.commitid, "code1")
+
+
+@pytest.mark.parametrize("private", [False, True])
+@pytest.mark.parametrize("branch", ["main", "fork:branch", "someone/fork:branch"])
+@pytest.mark.parametrize(
+ "branch_sent",
+ [
+ None,
+ "branch",
+ "fork:branch",
+ "someone/fork:branch",
+ ],
+)
+def test_reports_post_tokenless(client, db, mocker, private, branch, branch_sent):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ author__upload_token_required_for_public_repos=True,
+ private=private,
+ )
+ commit = CommitFactory(repository=repository)
+ commit.branch = branch
+ repository.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+
+ data = {"code": "code1"}
+ if branch_sent:
+ data["branch"] = branch_sent
+ response = client.post(
+ url,
+ data=data,
+ headers={},
+ )
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+ if private is False and ":" in branch:
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id,
+ code="code1",
+ report_type=CommitReport.ReportType.COVERAGE,
+ ).exists()
+ mocked_call.assert_called_with(repository.repoid, commit.commitid, "code1")
+ else:
+ assert response.status_code == 401
+ assert not CommitReport.objects.filter(
+ commit_id=commit.id,
+ code="code1",
+ report_type=CommitReport.ReportType.COVERAGE,
+ ).exists()
+ assert response.json().get("detail") == "Not valid tokenless upload"
+
+
+@pytest.mark.parametrize("private", [False, True])
+@pytest.mark.parametrize("branch", ["main", "fork:branch", "someone/fork:branch"])
+@pytest.mark.parametrize(
+ "branch_sent",
+ [
+ None,
+ "branch",
+ "fork:branch",
+ "someone/fork:branch",
+ ],
+)
+@pytest.mark.parametrize("upload_token_required_for_public_repos", [True, False])
+def test_reports_post_upload_token_required_auth_check(
+ client,
+ db,
+ mocker,
+ private,
+ branch,
+ branch_sent,
+ upload_token_required_for_public_repos,
+):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ private=private,
+ author__upload_token_required_for_public_repos=upload_token_required_for_public_repos,
+ )
+ commit = CommitFactory(repository=repository)
+ commit.branch = branch
+ repository.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+
+ data = {"code": "code1"}
+ if branch_sent:
+ data["branch"] = branch_sent
+ response = client.post(
+ url,
+ data=data,
+ headers={},
+ )
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+
+ # when TokenlessAuthentication is removed, this test should use `if private == False and upload_token_required_for_public_repos == False:`
+ # but TokenlessAuthentication lets some additional uploads through.
+ authorized_by_tokenless_auth_class = ":" in branch
+
+ if private == False and (
+ upload_token_required_for_public_repos == False
+ or authorized_by_tokenless_auth_class
+ ):
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id,
+ code="code1",
+ report_type=CommitReport.ReportType.COVERAGE,
+ ).exists()
+ mocked_call.assert_called_with(repository.repoid, commit.commitid, "code1")
+ else:
+ assert response.status_code == 401
+ assert not CommitReport.objects.filter(
+ commit_id=commit.id,
+ code="code1",
+ report_type=CommitReport.ReportType.COVERAGE,
+ ).exists()
+ assert response.json().get("detail") == "Not valid tokenless upload"
+
+
+def test_create_report_already_exists(client, db, mocker):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ CommitReport.objects.create(commit=commit, code="code")
+
+ repository.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+ response = client.post(url, data={"code": "code"})
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id, code="code", report_type=CommitReport.ReportType.COVERAGE
+ ).exists()
+ mocked_call.assert_not_called()
+
+
+def test_reports_post_code_as_default(client, db, mocker):
+ mocked_call = mocker.patch.object(TaskService, "preprocess_upload")
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ url = reverse(
+ "new_upload.reports",
+ args=["github", "codecov::::the_repo", commit.commitid],
+ )
+ response = client.post(url, data={"code": "default"})
+
+ assert (
+ url == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports"
+ )
+ assert response.status_code == 201
+ assert CommitReport.objects.filter(
+ commit_id=commit.id, code=None, report_type=CommitReport.ReportType.COVERAGE
+ ).exists()
+ mocked_call.assert_called_once()
+
+
+def test_reports_results_post_successful(client, db, mocker):
+ mocked_task = mocker.patch("services.task.TaskService.create_report_results")
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+ response = client.post(url, content_type="application/json", data={})
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+ assert response.status_code == 201
+ assert ReportResults.objects.filter(
+ report_id=commit_report.id,
+ ).exists()
+ mocked_task.assert_called_once()
+
+
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_reports_results_post_successful_github_oidc_auth(
+ mock_jwks_client, mock_jwt_decode, client, db, mocker
+):
+ mocked_task = mocker.patch("services.task.TaskService.create_report_results")
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+ response = client.post(
+ url,
+ content_type="application/json",
+ data={},
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+ assert response.status_code == 201
+ assert ReportResults.objects.filter(
+ report_id=commit_report.id,
+ ).exists()
+ mocked_task.assert_called_once()
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "create_report_results",
+ "repo_visibility": "private",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+
+@pytest.mark.parametrize("private", [False, True])
+@pytest.mark.parametrize("branch", ["main", "fork:branch", "someone/fork:branch"])
+@pytest.mark.parametrize(
+ "branch_sent",
+ [
+ None,
+ "branch",
+ "fork:branch",
+ "someone/fork:branch",
+ ],
+)
+@pytest.mark.parametrize("upload_token_required_for_public_repos", [True, False])
+def test_reports_results_post_upload_token_required_auth_check(
+ client,
+ db,
+ mocker,
+ private,
+ branch,
+ branch_sent,
+ upload_token_required_for_public_repos,
+):
+ mocked_task = mocker.patch("services.task.TaskService.create_report_results")
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ private=private,
+ author__upload_token_required_for_public_repos=upload_token_required_for_public_repos,
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ commit.branch = branch
+ repository.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+
+ data = {"code": "code1"}
+ if branch_sent:
+ data["branch"] = branch_sent
+ response = client.post(
+ url,
+ data=data,
+ headers={},
+ )
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+
+ # when TokenlessAuthentication is removed, this test should use `if private == False and upload_token_required_for_public_repos == False:`
+ # but TokenlessAuthentication lets some additional uploads through.
+ authorized_by_tokenless_auth_class = ":" in branch
+
+ if private == False and (
+ upload_token_required_for_public_repos == False
+ or authorized_by_tokenless_auth_class
+ ):
+ assert response.status_code == 201
+ assert ReportResults.objects.filter(
+ report_id=commit_report.id,
+ ).exists()
+ mocked_task.assert_called_once()
+ else:
+ assert response.status_code == 401
+ assert not ReportResults.objects.filter(
+ report_id=commit_report.id,
+ ).exists()
+ assert response.json().get("detail") == "Not valid tokenless upload"
+
+
+def test_reports_results_already_exists_post_successful(client, db, mocker):
+ mocked_task = mocker.patch("services.task.TaskService.create_report_results")
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ report_results = ReportResults.objects.create(
+ report=commit_report, state=ReportResults.ReportResultsStates.COMPLETED
+ )
+ repository.save()
+ commit_report.save()
+ report_results.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+ response = client.post(url, content_type="application/json", data={})
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+ assert response.status_code == 201
+ assert ReportResults.objects.filter(
+ report_id=commit_report.id, state=ReportResults.ReportResultsStates.PENDING
+ ).exists()
+ mocked_task.assert_called_once()
+
+
+def test_report_results_get_successful(client, db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ commit_report_results = ReportResultsFactory(report=commit_report)
+ repository.save()
+ commit_report_results.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+ response = client.get(url, content_type="application/json", data={})
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "external_id": str(commit_report_results.external_id),
+ "report": {
+ "external_id": str(commit_report.external_id),
+ "created_at": commit_report.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "commit_sha": commit_report.commit.commitid,
+ "code": commit_report.code,
+ },
+ "state": commit_report_results.state,
+ "result": {},
+ "completed_at": None,
+ }
+
+
+def test_report_results_get_unsuccessful(client, db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+
+ client = APIClient()
+ client.force_authenticate(user=OwnerFactory())
+ url = reverse(
+ "new_upload.reports_results",
+ args=["github", "codecov::::the_repo", commit.commitid, "code"],
+ )
+ response = client.get(url, content_type="application/json", data={})
+
+ assert (
+ url
+ == f"/upload/github/codecov::::the_repo/commits/{commit.commitid}/reports/code/results"
+ )
+ assert response.status_code == 400
+ assert response.json() == ["Report Results not found"]
diff --git a/apps/codecov-api/upload/tests/views/test_test_results.py b/apps/codecov-api/upload/tests/views/test_test_results.py
new file mode 100644
index 0000000000..85449fcea5
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_test_results.py
@@ -0,0 +1,620 @@
+import json
+import re
+from unittest.mock import ANY, patch
+
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.django_apps.codecov_auth.tests.factories import (
+ OrganizationLevelTokenFactory,
+)
+from shared.django_apps.core.models import Commit
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.events.amplitude import UNKNOWN_USER_OWNERID
+from shared.helpers.redis import get_redis_connection
+
+from services.task import TaskService
+
+
+def test_upload_test_results(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+ mock_amplitude = mocker.patch(
+ "shared.events.amplitude.AmplitudeEventPublisher.publish"
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github-actions",
+ "branch": "aaaaaa",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ # returns presigned storage URL
+ assert res.json() == {"raw_upload_location": "test-presigned-put"}
+
+ create_presigned_put.assert_called_once_with("archive", ANY, 10)
+ call = create_presigned_put.mock_calls[0]
+ _, storage_path, _ = call.args
+ match = re.match(
+ r"test_results/v1/raw/([\d\w\-]+)/([\d\w\-]+)/([\d\w\-]+)/([\d\w\-]+)\.txt",
+ storage_path,
+ )
+ assert match
+ (
+ date,
+ repo_hash,
+ commit_sha,
+ reportid,
+ ) = match.groups()
+
+ # creates commit
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+ assert commit.branch is not None
+
+ # saves args in Redis
+ redis = get_redis_connection()
+ args = json.loads(
+ redis.rpop(f"uploads/{repository.repoid}/{commit_sha}/test_results")
+ )
+ assert args == {
+ "reportid": reportid,
+ "build": "test-build",
+ "build_url": "test-build-url",
+ "job": "test-job",
+ "service": "github-actions",
+ "url": f"test_results/v1/raw/{date}/{repo_hash}/{commit_sha}/{reportid}.txt",
+ "commit": commit_sha,
+ "report_code": None,
+ "flags": None,
+ }
+
+ # sets latest upload timestamp
+ ts = redis.get(f"latest_upload/{repository.repoid}/{commit_sha}/test_results")
+ assert ts
+
+ # triggers upload task
+ upload.assert_called_with(
+ commitid=commit_sha,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="test_results",
+ arguments=args,
+ countdown=4,
+ )
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "test_results",
+ "endpoint": "test_results",
+ "repo_visibility": "private",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+ # emits Amplitude event
+ mock_amplitude.assert_called_with(
+ "Upload Received",
+ {
+ "user_ownerid": UNKNOWN_USER_OWNERID,
+ "ownerid": commit.repository.author.ownerid,
+ "repoid": commit.repository.repoid,
+ "commitid": commit.id,
+ "pullid": commit.pullid,
+ "upload_type": "Test results",
+ },
+ )
+
+
+def test_test_results_org_token(db, client, mocker, mock_redis):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner)
+ org_token = OrganizationLevelTokenFactory.create(owner=repository.author)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {org_token.token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "branch": "aaaaaa",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_test_results_github_oidc_token(
+ mock_jwks_client, mock_jwt_decode, db, client, mocker, mock_redis
+):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner)
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "branch": "aaaaaa",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+
+def test_test_results_upload_token_not_required(db, client, mocker, mock_redis):
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(
+ service="github",
+ username="codecov",
+ upload_token_required_for_public_repos=False,
+ )
+ repository = RepositoryFactory.create(author=owner, private=False)
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "branch": "aaaaaa",
+ "service": owner.service,
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+
+def test_test_results_no_auth(db, client, mocker, mock_redis):
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner)
+ token = "BAD"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 401
+ assert res.json().get("detail") == "Not valid tokenless upload"
+
+
+def test_upload_test_results_no_repo(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+ org_token = OrganizationLevelTokenFactory.create(owner=repository.author)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {org_token.token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef",
+ "slug": "FakeUser::::NonExistentName",
+ },
+ format="json",
+ )
+ assert res.status_code == 404
+ assert res.json() == {"detail": "Repository not found."}
+ assert not upload.called
+
+
+def test_upload_test_results_missing_args(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+ commit = CommitFactory.create(repository=repository)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit.commitid,
+ },
+ format="json",
+ )
+ assert res.status_code == 400
+ assert res.json() == {"slug": ["This field is required."]}
+ assert not upload.called
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 400
+ assert res.json() == {"commit": ["This field is required."]}
+ assert not upload.called
+
+
+def test_upload_test_results_missing_branch_no_commit(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ commit_sha = "aaaaaa"
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "aaaaaa",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+ assert upload.called
+
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit.branch is not None
+
+
+def test_upload_test_results_branch_none_no_commit(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ commit_sha = "aaaaaa"
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": "aaaaaa",
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "branch": None,
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+ assert upload.called
+
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit.branch is not None
+
+
+def test_update_repo_fields_when_upload_is_triggered(
+ db, client, mocker, mock_redis
+) -> None:
+ mocker.patch.object(TaskService, "upload")
+ mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ repository = RepositoryFactory.create(active=False, activated=False)
+ commit = CommitFactory.create(repository=repository)
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit.commitid,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ },
+ format="json",
+ )
+ assert res.status_code == 201
+
+ repository.refresh_from_db()
+ assert repository.active is True
+ assert repository.activated is True
+ assert repository.test_analytics_enabled is True
+
+
+def test_upload_test_results_file_not_found(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ create_presigned_put = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {repository.upload_token}")
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github-actions",
+ "branch": "aaaaaa",
+ "file_not_found": True,
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ assert res.data is None
+
+ create_presigned_put.assert_not_called()
+
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+ assert commit.branch is not None
+
+ redis = get_redis_connection()
+ args = json.loads(
+ redis.rpop(f"uploads/{repository.repoid}/{commit_sha}/test_results")
+ )
+ assert args == {
+ "reportid": mocker.ANY,
+ "build": "test-build",
+ "build_url": "test-build-url",
+ "job": "test-job",
+ "service": "github-actions",
+ "url": None,
+ "commit": commit_sha,
+ "report_code": None,
+ "flags": None,
+ }
+
+ # sets latest upload timestamp
+ ts = redis.get(f"latest_upload/{repository.repoid}/{commit_sha}/test_results")
+ assert ts
+
+ # triggers upload task
+ upload.assert_called_with(
+ commitid=commit_sha,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="test_results",
+ arguments=args,
+ countdown=4,
+ )
+
+
+def test_upload_test_results_tokenless_authentication(db, client, mocker, mock_redis):
+ upload = mocker.patch.object(TaskService, "upload")
+ _ = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner, private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github",
+ "branch": "fork:branch",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 201
+
+ assert res.data == {"raw_upload_location": "test-presigned-put"}
+
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+ assert commit.branch is not None
+
+ # triggers upload task
+ upload.assert_called_with(
+ commitid=commit_sha,
+ repoid=repository.repoid,
+ report_code=None,
+ report_type="test_results",
+ arguments=mocker.ANY,
+ countdown=4,
+ )
+
+
+def test_upload_test_results_tokenless_authentication_private_repo(
+ db, client, mocker, mock_redis
+):
+ _ = mocker.patch.object(TaskService, "upload")
+ _ = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ repository = RepositoryFactory.create(author=owner, private=True)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github",
+ "branch": "fork:branch",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 401
+ assert res.json().get("detail") == "Not valid tokenless upload"
+
+
+def test_upload_test_results_tokenless_authentication_invalid_branch(
+ db, client, mocker, mock_redis
+):
+ _ = mocker.patch.object(TaskService, "upload")
+ _ = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ owner.upload_token_required_for_public_repos = True
+ owner.save()
+
+ repository = RepositoryFactory.create(author=owner, private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github",
+ "branch": "branch",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 401
+ assert res.json().get("detail") == "Not valid tokenless upload"
+
+
+def test_upload_test_results_tokenless_authentication_invalid_branch_existing_commits(
+ db, client, mocker, mock_redis
+):
+ _ = mocker.patch.object(TaskService, "upload")
+ _ = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="test-presigned-put",
+ )
+
+ owner = OwnerFactory(service="github", username="codecov")
+ owner.upload_token_required_for_public_repos = True
+ owner.save()
+
+ repository = RepositoryFactory.create(author=owner, private=False)
+ commit_sha = "6fd5b89357fc8cdf34d6197549ac7c6d7e5977ef"
+ CommitFactory.create(repository=repository, commitid=commit_sha, branch="branch")
+ client = APIClient()
+
+ res = client.post(
+ reverse("upload-test-results"),
+ {
+ "commit": commit_sha,
+ "slug": f"{repository.author.username}::::{repository.name}",
+ "build": "test-build",
+ "buildURL": "test-build-url",
+ "job": "test-job",
+ "service": "github",
+ "branch": "branch",
+ },
+ format="json",
+ headers={"User-Agent": "codecov-cli/0.4.7"},
+ )
+ assert res.status_code == 401
+ assert res.json().get("detail") == "Not valid tokenless upload"
+ commit = Commit.objects.get(commitid=commit_sha)
+ assert commit
+ assert commit.branch == "branch"
diff --git a/apps/codecov-api/upload/tests/views/test_upload_completion.py b/apps/codecov-api/upload/tests/views/test_upload_completion.py
new file mode 100644
index 0000000000..9c6b5f44e0
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_upload_completion.py
@@ -0,0 +1,367 @@
+from unittest.mock import patch
+
+from django.http import HttpResponse
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from reports.tests.factories import CommitReportFactory, UploadFactory
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+
+def test_upload_completion_view_no_uploads(db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 404
+ assert response_json == {
+ "uploads_total": 0,
+ "uploads_success": 0,
+ "uploads_processing": 0,
+ "uploads_error": 0,
+ }
+
+
+@patch("services.task.TaskService.manual_upload_completion_trigger")
+def test_upload_completion_view_processed_uploads(mocked_manual_trigger, db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report)
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(url, headers={"User-Agent": "codecov-cli/0.4.7"})
+ response_json = response.json()
+ assert response.status_code == 200
+ assert response_json == {
+ "uploads_total": 2,
+ "uploads_success": 2,
+ "uploads_processing": 0,
+ "uploads_error": 0,
+ }
+ mocked_manual_trigger.assert_called_once_with(repository.repoid, commit.commitid)
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "upload_complete",
+ "repo_visibility": "private",
+ "is_using_shelter": "no",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+
+@patch("services.task.TaskService.manual_upload_completion_trigger")
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_upload_completion_view_processed_uploads_github_oidc_auth(
+ mock_jwks_client, mock_jwt_decode, mocked_manual_trigger, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report)
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert response_json == {
+ "uploads_total": 2,
+ "uploads_success": 2,
+ "uploads_processing": 0,
+ "uploads_error": 0,
+ }
+ mocked_manual_trigger.assert_called_once_with(repository.repoid, commit.commitid)
+
+
+def test_upload_completion_view_no_auth(db, mocker):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ token = "BAD"
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report)
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 401
+ assert (
+ response_json.get("detail")
+ == "Failed token authentication, please double-check that your repository token matches in the Codecov UI, "
+ "or review the docs https://docs.codecov.com/docs/adding-the-codecov-token"
+ )
+
+
+@patch("codecov_auth.authentication.repo_auth.exception_handler")
+def test_upload_completion_view_repo_auth_custom_exception_handler_error(
+ customized_error, db, mocker
+):
+ mocked_response = HttpResponse(
+ "No content posted.",
+ status=status.HTTP_401_UNAUTHORIZED,
+ content_type="application/json",
+ )
+ mocked_response.data = "invalid"
+ customized_error.return_value = mocked_response
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ token = "BAD"
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report)
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"token {token}")
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ assert response.status_code == 401
+ assert response == mocked_response
+
+
+@patch("services.task.TaskService.manual_upload_completion_trigger")
+def test_upload_completion_view_still_processing_uploads(
+ mocked_manual_trigger, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report, state="")
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert response_json == {
+ "uploads_total": 2,
+ "uploads_success": 1,
+ "uploads_processing": 1,
+ "uploads_error": 0,
+ }
+ mocked_manual_trigger.assert_called_once_with(repository.repoid, commit.commitid)
+
+
+@patch("services.task.TaskService.manual_upload_completion_trigger")
+def test_upload_completion_view_errored_uploads(mocked_manual_trigger, db, mocker):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report)
+ upload2 = UploadFactory(report=report, state="error")
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert response_json == {
+ "uploads_total": 2,
+ "uploads_success": 1,
+ "uploads_processing": 0,
+ "uploads_error": 1,
+ }
+ mocked_manual_trigger.assert_called_once_with(repository.repoid, commit.commitid)
+
+
+@patch("services.task.TaskService.manual_upload_completion_trigger")
+def test_upload_completion_view_errored_and_processing_uploads(
+ mocked_manual_trigger, db, mocker
+):
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ report = CommitReportFactory(commit=commit)
+ upload1 = UploadFactory(report=report, state="")
+ upload2 = UploadFactory(report=report, state="error")
+ repository.save()
+ commit.save()
+ report.save()
+ upload1.save()
+ upload2.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.upload-complete",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ ],
+ )
+ response = client.post(
+ url,
+ )
+ response_json = response.json()
+ assert response.status_code == 200
+ assert response_json == {
+ "uploads_total": 2,
+ "uploads_success": 0,
+ "uploads_processing": 1,
+ "uploads_error": 1,
+ }
+ mocked_manual_trigger.assert_called_once_with(repository.repoid, commit.commitid)
diff --git a/apps/codecov-api/upload/tests/views/test_upload_coverage.py b/apps/codecov-api/upload/tests/views/test_upload_coverage.py
new file mode 100644
index 0000000000..ec03e1e940
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_upload_coverage.py
@@ -0,0 +1,300 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shared.api_archive.archive import ArchiveService, MinioEndpoints
+from shared.django_apps.core.tests.factories import CommitFactory, RepositoryFactory
+
+from billing.helpers import mock_all_plans_and_tiers
+from reports.models import ReportSession, RepositoryFlag, UploadFlagMembership
+from upload.views.upload_coverage import CanDoCoverageUploadsPermission
+
+
+def test_get_repo(db):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ repository.save()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ response = client.post(url, {}, format="json")
+ assert response.status_code == 400 # Bad request due to missing required fields
+ assert "commitid" in response.json()
+
+
+@patch("services.task.TaskService.upload")
+def test_get_repo_not_found(upload, db):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ repo_slug = "codecov::::wrong-repo-name"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+ client = APIClient()
+ response = client.post(url, {}, format="json")
+ assert response.status_code == 401
+ assert response.json() == {"detail": "Not valid tokenless upload"}
+ assert not upload.called
+
+
+def test_deactivated_repo(db):
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ active=True,
+ activated=False,
+ )
+ repository.save()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+ response = client.post(url, {"commitid": "abc123"}, format="json")
+ assert response.status_code == 400
+ assert "This repository is deactivated" in str(response.json())
+
+
+def test_upload_coverage_with_errors(db):
+ mock_all_plans_and_tiers()
+ repository = RepositoryFactory()
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION="token " + repository.upload_token)
+
+ # Missing required fields
+ response = client.post(url, {}, format="json")
+ assert response.status_code == 400
+ assert "commitid" in response.json()
+
+ # Invalid flag format
+ response = client.post(
+ url, {"commitid": "abc123", "flags": "not-a-list"}, format="json"
+ )
+ assert response.status_code == 400
+ assert "flags" in response.json()
+
+
+def test_upload_coverage_post(db, mocker):
+ mock_all_plans_and_tiers()
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+ amplitude_mock = mocker.patch(
+ "shared.events.amplitude.AmplitudeEventPublisher.publish"
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo1", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {
+ "branch": "branch",
+ "ci_service": "ci_service",
+ "ci_url": "ci_url",
+ "code": "code",
+ "commitid": commit.commitid,
+ "flags": ["flag1", "flag2"],
+ "job_code": "job_code",
+ "version": "version",
+ },
+ format="json",
+ )
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report__commit=commit,
+ report__code="code",
+ upload_extras={"format_version": "v1"},
+ ).first()
+ assert response.status_code == 201
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+ amplitude_mock.assert_called_with(
+ "Upload Received",
+ {
+ "user_ownerid": commit.author.ownerid,
+ "ownerid": commit.repository.author.ownerid,
+ "repoid": commit.repository.repoid,
+ "commitid": commit.id,
+ "pullid": commit.pullid,
+ "upload_type": "Coverage report",
+ },
+ )
+
+ assert ReportSession.objects.filter(
+ report__commit=commit,
+ report__code="code",
+ upload_extras={"format_version": "v1"},
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ archive_service = ArchiveService(repository)
+ assert upload.storage_path == MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=upload.created_at.strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=upload.report.external_id,
+ uploadid=upload.external_id,
+ )
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
+
+
+@override_settings(SHELTER_SHARED_SECRET="shelter-shared-secret")
+def test_upload_coverage_post_shelter(db, mocker):
+ mock_all_plans_and_tiers()
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ repo_slug = f"{repository.author.username}::::{repository.name}"
+ url = reverse(
+ "new_upload.upload_coverage",
+ args=[repository.author.service, repo_slug],
+ )
+ response = client.post(
+ url,
+ {
+ "branch": "branch",
+ "ci_service": "ci_service",
+ "ci_url": "ci_url",
+ "code": "code",
+ "commitid": commit.commitid,
+ "flags": ["flag1", "flag2"],
+ "job_code": "job_code",
+ "storage_path": "shelter/test/path.txt",
+ "version": "version",
+ },
+ headers={
+ "X-Shelter-Token": "shelter-shared-secret",
+ "User-Agent": "codecov-cli/0.4.7",
+ },
+ format="json",
+ )
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report__commit=commit,
+ report__code="code",
+ upload_extras={"format_version": "v1"},
+ ).first()
+ assert response.status_code == 201
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+
+ assert ReportSession.objects.filter(
+ report__commit=commit,
+ report__code="code",
+ upload_extras={"format_version": "v1"},
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ assert upload.storage_path == "shelter/test/path.txt"
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
diff --git a/apps/codecov-api/upload/tests/views/test_uploads.py b/apps/codecov-api/upload/tests/views/test_uploads.py
new file mode 100644
index 0000000000..9a9d822cfc
--- /dev/null
+++ b/apps/codecov-api/upload/tests/views/test_uploads.py
@@ -0,0 +1,1001 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from django.conf import settings
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework.exceptions import ValidationError
+from rest_framework.test import APIClient, APITestCase
+from shared.api_archive.archive import ArchiveService, MinioEndpoints
+from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ RepositoryFactory,
+)
+from shared.plan.constants import PlanName, TierName
+from shared.utils.test_utils import mock_config_helper
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.authentication.repo_auth import OrgLevelTokenRepositoryAuth
+from codecov_auth.services.org_level_token_service import OrgLevelTokenService
+from reports.models import (
+ CommitReport,
+ ReportSession,
+ RepositoryFlag,
+ UploadFlagMembership,
+)
+from reports.tests.factories import CommitReportFactory, UploadFactory
+from upload.views.uploads import (
+ CanDoCoverageUploadsPermission,
+ UploadViews,
+ activate_repo,
+ trigger_upload_task,
+)
+
+
+def test_upload_permission_class_pass(db, mocker):
+ request_mocked = MagicMock(auth=MagicMock())
+ request_mocked.auth.get_scopes.return_value = ["upload"]
+ permission = CanDoCoverageUploadsPermission()
+ assert permission.has_permission(request_mocked, MagicMock())
+ request_mocked.auth.get_scopes.assert_called_once()
+
+
+def test_upload_permission_orglevel_token(db, mocker):
+ tier = TierFactory(tier_name=TierName.ENTERPRISE.value)
+ plan = PlanFactory(name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value, tier=tier)
+ owner = OwnerFactory(plan=plan.name)
+ owner.save()
+ repo = RepositoryFactory(author=owner)
+ repo.save()
+ token = OrgLevelTokenService.get_or_create_org_token(owner)
+
+ request_mocked = MagicMock(auth=OrgLevelTokenRepositoryAuth(token))
+ mocked_view = MagicMock()
+ mocked_view.get_repo = MagicMock(return_value=repo)
+ permission = CanDoCoverageUploadsPermission()
+ assert permission.has_permission(request_mocked, mocked_view)
+ mocked_view.get_repo.assert_called_once()
+
+
+def test_upload_permission_class_fail(db, mocker):
+ request_mocked = MagicMock(auth=MagicMock())
+ request_mocked.auth.get_scopes.return_value = ["wrong_scope"]
+ permission = CanDoCoverageUploadsPermission()
+ assert not permission.has_permission(request_mocked, MagicMock())
+ request_mocked.auth.get_scopes.assert_called_once()
+
+
+def test_upload_permission_orglevel_fail(db, mocker):
+ tier = TierFactory(tier_name=TierName.ENTERPRISE.value)
+ plan = PlanFactory(name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value, tier=tier)
+ owner = OwnerFactory(plan=plan.name)
+ owner.save()
+ repo = RepositoryFactory() # Not the same owner of the token
+ repo.save()
+ token = OrgLevelTokenService.get_or_create_org_token(owner)
+
+ request_mocked = MagicMock(auth=OrgLevelTokenRepositoryAuth(token))
+ mocked_view = MagicMock()
+ mocked_view.get_repo = MagicMock(return_value=repo)
+ permission = CanDoCoverageUploadsPermission()
+ assert not permission.has_permission(request_mocked, mocked_view)
+ mocked_view.get_repo.assert_called_once()
+
+
+def test_uploads_get_not_allowed(client, db, mocker):
+ mock_all_plans_and_tiers()
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the-repo", author__username="codecov", author__service="github"
+ )
+ CommitFactory(repository=repository, commitid="commit-sha")
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.uploads",
+ args=["github", "codecov::::the-repo", "commit-sha", "report-code"],
+ )
+ assert (
+ url
+ == "/upload/github/codecov::::the-repo/commits/commit-sha/reports/report-code/uploads"
+ )
+ res = client.get(url)
+ assert res.status_code == 405
+
+
+def test_get_repo(db):
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ repository.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(repo="codecov::::the_repo", service="github")
+ recovered_repo = upload_views.get_repo()
+ assert recovered_repo == repository
+
+
+def test_get_repo_with_invalid_service(db):
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(repo="repo", service="wrong service")
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_repo()
+ assert exp.match("Service not found: wrong service")
+
+
+def test_get_repo_not_found(db):
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(repo="repo", service="github")
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_repo()
+ assert exp.match("Repository not found")
+
+
+def test_get_commit(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(repo=repository.name, commit_sha=commit.commitid)
+ recovered_commit = upload_views.get_commit(repository)
+ assert recovered_commit == commit
+
+
+def test_get_commit_error(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ repository.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(repo=repository.name, commit_sha="missing_commit")
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_commit(repository)
+ assert exp.match("Commit SHA not found")
+
+
+def test_get_report(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ report = CommitReport(commit=commit)
+ repository.save()
+ commit.save()
+ report.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(
+ repo=repository.name, commit_sha=commit.commitid, report_code=report.code
+ )
+ recovered_report = upload_views.get_report(commit)
+ assert recovered_report == report
+
+
+def test_get_default_report(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ report = CommitReport(commit=commit)
+ repository.save()
+ commit.save()
+ report.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(
+ repo=repository.name, commit_sha=commit.commitid, report_code="default"
+ )
+ recovered_report = upload_views.get_report(commit)
+ assert recovered_report == report
+
+
+def test_get_report_error(db):
+ repository = RepositoryFactory(name="the_repo", author__username="codecov")
+ commit = CommitFactory(repository=repository)
+ repository.save()
+ commit.save()
+ upload_views = UploadViews()
+ upload_views.kwargs = dict(
+ repo=repository.name, commit_sha=commit.commitid, report_code="random_code"
+ )
+ with pytest.raises(ValidationError) as exp:
+ upload_views.get_report(commit)
+ assert exp.match("Report not found")
+
+
+def test_uploads_post(db, mocker, mock_redis):
+ mock_all_plans_and_tiers()
+ # TODO remove the mock object and test the flow with the permissions
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ amplitude_mock = mocker.patch(
+ "shared.events.amplitude.AmplitudeEventPublisher.publish"
+ )
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ # this cannot be passed in by default
+ "storage_path": "this/path/should/be/ingored.txt",
+ },
+ )
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).first()
+ assert response.status_code == 201
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+
+ amplitude_mock.assert_called_with(
+ "Upload Received",
+ {
+ "user_ownerid": commit.author.ownerid,
+ "ownerid": repository.author.ownerid,
+ "repoid": repository.repoid,
+ "commitid": commit.id,
+ "pullid": commit.pullid,
+ "upload_type": "Coverage report",
+ },
+ )
+ assert ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ archive_service = ArchiveService(repository)
+ assert upload.storage_path == MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=upload.created_at.strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=commit_report.external_id,
+ uploadid=upload.external_id,
+ )
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
+
+
+@pytest.mark.parametrize("private", [False, True])
+@pytest.mark.parametrize("branch", ["branch", "fork:branch", "someone/fork:branch"])
+@pytest.mark.parametrize(
+ "branch_sent", [None, "branch", "fork:branch", "someone/fork:branch"]
+)
+def test_uploads_post_tokenless(db, mocker, mock_redis, private, branch, branch_sent):
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+ analytics_service_mock = mocker.patch("upload.views.uploads.AnalyticsService")
+
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ private=private,
+ author__upload_token_required_for_public_repos=True,
+ )
+ commit = CommitFactory(repository=repository)
+ commit.branch = branch
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ if branch_sent is not None:
+ data = {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ "branch": branch_sent,
+ }
+ else:
+ data = {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ }
+ response = client.post(
+ url,
+ data,
+ )
+
+ if private is False and ":" in branch:
+ assert response.status_code == 201
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).first()
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+
+ assert ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ archive_service = ArchiveService(repository)
+ assert upload.storage_path == MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=upload.created_at.strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=commit_report.external_id,
+ uploadid=upload.external_id,
+ )
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
+ analytics_service_mock.return_value.account_uploaded_coverage_report.assert_called_with(
+ commit.repository.author.ownerid,
+ {
+ "commit": commit.commitid,
+ "branch": commit.branch,
+ "pr": commit.pullid,
+ "repo": commit.repository.name,
+ "repository_name": commit.repository.name,
+ "repository_id": commit.repository.repoid,
+ "service": commit.repository.service,
+ "build": upload.build_code,
+ "build_url": upload.build_url,
+ "flags": "",
+ "owner": commit.repository.author.ownerid,
+ "token": "tokenless_upload",
+ "version": "version",
+ "uploader_type": "CLI",
+ },
+ )
+ else:
+ assert response.status_code == 401
+ assert response.json().get("detail") == "Not valid tokenless upload"
+
+
+@pytest.mark.parametrize("private", [False, True])
+@pytest.mark.parametrize("branch", ["branch", "fork:branch", "someone/fork:branch"])
+@pytest.mark.parametrize(
+ "branch_sent", [None, "branch", "fork:branch", "someone/fork:branch"]
+)
+@pytest.mark.parametrize("upload_token_required_for_public_repos", [True, False])
+def test_uploads_post_token_required_auth_check(
+ db,
+ mocker,
+ mock_redis,
+ private,
+ branch,
+ branch_sent,
+ upload_token_required_for_public_repos,
+):
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+ analytics_service_mock = mocker.patch("upload.views.uploads.AnalyticsService")
+
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ private=private,
+ author__upload_token_required_for_public_repos=upload_token_required_for_public_repos,
+ )
+ commit = CommitFactory(repository=repository)
+ commit.branch = branch
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+ commit.save()
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ if branch_sent is not None:
+ data = {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ "branch": branch_sent,
+ }
+ else:
+ data = {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ }
+ response = client.post(
+ url,
+ data,
+ )
+
+ # when TokenlessAuthentication is removed, this test should use `if private == False and upload_token_required_for_public_repos == False:`
+ # but TokenlessAuthentication lets some additional uploads through.
+ authorized_by_tokenless_auth_class = ":" in branch
+
+ if private == False and (
+ upload_token_required_for_public_repos == False
+ or authorized_by_tokenless_auth_class
+ ):
+ assert response.status_code == 201
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).first()
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+
+ assert ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ archive_service = ArchiveService(repository)
+ assert upload.storage_path == MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=upload.created_at.strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=commit_report.external_id,
+ uploadid=upload.external_id,
+ )
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
+ analytics_service_mock.return_value.account_uploaded_coverage_report.assert_called_with(
+ commit.repository.author.ownerid,
+ {
+ "commit": commit.commitid,
+ "branch": commit.branch,
+ "pr": commit.pullid,
+ "repo": commit.repository.name,
+ "repository_name": commit.repository.name,
+ "repository_id": commit.repository.repoid,
+ "service": commit.repository.service,
+ "build": upload.build_code,
+ "build_url": upload.build_url,
+ "flags": "",
+ "owner": commit.repository.author.ownerid,
+ "token": "tokenless_upload",
+ "version": "version",
+ "uploader_type": "CLI",
+ },
+ )
+ else:
+ assert response.status_code == 401
+ assert response.json().get("detail") == "Not valid tokenless upload"
+
+
+@patch("upload.views.uploads.AnalyticsService")
+@patch("upload.helpers.jwt.decode")
+@patch("upload.helpers.PyJWKClient")
+def test_uploads_post_github_oidc_auth(
+ mock_jwks_client,
+ mock_jwt_decode,
+ analytics_service_mock,
+ db,
+ mocker,
+ mock_redis,
+):
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ upload_task_mock = mocker.patch(
+ "upload.views.uploads.trigger_upload_task", return_value=True
+ )
+
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ author__upload_token_required_for_public_repos=True,
+ private=False,
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://token.actions.githubusercontent.com",
+ "audience": [settings.CODECOV_API_URL],
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ },
+ headers={"Authorization": f"token {token}"},
+ )
+ assert response.status_code == 201
+ response_json = response.json()
+ upload = ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).first()
+ assert all(
+ (
+ x in response_json.keys()
+ for x in ["external_id", "created_at", "raw_upload_location", "url"]
+ )
+ )
+ assert (
+ response_json.get("url")
+ == f"{settings.CODECOV_DASHBOARD_URL}/{repository.author.service}/{repository.author.username}/{repository.name}/commit/{commit.commitid}"
+ )
+
+ assert ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).exists()
+ assert RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).exists()
+ flag1 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag1"
+ ).first()
+ flag2 = RepositoryFlag.objects.filter(
+ repository_id=repository.repoid, flag_name="flag2"
+ ).first()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag1.id
+ ).exists()
+ assert UploadFlagMembership.objects.filter(
+ report_session_id=upload.id, flag_id=flag2.id
+ ).exists()
+ assert list(upload.flags.all()) == [flag1, flag2]
+
+ archive_service = ArchiveService(repository)
+ assert upload.storage_path == MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=upload.created_at.strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=commit_report.external_id,
+ uploadid=upload.external_id,
+ )
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+ upload_task_mock.assert_called()
+ analytics_service_mock.return_value.account_uploaded_coverage_report.assert_called_with(
+ commit.repository.author.ownerid,
+ {
+ "commit": commit.commitid,
+ "branch": commit.branch,
+ "pr": commit.pullid,
+ "repo": commit.repository.name,
+ "repository_name": commit.repository.name,
+ "repository_id": commit.repository.repoid,
+ "service": commit.repository.service,
+ "build": upload.build_code,
+ "build_url": upload.build_url,
+ "flags": "",
+ "owner": commit.repository.author.ownerid,
+ "token": "oidc_token_upload",
+ "version": "version",
+ "uploader_type": "CLI",
+ },
+ )
+
+
+@override_settings(SHELTER_SHARED_SECRET="shelter-shared-secret")
+def test_uploads_post_shelter(db, mocker, mock_redis):
+ mock_all_plans_and_tiers()
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ presigned_put_mock = mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ mocker.patch("upload.views.uploads.trigger_upload_task", return_value=True)
+ mock_prometheus_metrics = mocker.patch("upload.metrics.API_UPLOAD_COUNTER.labels")
+
+ repository = RepositoryFactory(
+ name="the_repo", author__username="codecov", author__service="github"
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ "storage_path": "shelter/test/path.txt",
+ },
+ headers={
+ "X-Shelter-Token": "shelter-shared-secret",
+ "User-Agent": "codecov-cli/0.4.7",
+ },
+ )
+
+ mock_prometheus_metrics.assert_called_with(
+ **{
+ "agent": "cli",
+ "version": "0.4.7",
+ "action": "coverage",
+ "endpoint": "create_upload",
+ "repo_visibility": "private",
+ "is_using_shelter": "yes",
+ "position": "end",
+ "upload_version": None,
+ },
+ )
+
+ upload = ReportSession.objects.filter(
+ report_id=commit_report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ ).first()
+ assert response.status_code == 201
+ ArchiveService(repository)
+ assert upload.storage_path == "shelter/test/path.txt"
+ presigned_put_mock.assert_called_with("archive", upload.storage_path, 10)
+
+
+def test_deactivated_repo(db, mocker, mock_redis):
+ mock_all_plans_and_tiers()
+ mocker.patch.object(
+ CanDoCoverageUploadsPermission, "has_permission", return_value=True
+ )
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github",
+ active=True,
+ activated=False,
+ )
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+ repository.save()
+ commit_report.save()
+
+ owner = repository.author
+ client = APIClient()
+ client.force_authenticate(user=owner)
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {"state": "uploaded"},
+ format="json",
+ )
+ response_json = response.json()
+ assert response.status_code == 400
+ assert response_json == [
+ f"This repository is deactivated. To resume uploading to it, please activate the repository in the codecov UI: {settings.CODECOV_DASHBOARD_URL}/github/codecov/the_repo/config/general"
+ ]
+
+
+def test_trigger_upload_task(db, mocker):
+ repo = RepositoryFactory.create()
+ upload = UploadFactory.create()
+ report = CommitReportFactory.create()
+ commitid = "commit id"
+ mocked_redis = mocker.patch("upload.views.uploads.get_redis_connection")
+ mocked_dispatched_task = mocker.patch("upload.views.uploads.dispatch_upload_task")
+ trigger_upload_task(repo, commitid, upload, report)
+ mocked_redis.assert_called()
+ mocked_dispatched_task.assert_called()
+
+
+def test_activate_repo(db):
+ repo = RepositoryFactory(
+ active=False, deleted=True, activated=False, coverage_enabled=False
+ )
+ activate_repo(repo)
+ assert repo.active
+ assert repo.activated
+ assert not repo.deleted
+ assert repo.coverage_enabled
+
+
+def test_activate_already_activated_repo(db):
+ repo = RepositoryFactory(
+ active=True, activated=True, deleted=False, coverage_enabled=True
+ )
+ activate_repo(repo)
+ assert repo.active
+
+
+class TestGitlabEnterpriseOIDC(APITestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(request, mocker):
+ request.mocker = mocker
+
+ @pytest.fixture(autouse=True)
+ def mock_config(self, mocker):
+ mock_config_helper(
+ mocker, configs={"github_enterprise.url": "https://example.com/"}
+ )
+
+ @patch("upload.views.uploads.AnalyticsService")
+ @patch("upload.helpers.jwt.decode")
+ @patch("upload.helpers.PyJWKClient")
+ def test_uploads_post_github_enterprise_oidc_auth_jwks_url(
+ self,
+ mock_jwks_client,
+ mock_jwt_decode,
+ analytics_service_mock,
+ ):
+ self.mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ self.mocker.patch("upload.views.uploads.trigger_upload_task", return_value=True)
+
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github_enterprise",
+ author__upload_token_required_for_public_repos=True,
+ private=False,
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://enterprise-client.actions.githubusercontent.com",
+ "audience": [settings.CODECOV_API_URL],
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github_enterprise",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ },
+ headers={"Authorization": f"token {token}"},
+ )
+ assert response.status_code == 201
+ mock_jwks_client.assert_called_with(
+ "https://example.com/_services/token/.well-known/jwks"
+ )
+
+ @patch("upload.views.uploads.AnalyticsService")
+ @patch("upload.helpers.jwt.decode")
+ @patch("upload.helpers.PyJWKClient")
+ def test_uploads_post_github_enterprise_oidc_auth_no_url(
+ self,
+ mock_jwks_client,
+ mock_jwt_decode,
+ analytics_service_mock,
+ ):
+ mock_config_helper(self.mocker, configs={"github_enterprise.url": None})
+ self.mocker.patch(
+ "shared.storage.MinioStorageService.create_presigned_put",
+ return_value="presigned put",
+ )
+ self.mocker.patch("upload.views.uploads.trigger_upload_task", return_value=True)
+
+ repository = RepositoryFactory(
+ name="the_repo",
+ author__username="codecov",
+ author__service="github_enterprise",
+ author__upload_token_required_for_public_repos=True,
+ private=False,
+ )
+ mock_jwt_decode.return_value = {
+ "repository": f"url/{repository.name}",
+ "repository_owner": repository.author.username,
+ "iss": "https://enterprise-client.actions.githubusercontent.com",
+ "audience": [settings.CODECOV_API_URL],
+ }
+ token = "ThisValueDoesNotMatterBecauseOf_mock_jwt_decode"
+
+ commit = CommitFactory(repository=repository)
+ commit_report = CommitReport.objects.create(commit=commit, code="code")
+
+ client = APIClient()
+ url = reverse(
+ "new_upload.uploads",
+ args=[
+ "github_enterprise",
+ "codecov::::the_repo",
+ commit.commitid,
+ commit_report.code,
+ ],
+ )
+ response = client.post(
+ url,
+ {
+ "state": "uploaded",
+ "flags": ["flag1", "flag2"],
+ "version": "version",
+ },
+ headers={"Authorization": f"token {token}"},
+ )
+ assert response.status_code == 401
+ assert response.json().get("detail") == "Not valid tokenless upload"
diff --git a/apps/codecov-api/upload/throttles.py b/apps/codecov-api/upload/throttles.py
new file mode 100644
index 0000000000..42bc19ab80
--- /dev/null
+++ b/apps/codecov-api/upload/throttles.py
@@ -0,0 +1,78 @@
+import logging
+
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
+from django.http import HttpRequest
+from rest_framework.exceptions import ValidationError
+from rest_framework.throttling import BaseThrottle
+from rest_framework.views import APIView
+from shared.helpers.redis import get_redis_connection
+from shared.plan.service import PlanService
+from shared.reports.enums import UploadType
+from shared.upload.utils import query_monthly_coverage_measurements
+
+from reports.models import ReportSession
+from upload.helpers import _determine_responsible_owner
+
+log = logging.getLogger(__name__)
+
+redis = get_redis_connection()
+
+
+class UploadsPerCommitThrottle(BaseThrottle):
+ def allow_request(self, request: HttpRequest, view: APIView) -> bool:
+ try:
+ repository = view.get_repo()
+ commit = view.get_commit(repository)
+ new_session_count = ReportSession.objects.filter(
+ ~Q(state="error"),
+ ~Q(upload_type=UploadType.CARRIEDFORWARD.db_name),
+ report__commit=commit,
+ ).count()
+ max_upload_limit = repository.author.max_upload_limit or 150
+ if new_session_count > max_upload_limit:
+ log.warning(
+ "Too many uploads to this commit",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=repository.repoid,
+ ),
+ )
+ return False
+ return True
+ except (ObjectDoesNotExist, ValidationError):
+ return True
+
+
+class UploadsPerWindowThrottle(BaseThrottle):
+ def allow_request(self, request: HttpRequest, view: APIView) -> bool:
+ try:
+ repository = view.get_repo()
+ commit = view.get_commit(repository)
+
+ if settings.UPLOAD_THROTTLING_ENABLED and repository.private:
+ owner = _determine_responsible_owner(repository)
+ plan_service = PlanService(current_org=owner)
+ limit = plan_service.monthly_uploads_limit
+ if limit is not None:
+ did_commit_uploads_start_already = ReportSession.objects.filter(
+ report__commit=commit
+ ).exists()
+ if not did_commit_uploads_start_already:
+ if (
+ query_monthly_coverage_measurements(
+ plan_service=plan_service
+ )
+ >= limit
+ ):
+ log.warning(
+ "User exceeded its limits for usage",
+ extra=dict(
+ ownerid=owner.ownerid, repoid=commit.repository_id
+ ),
+ )
+ return False
+ return True
+ except (ObjectDoesNotExist, ValidationError):
+ return True
diff --git a/apps/codecov-api/upload/tokenless/__init__.py b/apps/codecov-api/upload/tokenless/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/upload/tokenless/appveyor.py b/apps/codecov-api/upload/tokenless/appveyor.py
new file mode 100644
index 0000000000..9b7d56855f
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/appveyor.py
@@ -0,0 +1,75 @@
+import logging
+from typing import Any, Dict
+
+import requests
+from requests.exceptions import ConnectionError, HTTPError
+from rest_framework.exceptions import NotFound
+
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessAppveyorHandler(BaseTokenlessUploadHandler):
+ def get_build(self) -> Dict[str, Any]:
+ try:
+ build = requests.get(
+ "https://ci.appveyor.com/api/projects/{}/{}/build/{}".format(
+ *self.job.split("/", 2)
+ ),
+ headers={"Accept": "application/json", "User-Agent": "Codecov"},
+ )
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"HTTP error {e}",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Unable to locate build via Appveyor API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ if not build:
+ raise NotFound(
+ "Unable to locate build via Appveyor API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ return build.json()
+
+ def verify(self) -> str:
+ if not self.upload_params.get("job"):
+ raise NotFound(
+ 'Missing "job" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+
+ self.job = (
+ self.upload_params.get("job")
+ if "/" in self.upload_params.get("job")
+ else (
+ f"{self.upload_params.get('owner')}/{self.upload_params.get('repo')}/{self.upload_params.get('job')}"
+ )
+ )
+
+ self.job = self.job.replace("+", "%20").replace(" ", "%20")
+
+ build = self.get_build()
+
+ # validate build
+ if not any(
+ filter(
+ lambda j: j["jobId"] == self.upload_params.get("build", "") # type: ignore
+ and j.get("finished") is None,
+ build["build"]["jobs"],
+ )
+ ):
+ raise NotFound(
+ "Build already finished, unable to accept new reports. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ service = self.check_repository_type(build["project"]["repositoryType"])
+
+ return service
diff --git a/apps/codecov-api/upload/tokenless/azure.py b/apps/codecov-api/upload/tokenless/azure.py
new file mode 100644
index 0000000000..4e871b505b
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/azure.py
@@ -0,0 +1,141 @@
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+import requests
+from requests.exceptions import ConnectionError, HTTPError
+from rest_framework.exceptions import NotFound
+from simplejson import JSONDecodeError
+
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessAzureHandler(BaseTokenlessUploadHandler):
+ def get_build(self) -> Dict[str, Any]:
+ try:
+ response = requests.get(
+ f"{self.server_uri}{self.project}/_apis/build/builds/{self.job}?api-version=5.0",
+ headers={"Accept": "application/json", "User-Agent": "Codecov"},
+ )
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"Request error {e}",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Unable to locate build via Azure API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ if not response:
+ raise NotFound(
+ "Unable to locate build via Azure API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+ try:
+ build = response.json()
+ except JSONDecodeError as e:
+ log.warning(
+ f"Expected JSON in Azure response, got error {e} instead",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ response=response,
+ ),
+ )
+ raise NotFound(
+ "Unable to locate build via Azure API. Project is likely private, please upload with the Codecov repository upload token to resolve issue."
+ )
+ return build
+
+ def verify(self) -> None:
+ if not self.upload_params.get("job"):
+ raise NotFound(
+ 'Missing "job" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.job = self.upload_params.get("job")
+
+ if not self.upload_params.get("project"):
+ raise NotFound(
+ 'Missing "project" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.project = self.upload_params.get("project")
+
+ if not self.upload_params.get("server_uri"):
+ raise NotFound(
+ 'Missing "server_uri" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.server_uri = self.upload_params.get("server_uri")
+
+ build = self.get_build()
+
+ # Build should have finished within the last 4 mins OR should have an 'inProgress' flag
+ if build["status"] == "completed":
+ finishTimestamp = build["finishTime"].replace("T", " ").replace("Z", "")
+ # Azure DevOps API returns nanosecond precision (7 digits), but Python only supports
+ # microsecond precision (6 digits). Truncate to 6 digits after decimal.
+ if "." in finishTimestamp:
+ base, fraction = finishTimestamp.rsplit(".", 1)
+ finishTimestamp = f"{base}.{fraction[:6]}"
+ buildFinishDateObj = datetime.strptime(
+ finishTimestamp, "%Y-%m-%d %H:%M:%S.%f"
+ )
+ finishTimeWithBuffer = buildFinishDateObj + timedelta(minutes=4)
+ now = datetime.now()
+ if not now <= finishTimeWithBuffer:
+ raise NotFound(
+ "Azure build has already finished. Please upload with the Codecov repository upload token to resolve issue."
+ )
+ else:
+ if build["status"].lower() != "inprogress":
+ raise NotFound(
+ "Azure build has already finished. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ # Check build ID
+ build["buildNumber"] = build["buildNumber"].replace("+", " ")
+ self.upload_params["build"] = self.upload_params.get("build").replace("+", " ")
+ if build["buildNumber"] != self.upload_params.get("build"):
+ log.warning(
+ f"Azure build numbers do not match. Upload build number: {self.upload_params.get('build')}, Azure build number: {self.upload_params.get('buildNumber')}",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Build numbers do not match. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ # Make sure commit sha matches
+ if build["sourceVersion"] != self.upload_params.get("commit") and (
+ build.get("triggerInfo", {}).get("pr.sourceSha")
+ != self.upload_params.get("commit")
+ ):
+ log.warning(
+ "Commit sha does not match Azure build",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Commit sha does not match Azure build. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ # Azure supports various repo types, ensure current repo type is supported on Codecov
+ service = self.check_repository_type(build["repository"]["type"])
+
+ # Validation step is complete, return repo type
+ return service
diff --git a/apps/codecov-api/upload/tokenless/base.py b/apps/codecov-api/upload/tokenless/base.py
new file mode 100644
index 0000000000..cf93e4033b
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/base.py
@@ -0,0 +1,19 @@
+from rest_framework.exceptions import NotFound
+
+
+class BaseTokenlessUploadHandler(object):
+ def __init__(self, upload_params):
+ self.upload_params = upload_params
+
+ def check_repository_type(self, repository_type):
+ if repository_type.lower() not in ("github", "gitlab", "bitbucket"):
+ raise NotFound(
+ "Sorry this service is not supported. Codecov currently only works with GitHub, GitLab, and BitBucket repositories"
+ )
+ return repository_type.lower()
+
+ def get_build(self):
+ raise NotImplementedError()
+
+ def verify(self):
+ raise NotImplementedError()
diff --git a/apps/codecov-api/upload/tokenless/circleci.py b/apps/codecov-api/upload/tokenless/circleci.py
new file mode 100644
index 0000000000..1aea3cb27e
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/circleci.py
@@ -0,0 +1,77 @@
+import logging
+
+import requests
+from django.conf import settings
+from requests.exceptions import ConnectionError, HTTPError
+from rest_framework.exceptions import NotFound
+
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessCircleciHandler(BaseTokenlessUploadHandler):
+ circleci_token = settings.CIRCLECI_TOKEN
+
+ def get_build(self):
+ build_num = self.build.split(".")[0]
+ try:
+ build = requests.get(
+ f"https://circleci.com/api/v1/project/{self.owner}/{self.repo}/{build_num}?circle-token={self.circleci_token}",
+ headers={"Accept": "application/json", "User-Agent": "Codecov"},
+ )
+ return build.json()
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"Request error {e}",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Unable to locate build via CircleCI API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ def verify(self):
+ if not self.upload_params.get("build"):
+ raise NotFound(
+ 'Missing "build" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.build = self.upload_params.get("build")
+
+ if not self.upload_params.get("owner"):
+ raise NotFound(
+ 'Missing "owner" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.owner = self.upload_params.get("owner")
+
+ if not self.upload_params.get("repo"):
+ raise NotFound(
+ 'Missing "repo" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ self.repo = self.upload_params.get("repo")
+
+ build = self.get_build()
+
+ if build.get("vcs_revision", "") != self.upload_params.get("commit"):
+ log.warning(
+ "Failed to fetch commit from CircleCI",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ vcs_revision=build.get("vcs_revision", ""),
+ repo_name=self.upload_params.get("repo"),
+ build_num=self.build.split(".")[0],
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ raise NotFound(
+ "Commit sha does not match Circle build. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ if build.get("stop_time") is not None:
+ raise NotFound("Build has already finished, uploads rejected.")
+
+ return build["vcs_type"]
diff --git a/apps/codecov-api/upload/tokenless/cirrus.py b/apps/codecov-api/upload/tokenless/cirrus.py
new file mode 100644
index 0000000000..9dc5350225
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/cirrus.py
@@ -0,0 +1,163 @@
+import logging
+import time
+from typing import Any, Dict
+
+import requests
+from requests.exceptions import ConnectionError, HTTPError
+from rest_framework.exceptions import NotFound
+
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessCirrusHandler(BaseTokenlessUploadHandler):
+ def get_build(self) -> Dict[str, Any]:
+ query = f"""{{
+ "query": "query ($buildId: ID!) {{
+ build(id: $buildId) {{
+ buildCreatedTimestamp,
+ changeIdInRepo,
+ durationInSeconds,
+ repository {{
+ name,
+ owner
+ }},
+ status
+ }}
+ }}",
+ "variables": {{
+ "buildId": {self.upload_params.get("build")}
+ }}
+ }}"""
+
+ try:
+ response = requests.post(
+ "https://api.cirrus-ci.com/graphql",
+ data=query,
+ headers={"Content-Type": "application/json", "User-Agent": "Codecov"},
+ )
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"Request error {e}",
+ extra=dict(
+ build=self.upload_params["build"],
+ commit=self.upload_params["commit"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ repo_name=self.upload_params["repo"],
+ ),
+ )
+ raise NotFound(
+ "Unable to locate build via Cirrus CI API. Please upload with the Codecov repository upload token to resolve this issue."
+ )
+
+ build = response.json()
+ log.info(
+ "Cirrus CI build response found.",
+ extra=dict(build=build, upload_params=self.upload_params),
+ )
+ if "errors" in build or build.get("data") is None:
+ log.warning(
+ "Build Error",
+ extra=dict(
+ build=self.upload_params["build"],
+ commit=self.upload_params["commit"],
+ error=build["errors"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ repo_name=self.upload_params["repo"],
+ ),
+ )
+ raise NotFound(
+ "Could not retrieve build via Cirrus CI API. Please upload with the Codecov repository upload token to resolve this issue."
+ )
+
+ return build
+
+ def verify(self) -> str:
+ if not self.upload_params.get("owner"):
+ raise NotFound(
+ 'Missing "owner" argument. Please upload with the Codecov repository upload token to resolve this issue.'
+ )
+ owner = self.upload_params.get("owner")
+
+ if not self.upload_params.get("repo"):
+ raise NotFound(
+ 'Missing "repo" argument. Please upload with the Codecov repository upload token to resolve this issue.'
+ )
+ repo = self.upload_params.get("repo")
+
+ if not self.upload_params.get("commit"):
+ raise NotFound(
+ 'Missing "commit" argument. Please upload with the Codecov repository upload token to resolve this issue.'
+ )
+ commit = self.upload_params.get("commit")
+
+ raw_build = self.get_build()
+ build = raw_build["data"]["build"]
+
+ # Check repository
+ if build["repository"]["owner"] != owner or build["repository"]["name"] != repo:
+ log.warning(
+ "Repository slug does not match Cirrus arguments",
+ extra=dict(
+ build_info=build,
+ commit=commit,
+ job=self.upload_params.get("job"),
+ owner=owner,
+ repo_name=repo,
+ ),
+ )
+ raise NotFound(
+ "Repository slug does not match Cirrus CI build. Please upload with the Codecov repository upload token to resolve this issue."
+ )
+
+ # Check commit SHA
+ if build["changeIdInRepo"] != commit:
+ log.warning(
+ "Commit sha does not match Github actions arguments",
+ extra=dict(
+ build_info=build,
+ commit=commit,
+ job=self.upload_params.get("job"),
+ owner=owner,
+ repo_name=repo,
+ ),
+ )
+ raise NotFound(
+ "Commit sha does not match Cirrus CI build. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ # Check if current status is correct
+ if build.get("status") != "EXECUTING":
+ finishTimestamp = (
+ build.get("buildCreatedTimestamp")
+ + build.get("durationInSeconds")
+ + (4 * 60) # Add 4 minutes buffer
+ )
+ now = time.time()
+ if now > finishTimestamp:
+ log.warning(
+ "Cirrus run is stale",
+ extra=dict(
+ build_info=build,
+ commit=commit,
+ job=self.upload_params.get("job"),
+ owner=owner,
+ repo_name=repo,
+ ),
+ )
+ log.warning(
+ "Cirrus run is stale",
+ extra=dict(
+ build_info=build,
+ commit=commit,
+ job=self.upload_params.get("job"),
+ owner=owner,
+ repo_name=repo,
+ ),
+ )
+ raise NotFound("Cirrus run is stale")
+
+ return "github"
diff --git a/apps/codecov-api/upload/tokenless/github_actions.py b/apps/codecov-api/upload/tokenless/github_actions.py
new file mode 100644
index 0000000000..b508351544
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/github_actions.py
@@ -0,0 +1,137 @@
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from rest_framework import exceptions
+from rest_framework.exceptions import NotFound
+from shared.torngit import get
+from shared.torngit.exceptions import TorngitClientError, TorngitRateLimitError
+
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessGithubActionsHandler(BaseTokenlessUploadHandler):
+ actions_token = settings.GITHUB_ACTIONS_TOKEN
+ client_id = settings.GITHUB_CLIENT_ID
+ client_secret = settings.GITHUB_CLIENT_SECRET
+
+ def log_warning(self, message: str) -> None:
+ log.warning(
+ message,
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+
+ def get_build(self) -> Dict[str, Any]:
+ git = get(
+ "github",
+ token=dict(key=self.actions_token),
+ repo=dict(name=self.upload_params.get("repo")),
+ owner=dict(username=self.upload_params.get("owner")),
+ oauth_consumer_token=dict(key=self.client_id, secret=self.client_secret),
+ )
+
+ try:
+ actions_response = async_to_sync(git.get_workflow_run)(
+ self.upload_params.get("build")
+ )
+ except TorngitRateLimitError as e:
+ self.log_warning(message=f"Rate limit error {e}")
+ if e.reset:
+ now_timestamp = datetime.now().timestamp()
+ retry_after = int(e.reset) - int(now_timestamp)
+ elif e.retry_after:
+ retry_after = int(e.retry_after)
+ else:
+ retry_after = None
+ time_to_available_str = ""
+ if retry_after is not None:
+ time_to_available_str = (
+ f" Expected time to availability: {retry_after}s."
+ )
+ raise exceptions.Throttled(
+ wait=None,
+ detail=f"Rate limit reached. Please upload with the Codecov repository upload token to resolve issue.{time_to_available_str}",
+ )
+ except TorngitClientError as e:
+ self.log_warning(message=f"Request client error {e}")
+ raise NotFound(
+ "Unable to locate build via Github Actions API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+ except Exception as e:
+ self.log_warning(message=f"Request error {e}")
+ raise NotFound(
+ "Unable to locate build via Github Actions API. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ return actions_response
+
+ def verify(self) -> str:
+ if not self.upload_params.get("owner"):
+ raise NotFound(
+ 'Missing "owner" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ owner = self.upload_params.get("owner")
+
+ if not self.upload_params.get("repo"):
+ raise NotFound(
+ 'Missing "repo" argument. Please upload with the Codecov repository upload token to resolve issue.'
+ )
+ repo = self.upload_params.get("repo")
+
+ build = self.get_build()
+
+ if (
+ build["public"] != True
+ or build["slug"] != f"{owner}/{repo}"
+ or (
+ build["commit_sha"] != self.upload_params.get("commit")
+ and self.upload_params.get("pr") is None
+ )
+ ):
+ self.log_warning(
+ message="Repository slug or commit sha do not match Github actions arguments"
+ )
+ raise NotFound(
+ "Repository slug or commit sha do not match Github actions build. Please upload with the Codecov repository upload token to resolve issue."
+ )
+
+ # Check if current status is correct (not stale or in progress)
+ if build.get("status") not in ["in_progress", "queued"]:
+ # Verify workflow finished within the last 4 minutes because it's not in-progress
+ try:
+ build_finish_date_obj = datetime.strptime(
+ build["finish_time"], "%Y-%m-%dT%H:%M:%SZ"
+ )
+ except ValueError:
+ build_finish_date_obj = datetime.strptime(
+ build["finish_time"], "%Y-%m-%d %H:%M:%S"
+ )
+
+ finish_time_with_buffer = build_finish_date_obj + timedelta(minutes=10)
+ now = datetime.now()
+ if not now <= finish_time_with_buffer:
+ log.warning(
+ "Actions workflow run is stale",
+ extra=dict(
+ build=build,
+ commit=self.upload_params.get("commit"),
+ finish_time_with_buffer=finish_time_with_buffer,
+ job=self.upload_params.get("job"),
+ now=now,
+ owner=self.upload_params.get("owner"),
+ repo_name=self.upload_params.get("repo"),
+ time_diff=now - finish_time_with_buffer,
+ ),
+ )
+ raise NotFound("Actions workflow run is stale")
+
+ return "github"
diff --git a/apps/codecov-api/upload/tokenless/tokenless.py b/apps/codecov-api/upload/tokenless/tokenless.py
new file mode 100644
index 0000000000..54861e4048
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/tokenless.py
@@ -0,0 +1,45 @@
+import logging
+
+from rest_framework.exceptions import NotFound
+
+from upload.tokenless.appveyor import TokenlessAppveyorHandler
+from upload.tokenless.azure import TokenlessAzureHandler
+from upload.tokenless.circleci import TokenlessCircleciHandler
+from upload.tokenless.cirrus import TokenlessCirrusHandler
+from upload.tokenless.github_actions import TokenlessGithubActionsHandler
+from upload.tokenless.travis import TokenlessTravisHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessUploadHandler(object):
+ ci_verifiers = {
+ "appveyor": TokenlessAppveyorHandler,
+ "azure_pipelines": TokenlessAzureHandler,
+ "circleci": TokenlessCircleciHandler,
+ "cirrus_ci": TokenlessCirrusHandler,
+ "github_actions": TokenlessGithubActionsHandler,
+ "travis": TokenlessTravisHandler,
+ }
+
+ def __init__(self, ci_type, upload_params):
+ self.verifier = self.ci_verifiers.get(ci_type.replace("-", "_"), None)
+ self.upload_params = upload_params
+ self.ci_type = ci_type
+
+ def verify_upload(self):
+ log.info(
+ f"Started {self.ci_type} tokenless upload",
+ extra=dict(
+ commit=self.upload_params.get("commit"),
+ repo_name=self.upload_params.get("repo"),
+ job=self.upload_params.get("job"),
+ owner=self.upload_params.get("owner"),
+ ),
+ )
+ try:
+ return self.verifier(self.upload_params).verify()
+ except TypeError:
+ raise NotFound(
+ "Your CI provider is not compatible with tokenless uploads, please upload using your repository token to resolve this."
+ )
diff --git a/apps/codecov-api/upload/tokenless/travis.py b/apps/codecov-api/upload/tokenless/travis.py
new file mode 100644
index 0000000000..0054f89a59
--- /dev/null
+++ b/apps/codecov-api/upload/tokenless/travis.py
@@ -0,0 +1,160 @@
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+import requests
+from requests.exceptions import ConnectionError, HTTPError
+from rest_framework.exceptions import NotFound
+
+from upload.constants import errors
+from upload.tokenless.base import BaseTokenlessUploadHandler
+
+log = logging.getLogger(__name__)
+
+
+class TokenlessTravisHandler(BaseTokenlessUploadHandler):
+ def get_build(self) -> Dict[str, Any]:
+ travis_dot_com = False
+
+ try:
+ build = requests.get(
+ "https://api.travis-ci.com/job/{}".format(self.upload_params["job"]),
+ headers={"Travis-API-Version": "3", "User-Agent": "Codecov"},
+ )
+ travis_dot_com = (
+ build.json()["repository"]["slug"]
+ == f"{self.upload_params['owner']}/{self.upload_params['repo']}"
+ )
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"Request error {e}",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ pass
+ except Exception as e:
+ log.warning(
+ f"Error {e}",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+
+ # if job not found in travis.com try travis.org
+ if not travis_dot_com:
+ log.info(
+ "Unable to verify using travis.com, trying travis.org",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ try:
+ build = requests.get(
+ "https://api.travis-ci.org/job/{}".format(
+ self.upload_params["job"]
+ ),
+ headers={"Travis-API-Version": "3", "User-Agent": "Codecov"},
+ )
+ except (ConnectionError, HTTPError) as e:
+ log.warning(
+ f"Request error {e}",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ raise NotFound(
+ errors["travis"]["tokenless-general-error"].format(
+ f"https://codecov.io/gh/{self.upload_params['owner']}/{self.upload_params['repo']}/settings"
+ )
+ )
+ if not build:
+ raise NotFound(
+ errors["travis"]["tokenless-general-error"].format(
+ f"https://codecov.io/gh/{self.upload_params['owner']}/{self.upload_params['repo']}/settings"
+ )
+ )
+
+ return build.json()
+
+ def verify(self) -> str:
+ # find repo in travis.com
+ job = self.get_build()
+
+ slug = f"{self.upload_params['owner']}/{self.upload_params['repo']}"
+
+ codecovUrl = f"https://codecov.io/gh/{self.upload_params['owner']}/{self.upload_params['repo']}/settings"
+
+ # Check repo slug and commit sha
+ # We check commit sha only for a push event since sha in arguments will not match if event type = pull request
+ if (
+ job["repository"]["slug"] != slug
+ or job["commit"]["sha"] != self.upload_params["commit"]
+ and job["build"]["event_type"] != "pull_request"
+ ):
+ log.warning(
+ f"Repository slug: {slug} or commit sha: {self.upload_params['commit']} do not match travis arguments",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ raise NotFound(
+ errors["travis"]["tokenless-general-error"].format(codecovUrl)
+ )
+
+ # Verify job finished within the last 4 minutes or is still in progress
+ if job["finished_at"] is not None:
+ finishTimestamp = job["finished_at"].replace("T", " ").replace("Z", "")
+ buildFinishDateObj = datetime.strptime(finishTimestamp, "%Y-%m-%d %H:%M:%S")
+ finishTimeWithBuffer = buildFinishDateObj + timedelta(minutes=4)
+ now = datetime.now()
+ if not now <= finishTimeWithBuffer:
+ log.warning(
+ "Cancelling upload: 4 mins since build",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ raise NotFound(errors["travis"]["tokenless-stale-build"])
+ else:
+ # check if current state is correct (i.e not finished)
+ if job["state"] != "started":
+ log.warning(
+ "Cancelling upload: job state does not indicate that build is in progress",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ raise NotFound(errors["travis"]["tokenless-bad-status"])
+
+ log.info(
+ "Finished travis tokenless upload",
+ extra=dict(
+ commit=self.upload_params["commit"],
+ repo_name=self.upload_params["repo"],
+ job=self.upload_params["job"],
+ owner=self.upload_params["owner"],
+ ),
+ )
+ return "github"
diff --git a/apps/codecov-api/upload/urls.py b/apps/codecov-api/upload/urls.py
new file mode 100644
index 0000000000..5194d09c3c
--- /dev/null
+++ b/apps/codecov-api/upload/urls.py
@@ -0,0 +1,68 @@
+from django.urls import path, re_path
+
+from upload.views.bundle_analysis import BundleAnalysisView
+from upload.views.commits import CommitViews
+from upload.views.empty_upload import EmptyUploadView
+from upload.views.legacy import UploadDownloadHandler, UploadHandler
+from upload.views.reports import ReportResultsView, ReportViews
+from upload.views.test_results import TestResultsView
+from upload.views.upload_completion import UploadCompletionView
+from upload.views.upload_coverage import UploadCoverageView
+from upload.views.uploads import UploadViews
+
+urlpatterns = [
+ path(
+ "test_results/v1",
+ TestResultsView.as_view(),
+ name="upload-test-results",
+ ),
+ path(
+ "bundle_analysis/v1",
+ BundleAnalysisView.as_view(),
+ name="upload-bundle-analysis",
+ ),
+ # use regex to make trailing slash optional
+ path(
+ "///download",
+ UploadDownloadHandler.as_view(),
+ name="upload-download",
+ ),
+ # Empty routes that will become the new upload endpoint eventually
+ path(
+ "//commits//reports//uploads",
+ UploadViews.as_view(),
+ name="new_upload.uploads",
+ ),
+ path(
+ "//commits//reports//results",
+ ReportResultsView.as_view(),
+ name="new_upload.reports_results",
+ ),
+ path(
+ "//commits//reports",
+ ReportViews.as_view(),
+ name="new_upload.reports",
+ ),
+ path(
+ "//commits//empty-upload",
+ EmptyUploadView.as_view(),
+ name="new_upload.empty_upload",
+ ),
+ path(
+ "//commits//upload-complete",
+ UploadCompletionView.as_view(),
+ name="new_upload.upload-complete",
+ ),
+ path(
+ "//commits",
+ CommitViews.as_view(),
+ name="new_upload.commits",
+ ),
+ path(
+ "//upload-coverage",
+ UploadCoverageView.as_view(),
+ name="new_upload.upload_coverage",
+ ),
+ # This was getting in the way of the new endpoints, so I moved to the end
+ re_path(r"(?P\w+)/?", UploadHandler.as_view(), name="upload-handler"),
+]
diff --git a/apps/codecov-api/upload/views/base.py b/apps/codecov-api/upload/views/base.py
new file mode 100644
index 0000000000..f250379f32
--- /dev/null
+++ b/apps/codecov-api/upload/views/base.py
@@ -0,0 +1,88 @@
+import logging
+from typing import Optional
+
+from django.conf import settings
+from rest_framework.exceptions import ValidationError
+
+from codecov_auth.models import Service
+from core.models import Commit, Repository
+from reports.models import CommitReport
+from upload.views.helpers import get_repository_from_string
+
+log = logging.getLogger(__name__)
+
+
+class ShelterMixin:
+ def is_shelter_request(self) -> bool:
+ """
+ Returns true when the incoming request originated from a Shelter.
+ Shelter adds an `X-Shelter-Token` header which contains a shared secret.
+ Use of that shared secret allows certain priviledged functionality that normal
+ uploads cannot access.
+ """
+ shelter_token = self.request.META.get("HTTP_X_SHELTER_TOKEN")
+ return shelter_token and shelter_token == settings.SHELTER_SHARED_SECRET
+
+
+class GetterMixin(ShelterMixin):
+ def get_repo(self) -> Repository:
+ service = self.kwargs.get("service")
+ repo_slug = self.kwargs.get("repo")
+ try:
+ service_enum = Service(service)
+ except ValueError:
+ log.warning(
+ f"Service not found: {service}", extra=dict(repo_slug=repo_slug)
+ )
+ raise ValidationError(f"Service not found: {service}")
+
+ repository = get_repository_from_string(service_enum, repo_slug)
+
+ if not repository:
+ log.warning(
+ "Repository not found",
+ extra=dict(repo_slug=repo_slug),
+ )
+ raise ValidationError("Repository not found")
+ return repository
+
+ def get_commit(self, repo: Repository) -> Commit:
+ commit_sha = self.kwargs.get("commit_sha")
+ try:
+ commit = Commit.objects.get(
+ commitid=commit_sha, repository__repoid=repo.repoid
+ )
+ return commit
+ except Commit.DoesNotExist:
+ log.warning(
+ "Commit SHA not found",
+ extra=dict(repo=repo.name, commit_sha=commit_sha),
+ )
+ raise ValidationError("Commit SHA not found")
+
+ def get_report(
+ self,
+ commit: Commit,
+ report_type: Optional[
+ CommitReport.ReportType
+ ] = CommitReport.ReportType.COVERAGE,
+ ) -> CommitReport:
+ report_code = self.kwargs.get("report_code")
+ if report_code == "default":
+ report_code = None
+ queryset = CommitReport.objects.filter(code=report_code, commit=commit)
+ if report_type == CommitReport.ReportType.COVERAGE:
+ queryset = queryset.coverage_reports()
+ else:
+ queryset = queryset.filter(report_type=report_type)
+ report = queryset.first()
+ if report is None:
+ log.warning(
+ "Report not found",
+ extra=dict(commit_sha=commit.commitid, report_code=report_code),
+ )
+ raise ValidationError("Report not found")
+ if report.report_type is None:
+ report.report_type = CommitReport.ReportType.COVERAGE
+ report.save()
+ return report
diff --git a/apps/codecov-api/upload/views/bundle_analysis.py b/apps/codecov-api/upload/views/bundle_analysis.py
new file mode 100644
index 0000000000..950d68d728
--- /dev/null
+++ b/apps/codecov-api/upload/views/bundle_analysis.py
@@ -0,0 +1,253 @@
+import logging
+import uuid
+from typing import Any, Callable, Tuple
+
+from django.conf import settings
+from django.http import HttpRequest
+from rest_framework import serializers, status
+from rest_framework.exceptions import NotAuthenticated, NotFound
+from rest_framework.permissions import BasePermission
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.api_archive.archive import ArchiveService
+from shared.bundle_analysis.storage import StoragePaths, get_bucket_name
+from shared.events.amplitude import UNKNOWN_USER_OWNERID, AmplitudeEventPublisher
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import Counter, inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ BundleAnalysisTokenlessAuthentication,
+ GitHubOIDCTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ UploadTokenRequiredGetFromBodyAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from codecov_auth.authentication.types import RepositoryAsUser
+from codecov_auth.models import Owner, Service
+from core.models import Commit
+from reports.models import CommitReport
+from timeseries.models import Dataset, MeasurementName
+from upload.helpers import (
+ dispatch_upload_task,
+ generate_upload_prometheus_metrics_labels,
+)
+from upload.views.base import ShelterMixin
+from upload.views.helpers import get_repository_from_string
+
+log = logging.getLogger(__name__)
+
+
+BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER = Counter(
+ "bundle_analysis_upload_views_runs",
+ "Number of times a BA upload was run and with what result",
+ [
+ "agent",
+ "version",
+ "action",
+ "endpoint",
+ "is_using_shelter",
+ "position",
+ "result",
+ ],
+)
+
+
+class UploadBundleAnalysisPermission(BasePermission):
+ def has_permission(self, request: HttpRequest, view: Any) -> bool:
+ return request.auth is not None and "upload" in request.auth.get_scopes()
+
+
+class UploadSerializer(serializers.Serializer):
+ commit = serializers.CharField(required=True)
+ slug = serializers.CharField(required=True)
+ build = serializers.CharField(required=False, allow_null=True)
+ buildURL = serializers.CharField(required=False, allow_null=True)
+ job = serializers.CharField(required=False, allow_null=True)
+ pr = serializers.CharField(required=False, allow_null=True)
+ service = serializers.CharField(required=False, allow_null=True)
+ branch = serializers.CharField(required=False, allow_null=True)
+ compareSha = serializers.CharField(required=False, allow_null=True)
+ git_service = serializers.CharField(required=False, allow_null=True)
+ storage_path = serializers.CharField(required=False, allow_null=True)
+ upload_external_id = serializers.CharField(required=False, allow_null=True)
+
+
+class BundleAnalysisView(APIView, ShelterMixin):
+ permission_classes = [UploadBundleAnalysisPermission]
+ authentication_classes = [
+ UploadTokenRequiredGetFromBodyAuthenticationCheck,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ BundleAnalysisTokenlessAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable:
+ return repo_auth_custom_exception_handler
+
+ def _handle_upload(self, request: HttpRequest) -> Tuple[str, Response]:
+ serializer = UploadSerializer(data=request.data)
+ if not serializer.is_valid():
+ return (
+ "bad_request",
+ Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST),
+ )
+ data = serializer.validated_data
+
+ if isinstance(request.user, Owner):
+ # using org token
+ owner = request.user
+ repo = get_repository_from_string(Service(owner.service), data["slug"])
+ elif isinstance(request.user, RepositoryAsUser):
+ # repository token
+ repo = request.user._repository
+ else:
+ raise NotAuthenticated()
+
+ if repo is None:
+ raise NotFound("Repository not found.")
+
+ update_fields = []
+ if not repo.active or not repo.activated:
+ repo.active = True
+ repo.activated = True
+ update_fields += ["active", "activated"]
+
+ if not repo.bundle_analysis_enabled:
+ repo.bundle_analysis_enabled = True
+ update_fields += ["bundle_analysis_enabled"]
+
+ if update_fields:
+ repo.save(update_fields=update_fields)
+
+ commit, _ = Commit.objects.get_or_create(
+ commitid=data["commit"],
+ repository=repo,
+ defaults={
+ "branch": data.get("branch"),
+ "pullid": data.get("pr"),
+ "merged": False if data.get("pr") is not None else None,
+ "state": "pending",
+ },
+ )
+
+ AmplitudeEventPublisher().publish(
+ "Upload Received",
+ {
+ "user_ownerid": commit.author.ownerid
+ if commit.author
+ else UNKNOWN_USER_OWNERID,
+ "ownerid": repo.author.ownerid,
+ "repoid": repo.repoid,
+ "commitid": commit.id, # Not commit.commitid, we do not want a commit SHA here!
+ "pullid": commit.pullid,
+ "upload_type": "Bundle",
+ },
+ )
+
+ storage_path = data.get("storage_path", None)
+ upload_external_id = data.get("upload_external_id", None)
+ url = None
+ if not self.is_shelter_request():
+ upload_external_id = str(uuid.uuid4())
+ storage_path = StoragePaths.upload.path(upload_key=upload_external_id)
+ archive_service = ArchiveService(repo)
+ url = archive_service.storage.create_presigned_put(
+ get_bucket_name(), storage_path, 30
+ )
+
+ task_arguments = {
+ # these are used in the upload task when saving an upload record
+ # and use some unfortunately named and confusing keys
+ # (eventual reports_upload columns indicated by comments)
+ "reportid": upload_external_id, # external_id
+ "build": data.get("build"), # build_code
+ "build_url": data.get("buildURL"), # build_url
+ "job": data.get("job"), # job_code
+ "service": data.get("service"), # provider
+ "url": storage_path, # storage_path
+ # these are used for dispatching the task below
+ "commit": commit.commitid,
+ "report_code": None,
+ # custom comparison sha for the current uploaded commit sha
+ "bundle_analysis_compare_sha": data.get("compareSha"),
+ }
+
+ log.info(
+ "Dispatching bundle analysis upload to worker",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=repo.repoid,
+ task_arguments=task_arguments,
+ ),
+ )
+
+ dispatch_upload_task(
+ task_arguments,
+ repo,
+ get_redis_connection(),
+ report_type=CommitReport.ReportType.BUNDLE_ANALYSIS,
+ )
+
+ if settings.TIMESERIES_ENABLED:
+ supported_bundle_analysis_measurement_types = [
+ MeasurementName.BUNDLE_ANALYSIS_ASSET_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_FONT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_IMAGE_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_JAVASCRIPT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_REPORT_SIZE,
+ MeasurementName.BUNDLE_ANALYSIS_STYLESHEET_SIZE,
+ ]
+ for measurement_type in supported_bundle_analysis_measurement_types:
+ _, created = Dataset.objects.get_or_create(
+ name=measurement_type.value,
+ repository_id=repo.pk,
+ )
+ if created:
+ log.info(
+ "Created new timescale dataset for bundle analysis",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=repo.repoid,
+ measurement_type=measurement_type,
+ ),
+ )
+
+ return ("success", Response({"url": url}, status=201))
+
+ def post(self, request: HttpRequest) -> Response:
+ labels = generate_upload_prometheus_metrics_labels(
+ action="bundle_analysis",
+ endpoint="bundle_analysis",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ include_empty_labels=False,
+ )
+ labels["result"] = "pending"
+ inc_counter(
+ BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER,
+ labels=labels,
+ )
+
+ try:
+ upload_result, response = self._handle_upload(request)
+ return response
+ except Exception as e:
+ log.error(
+ "Error handling bundle analysis upload",
+ extra=dict(
+ error=e,
+ ),
+ exc_info=True,
+ )
+ upload_result = "error"
+ raise
+ finally:
+ labels["position"] = "end"
+ labels["result"] = upload_result
+ inc_counter(
+ BUNDLE_ANALYSIS_UPLOAD_VIEWS_COUNTER,
+ labels=labels,
+ )
diff --git a/apps/codecov-api/upload/views/commits.py b/apps/codecov-api/upload/views/commits.py
new file mode 100644
index 0000000000..2ed1aa9a5c
--- /dev/null
+++ b/apps/codecov-api/upload/views/commits.py
@@ -0,0 +1,103 @@
+import logging
+from typing import Any, Callable, Dict
+
+from django.db.models import QuerySet
+from django.http import HttpRequest
+from rest_framework import serializers
+from rest_framework.exceptions import NotAuthenticated
+from rest_framework.generics import ListCreateAPIView
+from rest_framework.response import Response
+from shared.metrics import inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from core.models import Commit, Repository
+from upload.helpers import (
+ generate_upload_prometheus_metrics_labels,
+ validate_activated_repo,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.serializers import CommitSerializer
+from upload.views.base import GetterMixin
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+log = logging.getLogger(__name__)
+
+
+def create_commit(
+ serializer: serializers.ModelSerializer, repository: Repository
+) -> Commit:
+ validate_activated_repo(repository)
+ commit = serializer.save(repository=repository)
+ return commit
+
+
+class CommitViews(ListCreateAPIView, GetterMixin):
+ serializer_class = CommitSerializer
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable[[Exception, Dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def get_queryset(self) -> QuerySet:
+ repository = self.get_repo()
+ return Commit.objects.filter(repository=repository)
+
+ def list(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ repository = self.get_repo()
+ if repository.private and isinstance(
+ self.request.auth, TokenlessAuthentication
+ ):
+ raise NotAuthenticated()
+ return super().list(request, *args, **kwargs)
+
+ def create(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ return super().create(request, *args, **kwargs)
+
+ def perform_create(self, serializer: CommitSerializer) -> Commit:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_commit",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ repository = self.get_repo()
+ commit = create_commit(serializer, repository)
+
+ log.info(
+ "Request to create new commit",
+ extra=dict(repo=repository.name, commit=commit.commitid),
+ )
+
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_commit",
+ request=self.request,
+ repository=repository,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+
+ return commit
diff --git a/apps/codecov-api/upload/views/empty_upload.py b/apps/codecov-api/upload/views/empty_upload.py
new file mode 100644
index 0000000000..d8bb9ea1f6
--- /dev/null
+++ b/apps/codecov-api/upload/views/empty_upload.py
@@ -0,0 +1,229 @@
+import fnmatch
+import logging
+from typing import Any, Callable, List, Optional
+
+import regex
+from asgiref.sync import async_to_sync
+from django.http import HttpRequest
+from rest_framework import serializers, status
+from rest_framework.exceptions import NotFound
+from rest_framework.generics import CreateAPIView
+from rest_framework.response import Response
+from shared.metrics import inc_counter
+from shared.torngit.base import TorngitBaseAdapter
+from shared.torngit.exceptions import TorngitClientError, TorngitClientGeneralError
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from codecov_auth.authentication.types import RepositoryAsUser
+from core.models import Commit
+from services.repo_providers import RepoProviderService
+from services.task import TaskService
+from services.yaml import final_commit_yaml
+from upload.helpers import (
+ generate_upload_prometheus_metrics_labels,
+ try_to_get_best_possible_bot_token,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.views.base import GetterMixin
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+log = logging.getLogger(__name__)
+
+GLOB_NON_TESTABLE_FILES = [
+ "*.cfg",
+ "*.conf",
+ "*.css",
+ "*.csv",
+ "*.db",
+ "*.doc",
+ "*.egg",
+ "*.env",
+ "*.git",
+ "*.html",
+ "*.htmlypertext",
+ "*.ini",
+ "*.jar*",
+ "*.jpeg",
+ "*.jpg",
+ "*.jsonipt",
+ "*.mak*",
+ "*.md",
+ "*.pdf",
+ "*.png",
+ "*.ppt",
+ "*.svg",
+ "*.tar.tz",
+ "*.template",
+ "*.txt",
+ "*.whl",
+ "*.xls",
+ "*.xml",
+ "*.yaml",
+ "*.yml",
+]
+
+
+class EmptyUploadSerializer(serializers.Serializer):
+ should_force = serializers.BooleanField(required=False)
+
+
+class EmptyUploadView(CreateAPIView, GetterMixin):
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable[[Exception, dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="empty_upload",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ serializer = EmptyUploadSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ data = serializer.validated_data
+ should_force = data.get("should_force", False)
+
+ repo = self.get_repo()
+ commit = self.get_commit(repo)
+
+ if should_force is True:
+ TaskService().notify(
+ repoid=repo.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+ return Response(
+ data={
+ "result": "Force option was enabled. Triggering passing notifications.",
+ "non_ignored_files": [],
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ # Depending on the authentication class used, the request.user may need to be converted to a Owner
+ owner = request.user
+ if isinstance(request.user, RepositoryAsUser):
+ owner = request.user._repository.author
+
+ yaml = final_commit_yaml(commit, owner).to_dict()
+ token = try_to_get_best_possible_bot_token(repo)
+ provider = RepoProviderService().get_adapter(repo.author, repo, token=token)
+ pull_id = commit.pullid
+ if pull_id is None:
+ pull_id = self.get_pull_request_id(commit, provider, pull_id)
+
+ changed_files: List[str] = self.get_changed_files_from_provider(
+ commit, provider, pull_id
+ )
+
+ ignored_files = yaml.get("ignore", [])
+
+ regex_non_testable_files = [
+ fnmatch.translate(path) for path in GLOB_NON_TESTABLE_FILES
+ ]
+
+ compiled_files_to_ignore = [
+ regex.compile(path) for path in (regex_non_testable_files + ignored_files)
+ ]
+
+ ignored_changed_files = [
+ file
+ for file in changed_files
+ if any(
+ (
+ regex.match(regex_patt, file, timeout=2)
+ for regex_patt in compiled_files_to_ignore
+ )
+ )
+ ]
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="empty_upload",
+ request=self.request,
+ repository=repo,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+ if set(changed_files) == set(ignored_changed_files):
+ TaskService().notify(
+ repoid=repo.repoid, commitid=commit.commitid, empty_upload="pass"
+ )
+ return Response(
+ data={
+ "result": "All changed files are ignored. Triggering passing notifications.",
+ "non_ignored_files": [],
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ non_ignored_files = set(changed_files) - set(ignored_changed_files)
+ TaskService().notify(
+ repoid=repo.repoid, commitid=commit.commitid, empty_upload="fail"
+ )
+
+ return Response(
+ data={
+ "result": "Some files cannot be ignored. Triggering failing notifications.",
+ "non_ignored_files": non_ignored_files,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ def get_changed_files_from_provider(
+ self, commit: Commit, provider: TorngitBaseAdapter, pull_id: int
+ ) -> List[str]:
+ try:
+ changed_files = async_to_sync(provider.get_pull_request_files)(pull_id)
+ except TorngitClientError:
+ log.warning(
+ "Request client error",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repository.repoid,
+ ),
+ exc_info=True,
+ )
+ raise NotFound("Unable to get pull request's files.")
+ return changed_files
+
+ def get_pull_request_id(
+ self, commit: Commit, provider: TorngitBaseAdapter, pull_id: Optional[int]
+ ) -> int:
+ try:
+ if pull_id is None:
+ pull_id = async_to_sync(provider.find_pull_request)(
+ commit=commit.commitid
+ )
+ except TorngitClientGeneralError:
+ log.warning(
+ "Request client error",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repository.repoid,
+ ),
+ exc_info=True,
+ )
+ raise NotFound(f"Unable to get pull request for commit: {commit.commitid}")
+ return pull_id
diff --git a/apps/codecov-api/upload/views/helpers.py b/apps/codecov-api/upload/views/helpers.py
new file mode 100644
index 0000000000..cd61677680
--- /dev/null
+++ b/apps/codecov-api/upload/views/helpers.py
@@ -0,0 +1,47 @@
+import typing
+
+from codecov_auth.models import Owner, Service
+from core.models import Repository
+
+
+def get_repository_and_owner_from_string(
+ service: Service, repo_identifier: str
+) -> tuple[Repository | None, Owner | None]:
+ if not isinstance(service, Service):
+ # if we pass this value to the db, it just raises DataError
+ # No need for that
+ return None, None
+
+ if "::::" not in repo_identifier:
+ return None, None
+
+ owner_identifier, repo_name_identifier = repo_identifier.rsplit("::::", 1)
+ owner = _get_owner_from_string(service, owner_identifier)
+ if not owner:
+ return None, None
+ try:
+ repository = Repository.objects.get(author=owner, name=repo_name_identifier)
+ except Repository.DoesNotExist:
+ return None, None
+
+ return repository, owner
+
+
+def _get_owner_from_string(
+ service: Service, owner_identifier: str
+) -> typing.Optional[Owner]:
+ if ":::" in owner_identifier:
+ owner_identifier = owner_identifier.replace(":::", ":")
+ try:
+ return Owner.objects.get(service=service, username=owner_identifier)
+ except Owner.DoesNotExist:
+ return None
+
+
+def get_repository_from_string(
+ service: Service, repo_identifier: str
+) -> Repository | None:
+ repository, _ = get_repository_and_owner_from_string(
+ service=service, repo_identifier=repo_identifier
+ )
+ return repository
diff --git a/apps/codecov-api/upload/views/legacy.py b/apps/codecov-api/upload/views/legacy.py
new file mode 100644
index 0000000000..42287d57f3
--- /dev/null
+++ b/apps/codecov-api/upload/views/legacy.py
@@ -0,0 +1,450 @@
+import asyncio
+import logging
+import re
+from json import dumps
+from uuid import uuid4
+
+import minio
+from asgiref.sync import sync_to_async
+from django.conf import settings
+from django.core.exceptions import MultipleObjectsReturned
+from django.http import Http404, HttpResponse, HttpResponseServerError
+from django.utils import timezone
+from django.utils.decorators import classonlymethod
+from django.utils.encoding import smart_str
+from django.views import View
+from rest_framework import renderers, status
+from rest_framework.exceptions import ValidationError
+from rest_framework.permissions import AllowAny
+from rest_framework.views import APIView
+from shared.api_archive.archive import ArchiveService
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import inc_counter
+
+from codecov_auth.commands.owner import OwnerCommands
+from core.commands.repository import RepositoryCommands
+from services.analytics import AnalyticsService
+from upload.helpers import (
+ check_commit_upload_constraints,
+ determine_repo_for_upload,
+ determine_upload_branch_to_use,
+ determine_upload_commit_to_use,
+ determine_upload_pr_to_use,
+ dispatch_upload_task,
+ generate_upload_prometheus_metrics_labels,
+ insert_commit,
+ parse_headers,
+ parse_params,
+ validate_upload,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.views.base import ShelterMixin
+from utils.config import get_config
+from utils.services import get_long_service_name
+
+log = logging.getLogger(__name__)
+
+
+class PlainTextRenderer(renderers.BaseRenderer):
+ media_type = "text/plain"
+ format = "txt"
+
+ def render(self, data, media_type=None, renderer_context=None):
+ return smart_str(data, encoding=self.charset)
+
+
+class UploadHandler(APIView, ShelterMixin):
+ permission_classes = [AllowAny]
+ renderer_classes = [PlainTextRenderer, renderers.JSONRenderer]
+
+ def get(self, request, *args, **kwargs):
+ return HttpResponse(status=status.HTTP_405_METHOD_NOT_ALLOWED)
+
+ def options(self, request, *args, **kwargs):
+ response = HttpResponse()
+ response["Accept"] = "text/*"
+ response["Access-Control-Allow-Origin"] = "*"
+ response["Access-Control-Allow-Method"] = "POST"
+ response["Access-Control-Allow-Headers"] = (
+ "Origin, Content-Type, Accept, X-User-Agent"
+ )
+
+ return response
+
+ def post(self, request, *args, **kwargs):
+ # Extract the version
+ version = self.kwargs["version"]
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="legacy_upload",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ upload_version=version,
+ ),
+ )
+
+ log.info(
+ f"Received upload request {version}",
+ extra=dict(
+ version=version,
+ query_params=self.request.query_params,
+ commit=self.request.query_params.get("commit"),
+ ),
+ )
+
+ # Set response headers
+ response = HttpResponse()
+ response["Access-Control-Allow-Origin"] = "*"
+ response["Access-Control-Allow-Headers"] = (
+ "Origin, Content-Type, Accept, X-User-Agent"
+ )
+
+ # Parse request parameters
+ request_params = {
+ **self.request.query_params.dict(), # query_params is a QueryDict, need to convert to dict to process it properly
+ **self.kwargs,
+ }
+ request_params["token"] = request_params.get("token") or request.META.get(
+ "HTTP_X_UPLOAD_TOKEN"
+ )
+
+ package = request_params.get("package")
+ if package is not None:
+ package_format = r"((codecov-cli/)|((.+-)?uploader-))(\d+.\d+.\d+)"
+ match = re.fullmatch(package_format, package)
+ if not match:
+ log.warning(
+ "Package query parameter failed to match CLI or uploader format",
+ extra=dict(package=package),
+ )
+ try:
+ # note: try to avoid mutating upload_params past this point, to make it easier to reason about the state of this variable
+ upload_params = parse_params(request_params)
+ except ValidationError as e:
+ log.warning(
+ "Failed to parse upload request params",
+ extra=dict(request_params=request_params, errors=str(e)),
+ )
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ response.content = "Invalid request parameters"
+ return response
+
+ # Try to determine the repository associated with the upload based on the params provided
+ try:
+ repository = determine_repo_for_upload(upload_params)
+ owner = repository.author
+ except ValidationError:
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ response.content = "Could not determine repo and owner"
+ return response
+ except MultipleObjectsReturned:
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ response.content = "Found too many repos"
+ return response
+
+ log.info(
+ "Found repository for upload request",
+ extra=dict(
+ version=version,
+ upload_params=upload_params,
+ repo_name=repository.name,
+ owner_username=owner.username,
+ commit=upload_params.get("commit"),
+ ),
+ )
+
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="legacy_upload",
+ request=self.request,
+ repository=repository,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ upload_version=version,
+ ),
+ )
+
+ # Validate the upload to make sure the org has enough repo credits and is allowed to upload for this commit
+ redis = get_redis_connection()
+ validate_upload(upload_params, repository, redis)
+ log.info(
+ "Upload was determined to be valid", extra=dict(repoid=repository.repoid)
+ )
+ # Do some processing to handle special cases for branch, pr, and commit values, and determine which values to use
+ # note that these values may be different from the values provided in the upload_params
+ branch = determine_upload_branch_to_use(upload_params, repository.branch)
+ pr = determine_upload_pr_to_use(upload_params)
+ commitid = determine_upload_commit_to_use(upload_params, repository)
+
+ # Save (or update, if it exists already) the commit in the database
+ log.info(
+ "Saving commit in database",
+ extra=dict(
+ commit=commitid,
+ pr=pr,
+ branch=branch,
+ version=version,
+ upload_params=upload_params,
+ ),
+ )
+ commit = insert_commit(
+ commitid, branch, pr, repository, owner, upload_params.get("parent")
+ )
+ check_commit_upload_constraints(commit)
+
+ # --------- Handle the actual upload
+
+ reportid = str(uuid4())
+ # populated later for `v4` uploads when generating presigned PUT url,
+ # or by `v2` uploads when storing the report directly
+ path = None
+
+ # Get the url where the commit details can be found on the Codecov site, we'll return this in the response
+ destination_url = f"{settings.CODECOV_DASHBOARD_URL}/{owner.service}/{owner.username}/{repository.name}/commit/{commitid}"
+
+ archive_service = ArchiveService(repository)
+ datetime = timezone.now().strftime("%Y-%m-%d")
+ repo_hash = archive_service.get_archive_hash(repository)
+ default_path = f"v4/raw/{datetime}/{repo_hash}/{commitid}/{reportid}.txt"
+
+ # v2 - store request body directly
+ if version == "v2":
+ log.info(
+ "Started V2 upload",
+ extra=dict(
+ commit=commitid,
+ pr=pr,
+ branch=branch,
+ version=version,
+ upload_params=upload_params,
+ ),
+ )
+
+ path = default_path
+ encoding = request.META.get("HTTP_X_CONTENT_ENCODING") or request.META.get(
+ "HTTP_CONTENT_ENCODING"
+ )
+ archive_service.write_file(
+ path, request.body, is_already_gzipped=(encoding == "gzip")
+ )
+
+ log.info(
+ "Stored coverage report",
+ extra=dict(
+ commit=commitid,
+ upload_params=upload_params,
+ reportid=reportid,
+ path=path,
+ repoid=repository.repoid,
+ ),
+ )
+
+ response.write(
+ dumps(
+ dict(
+ message="Coverage reports upload successfully",
+ uploaded=True,
+ queued=True,
+ id=reportid,
+ url=destination_url,
+ )
+ )
+ )
+
+ # v4 - generate presigned PUT url
+ minio = get_config("services", "minio") or {}
+ if minio and version == "v4":
+ log.info(
+ "Started V4 upload",
+ extra=dict(
+ commit=commitid,
+ pr=pr,
+ branch=branch,
+ version=version,
+ upload_params=upload_params,
+ ),
+ )
+
+ parse_headers(request.META, upload_params)
+
+ # only Shelter requests are allowed to set their own `storage_path`
+ path = upload_params.get("storage_path")
+ if path is None or not self.is_shelter_request():
+ path = default_path
+
+ try:
+ # When using shelter (`is_shelter_request`), the returned `upload_url` is being
+ # ignored, as shelter is handling the creation of a "presigned put" matching the
+ # `storage_path`.
+ # This code runs here just for backwards compatibility reasons:
+ upload_url = archive_service.create_presigned_put(default_path)
+ except Exception as e:
+ log.warning(
+ f"Error generating minio presign put {e}",
+ extra=dict(
+ commit=commitid,
+ pr=pr,
+ branch=branch,
+ version=version,
+ upload_params=upload_params,
+ ),
+ )
+ return HttpResponseServerError("Unknown error, please try again later")
+ log.info(
+ "Returning presign put",
+ extra=dict(
+ commit=commitid, repoid=repository.repoid, upload_url=upload_url
+ ),
+ )
+ response["Content-Type"] = "text/plain"
+ response.write(f"{destination_url}\n{upload_url}")
+
+ # Get build url
+ if (
+ repository.service == "gitlab_enterprise"
+ and not upload_params.get("build_url")
+ and upload_params.get("build")
+ ):
+ # if gitlab ci - change domain based by referer
+ build_url = f"{get_config((repository.service, 'url'))}/{owner.username}/{repository.name}/{upload_params.get('build')}"
+ else:
+ build_url = upload_params.get("build_url")
+ queue_params = upload_params.copy()
+ if upload_params.get("using_global_token"):
+ queue_params["service"] = request_params.get("service")
+ # Define the task arguments to send when dispatching upload task to worker
+ task_arguments = {
+ **queue_params,
+ "build_url": build_url,
+ "reportid": reportid,
+ "url": (
+ path
+ if path # If a path was generated for an upload, pass that to the 'url' field, potentially overwriting it
+ else upload_params.get("url")
+ ),
+ # These values below might be different from the initial request parameters, so overwrite them here to ensure they're up-to-date
+ "commit": commitid,
+ "branch": branch,
+ "pr": pr,
+ }
+
+ log.info(
+ "Dispatching upload to worker (new upload)",
+ extra=dict(
+ commit=commitid, task_arguments=task_arguments, repoid=repository.repoid
+ ),
+ )
+
+ # Send task to worker
+ dispatch_upload_task(task_arguments, repository, redis)
+
+ # Analytics Tracking
+ analytics_upload_data = upload_params.copy()
+ analytics_upload_data["repository_id"] = repository.repoid
+ analytics_upload_data["repository_name"] = repository.name
+ analytics_upload_data["version"] = version
+ analytics_upload_data["userid_type"] = "org"
+ analytics_upload_data["uploader_type"] = "node uploader"
+ AnalyticsService().account_uploaded_coverage_report(
+ owner.ownerid, analytics_upload_data
+ )
+
+ if version == "v4":
+ response["Content-Type"] = "text/plain"
+ request.META["HTTP_ACCEPT"] = "text/plain"
+ if version == "v2":
+ response["Content-Type"] = "application/json"
+
+ response.status_code = status.HTTP_200_OK
+ return response
+
+
+class UploadDownloadHandler(View):
+ @classonlymethod
+ def as_view(_, **initkwargs):
+ view = super().as_view(**initkwargs)
+ view._is_coroutine = asyncio.coroutines._is_coroutine
+ return view
+
+ async def get_repo(self):
+ owner = await OwnerCommands(
+ self.request.current_owner, self.service
+ ).fetch_owner(self.owner_username)
+ if owner is None:
+ raise Http404("Requested report could not be found")
+ repo = await RepositoryCommands(
+ self.request.current_owner,
+ self.service,
+ ).fetch_repository(
+ owner, self.repo_name, [], exclude_okta_enforced_repos=False
+ ) # Okta sign-in is only enforced on the UI for now.
+
+ if repo is None:
+ raise Http404("Requested report could not be found")
+ return repo
+
+ @sync_to_async
+ def validate_path(self, repo):
+ msg = "Requested report could not be found"
+ if not self.path:
+ raise Http404(msg)
+
+ if self.path.startswith("v4/raw"):
+ # direct API upload
+
+ # Verify that the repo hash in the path matches the repo in the URL by generating the repo hash
+ archive_service = ArchiveService(repo)
+ if archive_service.storage_hash not in self.path:
+ raise Http404(msg)
+ elif self.path.startswith("shelter/"):
+ # Shelter upload
+ if not self.path.startswith(
+ f"shelter/{self.service}/{self.owner_username}::::{self.repo_name}"
+ ):
+ raise Http404(msg)
+ else:
+ # unexpected path structure
+ raise Http404(msg)
+
+ def read_params(self):
+ self.path = self.request.GET.get("path")
+ self.service = get_long_service_name(self.kwargs.get("service"))
+ self.repo_name = self.kwargs.get("repo_name")
+ self.owner_username = self.kwargs.get("owner_username")
+
+ @sync_to_async
+ def get_presigned_url(self, repo):
+ archive_service = ArchiveService(repo)
+
+ try:
+ return archive_service.storage.create_presigned_get(
+ archive_service.root, self.path, expires=30
+ )
+ except minio.error.S3Error as e:
+ if e.code == "NoSuchKey":
+ raise Http404("Requested report could not be found")
+ else:
+ raise
+
+ async def get(self, request, *args, **kwargs):
+ await self._get_user(request)
+
+ self.read_params()
+ repo = await self.get_repo()
+ await self.validate_path(repo)
+
+ response = HttpResponse(status=302)
+ response["Location"] = await self.get_presigned_url(repo)
+ return response
+
+ @sync_to_async
+ def _get_user(self, request):
+ # force eager evaluation of `request.user` (a lazy object)
+ # while we're in a sync context
+ if request.user:
+ request.user.pk
diff --git a/apps/codecov-api/upload/views/reports.py b/apps/codecov-api/upload/views/reports.py
new file mode 100644
index 0000000000..a6140d5a91
--- /dev/null
+++ b/apps/codecov-api/upload/views/reports.py
@@ -0,0 +1,179 @@
+import logging
+from typing import Any, Callable
+
+from django.http import HttpRequest, HttpResponseNotAllowed
+from rest_framework.exceptions import ValidationError
+from rest_framework.generics import CreateAPIView, ListCreateAPIView, RetrieveAPIView
+from rest_framework.response import Response
+from shared.metrics import inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from core.models import Commit, Repository
+from reports.models import CommitReport, ReportResults
+from services.task import TaskService
+from upload.helpers import (
+ generate_upload_prometheus_metrics_labels,
+ validate_activated_repo,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.serializers import CommitReportSerializer, ReportResultsSerializer
+from upload.views.base import GetterMixin
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+log = logging.getLogger(__name__)
+
+
+def create_report(
+ serializer: CommitReportSerializer, repository: Repository, commit: Commit
+) -> CommitReport:
+ code = serializer.validated_data.get("code")
+ if code == "default":
+ serializer.validated_data["code"] = None
+ instance, was_created = serializer.save(
+ commit_id=commit.id,
+ report_type=CommitReport.ReportType.COVERAGE,
+ )
+ if was_created:
+ TaskService().preprocess_upload(
+ repository.repoid, commit.commitid, instance.code
+ )
+ return instance
+
+
+class ReportViews(ListCreateAPIView, GetterMixin):
+ serializer_class = CommitReportSerializer
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable[[Exception, dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def perform_create(self, serializer: CommitReportSerializer) -> CommitReport:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_report",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ repository = self.get_repo()
+ validate_activated_repo(repository)
+ commit = self.get_commit(repository)
+ log.info(
+ "Request to create new report",
+ extra=dict(repo=repository.name, commit=commit.commitid),
+ )
+ instance = create_report(serializer, repository, commit)
+
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_report",
+ request=self.request,
+ repository=repository,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+ return instance
+
+ def list(
+ self, request: HttpRequest, service: str, repo: str, commit_sha: str
+ ) -> HttpResponseNotAllowed:
+ return HttpResponseNotAllowed(permitted_methods=["POST"])
+
+
+class ReportResultsView(
+ CreateAPIView,
+ RetrieveAPIView,
+ GetterMixin,
+):
+ serializer_class = ReportResultsSerializer
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable[[Exception, dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def perform_create(self, serializer: ReportResultsSerializer) -> ReportResults:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_report_results",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ repository = self.get_repo()
+ commit = self.get_commit(repository)
+ report = self.get_report(commit)
+ instance = ReportResults.objects.filter(report=report).first()
+ if not instance:
+ instance = serializer.save(
+ report=report, state=ReportResults.ReportResultsStates.PENDING
+ )
+ else:
+ instance.state = ReportResults.ReportResultsStates.PENDING
+ instance.save()
+ TaskService().create_report_results(
+ commitid=commit.commitid,
+ repoid=repository.repoid,
+ report_code=report.code,
+ )
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_report_results",
+ request=self.request,
+ repository=repository,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+ return instance
+
+ def get_object(self) -> ReportResults:
+ repository = self.get_repo()
+ commit = self.get_commit(repository)
+ report = self.get_report(commit)
+ try:
+ report_results = ReportResults.objects.get(report=report)
+ except ReportResults.DoesNotExist:
+ log.info(
+ "Report Results not found",
+ extra=dict(
+ commit_sha=commit.commitid,
+ report_code=self.kwargs.get("report_code"),
+ ),
+ )
+ raise ValidationError("Report Results not found")
+ return report_results
diff --git a/apps/codecov-api/upload/views/test_results.py b/apps/codecov-api/upload/views/test_results.py
new file mode 100644
index 0000000000..7e975d0436
--- /dev/null
+++ b/apps/codecov-api/upload/views/test_results.py
@@ -0,0 +1,208 @@
+import logging
+import uuid
+
+from django.utils import timezone
+from rest_framework import serializers, status
+from rest_framework.exceptions import NotAuthenticated, NotFound
+from rest_framework.permissions import BasePermission
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.api_archive.archive import ArchiveService, MinioEndpoints
+from shared.events.amplitude import UNKNOWN_USER_OWNERID, AmplitudeEventPublisher
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TestAnalyticsTokenlessAuthentication,
+ UploadTokenRequiredGetFromBodyAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from codecov_auth.authentication.types import RepositoryAsUser
+from codecov_auth.models import Owner, Service
+from core.models import Commit
+from reports.models import CommitReport
+from upload.helpers import (
+ dispatch_upload_task,
+ generate_upload_prometheus_metrics_labels,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.serializers import FlagListField
+from upload.views.base import ShelterMixin
+from upload.views.helpers import get_repository_from_string
+
+log = logging.getLogger(__name__)
+
+
+class UploadTestResultsPermission(BasePermission):
+ def has_permission(self, request, view):
+ return request.auth is not None and "upload" in request.auth.get_scopes()
+
+
+class UploadSerializer(serializers.Serializer):
+ commit = serializers.CharField(required=True)
+ slug = serializers.CharField(required=True)
+ service = serializers.CharField(required=False) # git_service
+ build = serializers.CharField(required=False)
+ buildURL = serializers.CharField(required=False)
+ job = serializers.CharField(required=False)
+ flags = FlagListField(required=False)
+ pr = serializers.CharField(required=False)
+ branch = serializers.CharField(required=False, allow_null=True)
+ storage_path = serializers.CharField(required=False)
+ file_not_found = serializers.BooleanField(required=False)
+
+
+class TestResultsView(
+ APIView,
+ ShelterMixin,
+):
+ permission_classes = [UploadTestResultsPermission]
+ authentication_classes = [
+ UploadTokenRequiredGetFromBodyAuthenticationCheck,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TestAnalyticsTokenlessAuthentication,
+ ]
+
+ def get_exception_handler(self):
+ return repo_auth_custom_exception_handler
+
+ def post(self, request):
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="test_results",
+ endpoint="test_results",
+ request=request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ serializer = UploadSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ data = serializer.validated_data
+
+ if isinstance(request.user, Owner):
+ # using org token
+ owner = request.user
+ repo = get_repository_from_string(Service(owner.service), data["slug"])
+ elif isinstance(request.user, RepositoryAsUser):
+ # repository token
+ repo = request.user._repository
+ else:
+ raise NotAuthenticated()
+
+ if repo is None:
+ raise NotFound("Repository not found.")
+
+ update_fields = []
+ if not repo.active or not repo.activated:
+ repo.active = True
+ repo.activated = True
+ update_fields += ["active", "activated"]
+
+ if not repo.test_analytics_enabled:
+ repo.test_analytics_enabled = True
+ update_fields += ["test_analytics_enabled"]
+
+ if update_fields:
+ repo.save(update_fields=update_fields)
+
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="test_results",
+ endpoint="test_results",
+ request=request,
+ repository=repo,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+
+ commit, _ = Commit.objects.get_or_create(
+ commitid=data["commit"],
+ repository=repo,
+ defaults={
+ "branch": data.get("branch") or repo.branch,
+ "pullid": data.get("pr"),
+ "merged": False if data.get("pr") is not None else None,
+ "state": "pending",
+ },
+ )
+
+ AmplitudeEventPublisher().publish(
+ "Upload Received",
+ {
+ "user_ownerid": commit.author.ownerid
+ if commit.author
+ else UNKNOWN_USER_OWNERID,
+ "ownerid": repo.author.ownerid,
+ "repoid": repo.repoid,
+ "commitid": commit.id, # Not commit.commitid, we do not want a commit SHA here!
+ "pullid": commit.pullid,
+ "upload_type": "Test results",
+ },
+ )
+
+ upload_external_id = str(uuid.uuid4())
+
+ url = None
+ file_not_found = data.get("file_not_found", False)
+ if file_not_found:
+ storage_path = None
+ else:
+ archive_service = ArchiveService(repo)
+
+ storage_path = data.get("storage_path", None)
+ if storage_path is None or not self.is_shelter_request():
+ storage_path = MinioEndpoints.test_results.get_path(
+ date=timezone.now().strftime("%Y-%m-%d"),
+ repo_hash=archive_service.get_archive_hash(repo),
+ commit_sha=data["commit"],
+ uploadid=upload_external_id,
+ )
+
+ url = archive_service.create_presigned_put(storage_path)
+
+ task_arguments = {
+ # these are used in the upload task when saving an upload record
+ # and use some unfortunately named and confusing keys
+ # (eventual reports_upload columns indicated by comments)
+ "reportid": upload_external_id, # external_id
+ "build": data.get("build"), # build_code
+ "build_url": data.get("buildURL"), # build_url
+ "job": data.get("job"), # job_code
+ "flags": data.get("flags"),
+ "service": data.get("service"), # git provider
+ "url": storage_path, # storage_path
+ # these are used for dispatching the task below
+ "commit": commit.commitid,
+ "report_code": None,
+ }
+
+ log.info(
+ "Dispatching test results upload to worker",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=repo.repoid,
+ task_arguments=task_arguments,
+ ),
+ )
+
+ dispatch_upload_task(
+ task_arguments,
+ repo,
+ get_redis_connection(),
+ report_type=CommitReport.ReportType.TEST_RESULTS,
+ )
+
+ if url is None:
+ return Response(status=201)
+ else:
+ return Response({"raw_upload_location": url}, status=201)
diff --git a/apps/codecov-api/upload/views/upload_completion.py b/apps/codecov-api/upload/views/upload_completion.py
new file mode 100644
index 0000000000..f9b82ace1c
--- /dev/null
+++ b/apps/codecov-api/upload/views/upload_completion.py
@@ -0,0 +1,107 @@
+import logging
+from typing import Any, Callable, Dict
+
+from django.http import HttpRequest
+from rest_framework import status
+from rest_framework.generics import CreateAPIView
+from rest_framework.response import Response
+from shared.metrics import inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from reports.models import ReportSession
+from services.task import TaskService
+from upload.helpers import generate_upload_prometheus_metrics_labels
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.views.base import GetterMixin
+from upload.views.uploads import CanDoCoverageUploadsPermission
+
+log = logging.getLogger(__name__)
+
+
+class UploadCompletionView(CreateAPIView, GetterMixin):
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ ]
+
+ def get_exception_handler(self) -> Callable[[Exception, Dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="upload_complete",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ repo = self.get_repo()
+ commit = self.get_commit(repo)
+ uploads_queryset = ReportSession.objects.filter(
+ report__commit=commit,
+ report__code=None,
+ )
+ uploads_count = uploads_queryset.count()
+ if not uploads_queryset or uploads_count == 0:
+ log.info(
+ "Cannot trigger notifications as we didn't find any uploads for the provided commit",
+ extra=dict(
+ repo=repo.name, commit=commit.commitid, pullid=commit.pullid
+ ),
+ )
+ return Response(
+ data={
+ "uploads_total": 0,
+ "uploads_success": 0,
+ "uploads_processing": 0,
+ "uploads_error": 0,
+ },
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ in_progress_uploads = 0
+ errored_uploads = 0
+ for upload in uploads_queryset:
+ # upload is still processing
+ if not upload.state:
+ in_progress_uploads += 1
+ elif upload.state == "error":
+ errored_uploads += 1
+
+ TaskService().manual_upload_completion_trigger(repo.repoid, commit.commitid)
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="upload_complete",
+ request=self.request,
+ repository=repo,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+ return Response(
+ data={
+ "uploads_total": uploads_count,
+ "uploads_success": uploads_count
+ - in_progress_uploads
+ - errored_uploads,
+ "uploads_processing": in_progress_uploads,
+ "uploads_error": errored_uploads,
+ },
+ status=status.HTTP_200_OK,
+ )
diff --git a/apps/codecov-api/upload/views/upload_coverage.py b/apps/codecov-api/upload/views/upload_coverage.py
new file mode 100644
index 0000000000..ecea6b92a2
--- /dev/null
+++ b/apps/codecov-api/upload/views/upload_coverage.py
@@ -0,0 +1,158 @@
+import logging
+
+from django.conf import settings
+from django.http import HttpRequest
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.api_archive.archive import ArchiveService
+from shared.metrics import inc_counter
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from upload.helpers import generate_upload_prometheus_metrics_labels
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.serializers import (
+ CommitReportSerializer,
+ CommitSerializer,
+ UploadSerializer,
+)
+from upload.throttles import UploadsPerCommitThrottle, UploadsPerWindowThrottle
+from upload.views.base import GetterMixin
+from upload.views.commits import create_commit
+from upload.views.reports import create_report
+from upload.views.uploads import (
+ CanDoCoverageUploadsPermission,
+ create_upload,
+ get_token_for_analytics,
+)
+
+log = logging.getLogger(__name__)
+
+
+class UploadCoverageView(APIView, GetterMixin):
+ permission_classes = [CanDoCoverageUploadsPermission]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ ]
+ throttle_classes = [UploadsPerCommitThrottle, UploadsPerWindowThrottle]
+
+ def get_exception_handler(self):
+ return repo_auth_custom_exception_handler
+
+ def emit_metrics(self, position: str) -> None:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="upload_coverage",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position=position,
+ ),
+ )
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> Response:
+ self.emit_metrics(position="start")
+
+ # Create commit
+ create_commit_data = dict(
+ branch=request.data.get("branch"),
+ commitid=request.data.get("commitid"),
+ parent_commit_id=request.data.get("parent_commit_id"),
+ pullid=request.data.get("pullid"),
+ )
+ commit_serializer = CommitSerializer(data=create_commit_data)
+ if not commit_serializer.is_valid():
+ return Response(
+ commit_serializer.errors, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ repository = self.get_repo()
+ self.emit_metrics(position="create_commit")
+ commit = create_commit(commit_serializer, repository)
+
+ log.info(
+ "Request to create new coverage upload",
+ extra=dict(
+ repo=repository.name,
+ commit=commit.commitid,
+ ),
+ )
+
+ # Create report
+ commit_report_data = dict(
+ code=request.data.get("code"),
+ )
+ commit_report_serializer = CommitReportSerializer(data=commit_report_data)
+ if not commit_report_serializer.is_valid():
+ return Response(
+ commit_report_serializer.errors, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ self.emit_metrics(position="create_report")
+ report = create_report(commit_report_serializer, repository, commit)
+
+ # Do upload
+ upload_data = dict(
+ ci_service=request.data.get("ci_service"),
+ ci_url=request.data.get("ci_url"),
+ env=request.data.get("env"),
+ flags=request.data.get("flags"),
+ job_code=request.data.get("job_code"),
+ name=request.data.get("name"),
+ version=request.data.get("version"),
+ )
+
+ if self.is_shelter_request():
+ upload_data["storage_path"] = request.data.get("storage_path")
+
+ upload_serializer = UploadSerializer(data=upload_data)
+ if not upload_serializer.is_valid():
+ return Response(
+ upload_serializer.errors, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ self.emit_metrics(position="create_upload")
+ upload = create_upload(
+ upload_serializer,
+ repository,
+ commit,
+ report,
+ self.is_shelter_request(),
+ get_token_for_analytics(commit, self.request),
+ )
+
+ self.emit_metrics(position="end")
+
+ if not upload:
+ return Response(
+ upload_serializer.errors, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ commitid = upload.report.commit.commitid
+ upload_repository = upload.report.commit.repository
+ url = f"{settings.CODECOV_DASHBOARD_URL}/{upload_repository.author.service}/{upload_repository.author.username}/{upload_repository.name}/commit/{commitid}"
+ archive_service = ArchiveService(upload_repository)
+ raw_upload_location = archive_service.create_presigned_put(upload.storage_path)
+ return Response(
+ {
+ "external_id": str(upload.external_id),
+ "created_at": upload.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "raw_upload_location": raw_upload_location,
+ "url": url,
+ },
+ status=status.HTTP_201_CREATED,
+ )
diff --git a/apps/codecov-api/upload/views/uploads.py b/apps/codecov-api/upload/views/uploads.py
new file mode 100644
index 0000000000..a430943dd3
--- /dev/null
+++ b/apps/codecov-api/upload/views/uploads.py
@@ -0,0 +1,309 @@
+import logging
+import uuid
+from typing import Any, Callable, Dict
+
+from django.http import HttpRequest, HttpResponseNotAllowed
+from django.utils import timezone
+from rest_framework.exceptions import ValidationError
+from rest_framework.generics import ListCreateAPIView
+from rest_framework.permissions import BasePermission
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.api_archive.archive import ArchiveService, MinioEndpoints
+from shared.events.amplitude import UNKNOWN_USER_OWNERID, AmplitudeEventPublisher
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import inc_counter
+from shared.upload.utils import UploaderType, insert_coverage_measurement
+
+from codecov_auth.authentication.repo_auth import (
+ GitHubOIDCTokenAuthentication,
+ GlobalTokenAuthentication,
+ OIDCTokenRepositoryAuth,
+ OrgLevelTokenAuthentication,
+ OrgLevelTokenRepositoryAuth,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuth,
+ TokenlessAuthentication,
+ UploadTokenRequiredAuthenticationCheck,
+ repo_auth_custom_exception_handler,
+)
+from codecov_auth.models import OrganizationLevelToken
+from core.models import Commit, Repository
+from reports.models import CommitReport, ReportSession
+from services.analytics import AnalyticsService
+from upload.helpers import (
+ dispatch_upload_task,
+ generate_upload_prometheus_metrics_labels,
+ validate_activated_repo,
+)
+from upload.metrics import API_UPLOAD_COUNTER
+from upload.serializers import UploadSerializer
+from upload.throttles import UploadsPerCommitThrottle, UploadsPerWindowThrottle
+from upload.views.base import GetterMixin
+
+log = logging.getLogger(__name__)
+
+
+def create_upload(
+ serializer: UploadSerializer,
+ repository: Repository,
+ commit: Commit,
+ report: CommitReport,
+ is_shelter_request: bool,
+ analytics_token: str,
+) -> ReportSession:
+ AmplitudeEventPublisher().publish(
+ "Upload Received",
+ {
+ "user_ownerid": commit.author.ownerid
+ if commit.author
+ else UNKNOWN_USER_OWNERID,
+ "ownerid": repository.author.ownerid,
+ "repoid": repository.repoid,
+ "commitid": commit.id, # Not commit.commitid, we do not want a commit SHA here.
+ "pullid": commit.pullid,
+ "upload_type": "Coverage report",
+ },
+ )
+
+ version = (
+ serializer.validated_data["version"]
+ if "version" in serializer.validated_data
+ else None
+ )
+ archive_service = ArchiveService(repository)
+ # only Shelter requests are allowed to set their own `storage_path`
+
+ if not serializer.validated_data.get("storage_path") or not is_shelter_request:
+ serializer.validated_data["external_id"] = uuid.uuid4()
+ path = MinioEndpoints.raw_with_upload_id.get_path(
+ version="v4",
+ date=timezone.now().strftime("%Y-%m-%d"),
+ repo_hash=archive_service.storage_hash,
+ commit_sha=commit.commitid,
+ reportid=report.external_id,
+ uploadid=serializer.validated_data["external_id"],
+ )
+ serializer.validated_data["storage_path"] = path
+ # Create upload record
+ instance: ReportSession = serializer.save(
+ repo_id=repository.repoid,
+ report_id=report.id,
+ upload_extras={"format_version": "v1"},
+ state="started",
+ )
+
+ # Inserts mirror upload record into measurements table. CLI hits this endpoint
+ insert_coverage_measurement(
+ owner_id=repository.author.ownerid,
+ repo_id=repository.repoid,
+ commit_id=commit.id,
+ upload_id=instance.id,
+ uploader_used=UploaderType.CLI.value,
+ private_repo=repository.private,
+ report_type=report.report_type,
+ )
+
+ trigger_upload_task(repository, commit.commitid, instance, report)
+ activate_repo(repository)
+ send_analytics_data(commit, instance, version, analytics_token)
+ return instance
+
+
+def trigger_upload_task(
+ repository: Repository, commit_sha: str, upload: ReportSession, report: CommitReport
+) -> None:
+ log.info(
+ "Triggering upload task",
+ extra=dict(
+ repo=repository.name,
+ commit=commit_sha,
+ upload_id=upload.id,
+ report_code=report.code,
+ ),
+ )
+ redis = get_redis_connection()
+ task_arguments = {
+ "commit": commit_sha,
+ "upload_id": upload.id,
+ "version": "v4",
+ "report_code": report.code,
+ "reportid": str(report.external_id),
+ }
+ dispatch_upload_task(task_arguments, repository, redis)
+
+
+def activate_repo(repository: Repository) -> None:
+ # Only update the fields if needed
+ if (
+ repository.activated
+ and repository.active
+ and not repository.deleted
+ and repository.coverage_enabled
+ ):
+ return
+ repository.activated = True
+ repository.active = True
+ repository.deleted = False
+ repository.coverage_enabled = True
+ repository.save(
+ update_fields=[
+ "activated",
+ "active",
+ "deleted",
+ "coverage_enabled",
+ "updatestamp",
+ ]
+ )
+
+
+def send_analytics_data(
+ commit: Commit, upload: ReportSession, version: str, analytics_token: str
+) -> None:
+ analytics_upload_data = {
+ "commit": commit.commitid,
+ "branch": commit.branch,
+ "pr": commit.pullid,
+ "repo": commit.repository.name,
+ "repository_name": commit.repository.name,
+ "repository_id": commit.repository.repoid,
+ "service": commit.repository.service,
+ "build": upload.build_code,
+ "build_url": upload.build_url,
+ # we were previously using upload.flag_names here, and this query might not be optimized
+ # we weren't doing it in the legacy endpoint, but in the new one we are, and it may be causing problems
+ # therefore we are removing this for now to see if it is the source of the issue
+ "flags": "",
+ "owner": commit.repository.author.ownerid,
+ "token": str(analytics_token),
+ "version": version,
+ "uploader_type": "CLI",
+ }
+ AnalyticsService().account_uploaded_coverage_report(
+ commit.repository.author.ownerid, analytics_upload_data
+ )
+
+
+def get_token_for_analytics(commit: Commit, request: HttpRequest) -> str:
+ repo = commit.repository
+ if isinstance(request.auth, TokenlessAuth):
+ analytics_token = "tokenless_upload"
+ elif isinstance(request.auth, OrgLevelTokenRepositoryAuth):
+ analytics_token = (
+ OrganizationLevelToken.objects.filter(owner=repo.author).first().token
+ )
+ elif isinstance(request.auth, OIDCTokenRepositoryAuth):
+ analytics_token = "oidc_token_upload"
+ else:
+ analytics_token = repo.upload_token
+ return analytics_token
+
+
+class CanDoCoverageUploadsPermission(BasePermission):
+ def has_permission(self, request: HttpRequest, view: APIView) -> bool:
+ repository = view.get_repo()
+ return (
+ request.auth is not None
+ and "upload" in request.auth.get_scopes()
+ and request.auth.allows_repo(repository)
+ )
+
+
+class UploadViews(ListCreateAPIView, GetterMixin):
+ serializer_class = UploadSerializer
+ permission_classes = [
+ CanDoCoverageUploadsPermission,
+ ]
+ authentication_classes = [
+ UploadTokenRequiredAuthenticationCheck,
+ GlobalTokenAuthentication,
+ OrgLevelTokenAuthentication,
+ GitHubOIDCTokenAuthentication,
+ RepositoryLegacyTokenAuthentication,
+ TokenlessAuthentication,
+ ]
+
+ throttle_classes = [UploadsPerCommitThrottle, UploadsPerWindowThrottle]
+
+ def get_exception_handler(self) -> Callable[[Exception, Dict[str, Any]], Response]:
+ return repo_auth_custom_exception_handler
+
+ def perform_create(self, serializer: UploadSerializer) -> ReportSession:
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_upload",
+ request=self.request,
+ is_shelter_request=self.is_shelter_request(),
+ position="start",
+ ),
+ )
+ repository: Repository = self.get_repo()
+ validate_activated_repo(repository)
+ commit: Commit = self.get_commit(repository)
+ report: CommitReport = self.get_report(commit)
+
+ log.info(
+ "Request to create new upload",
+ extra=dict(
+ repo=repository.name,
+ commit=commit.commitid,
+ cli_version=serializer.validated_data["version"]
+ if "version" in serializer.validated_data
+ else None,
+ ),
+ )
+
+ instance = create_upload(
+ serializer,
+ repository,
+ commit,
+ report,
+ self.is_shelter_request(),
+ get_token_for_analytics(commit, self.request),
+ )
+ inc_counter(
+ API_UPLOAD_COUNTER,
+ labels=generate_upload_prometheus_metrics_labels(
+ action="coverage",
+ endpoint="create_upload",
+ request=self.request,
+ repository=repository,
+ is_shelter_request=self.is_shelter_request(),
+ position="end",
+ ),
+ )
+
+ return instance
+
+ def list(
+ self,
+ request: HttpRequest,
+ service: str,
+ repo: str,
+ commit_sha: str,
+ report_code: str,
+ ) -> HttpResponseNotAllowed:
+ return HttpResponseNotAllowed(permitted_methods=["POST"])
+
+ def get_repo(self) -> Repository:
+ try:
+ repo = super().get_repo()
+ return repo
+ except ValidationError as exception:
+ raise exception
+
+ def get_commit(self, repo: Repository) -> Commit:
+ try:
+ commit = super().get_commit(repo)
+ return commit
+ except ValidationError as excpetion:
+ raise excpetion
+
+ def get_report(self, commit: Commit, _: Any = None) -> CommitReport:
+ try:
+ report = super().get_report(commit)
+ return report
+ except ValidationError as exception:
+ raise exception
diff --git a/apps/codecov-api/utils/__init__.py b/apps/codecov-api/utils/__init__.py
new file mode 100644
index 0000000000..2da0214932
--- /dev/null
+++ b/apps/codecov-api/utils/__init__.py
@@ -0,0 +1,43 @@
+import math
+import uuid
+from typing import Any
+
+
+def is_uuid(value: Any) -> bool:
+ try:
+ uuid.UUID(str(value))
+ return True
+ except ValueError:
+ return False
+
+
+def round_decimals_down(number: float, decimals: int = 2) -> float:
+ """
+ Returns a value rounded down to a specific number of decimal places.
+ """
+ if not isinstance(decimals, int):
+ raise TypeError("decimal places must be an integer")
+ elif decimals < 0:
+ raise ValueError("decimal places has to be 0 or more")
+ elif decimals == 0:
+ return math.floor(number)
+
+ factor = 10**decimals
+ return math.floor(number * factor) / factor
+
+
+# Copied from
+def strtobool(val: Any) -> int:
+ """Convert a string representation of truth to true (1) or false (0).
+
+ True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
+ are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
+ 'val' is anything else.
+ """
+ val = val.lower()
+ if val in ("y", "yes", "t", "true", "on", "1"):
+ return 1
+ elif val in ("n", "no", "f", "false", "off", "0"):
+ return 0
+ else:
+ raise ValueError("invalid truth value %r" % (val,))
diff --git a/apps/codecov-api/utils/config.py b/apps/codecov-api/utils/config.py
new file mode 100644
index 0000000000..577b0e9904
--- /dev/null
+++ b/apps/codecov-api/utils/config.py
@@ -0,0 +1,64 @@
+import logging
+import os
+from enum import Enum
+
+from shared.config import get_config as shared_get_config
+
+
+class SettingsModule(Enum):
+ DEV = "codecov.settings_dev"
+ STAGING = "codecov.settings_staging"
+ TESTING = "codecov.settings_test"
+ ENTERPRISE = "codecov.settings_enterprise"
+ PRODUCTION = "codecov.settings_prod"
+
+
+RUN_ENV = os.environ.get("RUN_ENV", "PRODUCTION")
+
+
+if RUN_ENV == "DEV":
+ settings_module = SettingsModule.DEV.value
+elif RUN_ENV == "STAGING":
+ settings_module = SettingsModule.STAGING.value
+elif RUN_ENV == "TESTING":
+ settings_module = SettingsModule.TESTING.value
+elif RUN_ENV == "ENTERPRISE":
+ settings_module = SettingsModule.ENTERPRISE.value
+else:
+ settings_module = SettingsModule.PRODUCTION.value
+
+
+def get_settings_module():
+ return settings_module
+
+
+class MissingConfigException(Exception):
+ pass
+
+
+log = logging.getLogger(__name__)
+
+
+def get_config(*path, default=None):
+ return shared_get_config(*path, default=default)
+
+
+def should_write_data_to_storage_config_check(
+ master_switch_key: str, is_codecov_repo: bool, repoid: int
+) -> bool:
+ master_write_switch = get_config(
+ "setup",
+ "save_report_data_in_storage",
+ master_switch_key,
+ default=False,
+ )
+ if master_write_switch == "restricted_access":
+ allowed_repo_ids = get_config(
+ "setup", "save_report_data_in_storage", "repo_ids", default=[]
+ )
+ is_in_allowed_repoids = repoid in allowed_repo_ids
+ elif master_write_switch == "general_access":
+ is_in_allowed_repoids = True
+ else:
+ is_in_allowed_repoids = False
+ return master_write_switch and (is_codecov_repo or is_in_allowed_repoids)
diff --git a/apps/codecov-api/utils/encryption.py b/apps/codecov-api/utils/encryption.py
new file mode 100644
index 0000000000..8fc320865e
--- /dev/null
+++ b/apps/codecov-api/utils/encryption.py
@@ -0,0 +1,3 @@
+from shared.encryption.oauth import get_encryptor_from_configuration
+
+encryptor = get_encryptor_from_configuration()
diff --git a/apps/codecov-api/utils/logging_configuration.py b/apps/codecov-api/utils/logging_configuration.py
new file mode 100644
index 0000000000..27455746fd
--- /dev/null
+++ b/apps/codecov-api/utils/logging_configuration.py
@@ -0,0 +1,89 @@
+import json
+from datetime import datetime
+from logging import Filter
+
+from pythonjsonlogger.jsonlogger import JsonFormatter
+from sentry_sdk import get_current_span
+
+
+class BaseLogger(JsonFormatter):
+ def add_fields(self, log_record, record, message_dict):
+ super(BaseLogger, self).add_fields(log_record, record, message_dict)
+
+ asctime_format = "%Y-%m-%d %H:%M:%S,%f"
+ asctime = datetime.strptime(log_record.get("asctime"), asctime_format)
+
+ log_record["utctime"] = asctime.isoformat()
+
+ def format_json_on_new_lines(self, json_str):
+ # Parse the input JSON string
+ data = json.loads(json_str)
+ # Convert the parsed JSON data back to a formatted JSON string
+ formatted_json = json.dumps(data, indent=4)
+ return formatted_json
+
+
+class CustomLocalJsonFormatter(BaseLogger):
+ def jsonify_log_record(self, log_record):
+ """Returns a json string of the log record."""
+ levelname = log_record.pop("levelname")
+ message = log_record.pop("message")
+ exc_info = log_record.pop("exc_info", "")
+ content = super().jsonify_log_record(log_record)
+ formatted = super().format_json_on_new_lines(content) if content else None
+ if exc_info:
+ return f"{levelname}: {message} \n {formatted}\n{exc_info}"
+ return f"{levelname}: {message} \n {formatted}"
+
+
+class CustomDatadogJsonFormatter(BaseLogger):
+ def add_fields(self, log_record, record, message_dict):
+ super(CustomDatadogJsonFormatter, self).add_fields(
+ log_record, record, message_dict
+ )
+ if not log_record.get("logger.name") and log_record.get("name"):
+ log_record["logger.name"] = log_record.get("name")
+ if not log_record.get("logger.thread_name") and log_record.get("threadName"):
+ log_record["logger.thread_name"] = log_record.get("threadName")
+ if log_record.get("level"):
+ log_record["level"] = log_record["level"].upper()
+ else:
+ log_record["level"] = record.levelname
+
+ span = get_current_span()
+ if span and span.trace_id:
+ log_record["sentry_trace_id"] = span.trace_id
+
+
+class CustomGunicornLogFormatter(JsonFormatter):
+ rename_fields = {
+ "levelname": "level",
+ "r": "request",
+ "a": "useragent",
+ "f": "referrer",
+ "b": "response_length",
+ "h": "remote_address",
+ "t": "request_time",
+ "s": "status_code",
+ }
+
+ def add_fields(self, log_record, record, message_dict):
+ super(CustomGunicornLogFormatter, self).add_fields(
+ log_record, record, message_dict
+ )
+ for field in self._required_fields:
+ if field in self.rename_fields:
+ log_record[self.rename_fields[field]] = record.args.get(field)
+ del log_record[field]
+ else:
+ log_record[field] = record.args.get(field)
+
+
+class HealthCheckFilter(Filter):
+ def filter(self, record):
+ # Ignore /health/ requests, unless it's not a 200.
+ return (
+ ("GET /health/" not in record.getMessage())
+ if record.args.get("s") == "200"
+ else True
+ )
diff --git a/apps/codecov-api/utils/repos.py b/apps/codecov-api/utils/repos.py
new file mode 100644
index 0000000000..a44c2806bc
--- /dev/null
+++ b/apps/codecov-api/utils/repos.py
@@ -0,0 +1,13 @@
+from typing import Optional
+
+from codecov_auth.models import Owner
+from core.models import Repository
+
+
+def get_bot_user(repo: Repository) -> Optional[Owner]:
+ if repo.bot and repo.bot.oauth_token:
+ return repo.bot
+ if repo.author.bot and repo.author.bot.oauth_token:
+ return repo.author.bot
+ if repo.author.oauth_token:
+ return repo.author
diff --git a/apps/codecov-api/utils/rollouts.py b/apps/codecov-api/utils/rollouts.py
new file mode 100644
index 0000000000..a9ffa1aa57
--- /dev/null
+++ b/apps/codecov-api/utils/rollouts.py
@@ -0,0 +1,5 @@
+from codecov_auth.models import Owner
+
+
+def owner_slug(owner: Owner) -> str:
+ return f"{owner.service}/{owner.username}"
diff --git a/apps/codecov-api/utils/routers.py b/apps/codecov-api/utils/routers.py
new file mode 100644
index 0000000000..94ebc65f89
--- /dev/null
+++ b/apps/codecov-api/utils/routers.py
@@ -0,0 +1,40 @@
+from rest_framework.routers import DefaultRouter, DynamicRoute, Route
+
+
+class OptionalTrailingSlashRouter(DefaultRouter):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.trailing_slash = "/?"
+
+
+class RetrieveUpdateDestroyRouter(OptionalTrailingSlashRouter):
+ """
+ A router that maps GET /resource/ -> retrieve, as opposed to list.
+
+ Also maps
+
+ PUT /resource/ -> to update
+ PATCH /resource/ -> to partial_update
+ DELETE /resource/ -> to destroy
+ """
+
+ routes = [
+ Route(
+ url=r"^{prefix}{trailing_slash}$",
+ mapping={
+ "get": "retrieve",
+ "put": "update",
+ "patch": "partial_update",
+ "delete": "destroy",
+ },
+ name="{basename}-detail",
+ detail=False,
+ initkwargs={"suffix": "Retrieve"},
+ ),
+ DynamicRoute(
+ url=r"^{prefix}/{url_path}$",
+ name="{basename}-{url_name}",
+ detail=False,
+ initkwargs={},
+ ),
+ ]
diff --git a/apps/codecov-api/utils/services.py b/apps/codecov-api/utils/services.py
new file mode 100644
index 0000000000..1c6edb2983
--- /dev/null
+++ b/apps/codecov-api/utils/services.py
@@ -0,0 +1,21 @@
+short_services = {
+ "gh": "github",
+ "bb": "bitbucket",
+ "gl": "gitlab",
+ "ghe": "github_enterprise",
+ "gle": "gitlab_enterprise",
+ "bbs": "bitbucket_server",
+}
+long_services = {value: key for (key, value) in short_services.items()}
+
+
+def get_long_service_name(service):
+ if service in short_services:
+ return short_services[service]
+ return service
+
+
+def get_short_service_name(service):
+ if service in long_services:
+ return long_services[service]
+ return service
diff --git a/apps/codecov-api/utils/shelter.py b/apps/codecov-api/utils/shelter.py
new file mode 100644
index 0000000000..f34a847b8b
--- /dev/null
+++ b/apps/codecov-api/utils/shelter.py
@@ -0,0 +1,50 @@
+import json
+import logging
+from typing import Any, Dict
+
+from django.conf import settings
+from google.cloud import pubsub_v1
+
+log = logging.getLogger(__name__)
+
+
+class ShelterPubsub:
+ pubsub_publisher = None
+ _instance = None
+
+ @classmethod
+ def get_instance(cls) -> "ShelterPubsub":
+ """
+ This class needs the Django settings to be fully loaded before it can be instantiated,
+ therefore use this method to get an instance rather than instantiating directly.
+ """
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def __init__(self) -> None:
+ if not settings.SHELTER_ENABLED:
+ return
+
+ if not self.pubsub_publisher:
+ self.pubsub_publisher = pubsub_v1.PublisherClient()
+ pubsub_project_id: str = settings.SHELTER_PUBSUB_PROJECT_ID
+
+ # topic_id has REPO in the name but it is used for all types of objects
+ topic_id: str = settings.SHELTER_PUBSUB_SYNC_REPO_TOPIC_ID
+ self.topic_path = self.pubsub_publisher.topic_path(pubsub_project_id, topic_id)
+
+ def publish(self, data: Dict[str, Any]) -> None:
+ if not settings.SHELTER_ENABLED:
+ return
+
+ try:
+ self.pubsub_publisher.publish(
+ self.topic_path,
+ json.dumps(data).encode("utf-8"),
+ )
+ except Exception as e:
+ log.warning(
+ "Failed to publish a message",
+ extra=dict(data_to_publish=data, error=e),
+ )
diff --git a/apps/codecov-api/utils/test_results.py b/apps/codecov-api/utils/test_results.py
new file mode 100644
index 0000000000..1d5e043871
--- /dev/null
+++ b/apps/codecov-api/utils/test_results.py
@@ -0,0 +1,116 @@
+import polars as pl
+from django.conf import settings
+from shared.helpers.redis import get_redis_connection
+from shared.storage import get_appropriate_storage_service
+from shared.storage.exceptions import FileNotInStorageError
+
+from services.task import TaskService
+
+
+def redis_key(
+ repoid: int,
+ branch: str,
+ interval_start: int,
+ interval_end: int | None = None,
+) -> str:
+ key = f"test_results:{repoid}:{branch}:{interval_start}"
+
+ if interval_end is not None:
+ key = f"{key}:{interval_end}"
+
+ return key
+
+
+def storage_key(
+ repoid: int, branch: str, interval_start: int, interval_end: int | None = None
+) -> str:
+ key = f"test_results/rollups/{repoid}/{branch}/{interval_start}"
+
+ if interval_end is not None:
+ key = f"{key}_{interval_end}"
+
+ return key
+
+
+def dedup_table(table: pl.DataFrame) -> pl.DataFrame:
+ failure_rate_expr = (
+ pl.col("failure_rate")
+ * (pl.col("total_fail_count") + pl.col("total_pass_count"))
+ ).sum() / (pl.col("total_fail_count") + pl.col("total_pass_count")).sum()
+
+ flake_rate_expr = (
+ pl.col("flake_rate") * (pl.col("total_fail_count") + pl.col("total_pass_count"))
+ ).sum() / (pl.col("total_fail_count") + pl.col("total_pass_count")).sum()
+
+ avg_duration_expr = (
+ pl.col("avg_duration")
+ * (pl.col("total_pass_count") + pl.col("total_fail_count"))
+ ).sum() / (pl.col("total_pass_count") + pl.col("total_fail_count")).sum()
+
+ # dedup
+ table = (
+ table.group_by("name")
+ .agg(
+ pl.col("testsuite").alias("testsuite"),
+ pl.col("flags").explode().unique().alias("flags"),
+ failure_rate_expr.fill_nan(0).alias("failure_rate"),
+ flake_rate_expr.fill_nan(0).alias("flake_rate"),
+ pl.col("updated_at").max().alias("updated_at"),
+ avg_duration_expr.fill_nan(0).alias("avg_duration"),
+ pl.col("total_fail_count").sum().alias("total_fail_count"),
+ pl.col("total_flaky_fail_count").sum().alias("total_flaky_fail_count"),
+ pl.col("total_pass_count").sum().alias("total_pass_count"),
+ pl.col("total_skip_count").sum().alias("total_skip_count"),
+ pl.col("commits_where_fail").sum().alias("commits_where_fail"),
+ pl.col("last_duration").max().alias("last_duration"),
+ )
+ .sort("name")
+ )
+
+ return table
+
+
+def get_results(
+ repoid: int,
+ branch: str,
+ interval_start: int,
+ interval_end: int | None = None,
+) -> pl.DataFrame | None:
+ """
+ try redis
+ if redis is empty
+ try storage
+ if storage is empty
+ return None
+ else
+ cache to redis
+ deserialize
+ """
+ # try redis
+ redis_conn = get_redis_connection()
+ key = redis_key(repoid, branch, interval_start, interval_end)
+ result: bytes | None = redis_conn.get(key)
+
+ if result is None:
+ # try storage
+ storage_service = get_appropriate_storage_service(repoid)
+ key = storage_key(repoid, branch, interval_start, interval_end)
+ try:
+ result = storage_service.read_file(
+ bucket_name=settings.GCS_BUCKET_NAME, path=key
+ )
+ # cache to redis
+ TaskService().cache_test_results_redis(repoid, branch)
+ except FileNotInStorageError:
+ # give up
+ return None
+
+ # deserialize
+ table = pl.read_ipc(result)
+
+ if table.height == 0:
+ return None
+
+ table = dedup_table(table)
+
+ return table
diff --git a/apps/codecov-api/utils/test_utils.py b/apps/codecov-api/utils/test_utils.py
new file mode 100644
index 0000000000..6cac27e04e
--- /dev/null
+++ b/apps/codecov-api/utils/test_utils.py
@@ -0,0 +1,71 @@
+from typing import Any
+
+from django.apps import apps
+from django.db import connection
+from django.db.migrations.executor import MigrationExecutor
+from django.test import TestCase
+from django.test.client import Client as DjangoClient
+from rest_framework.test import APIClient as DjangoAPIClient
+
+from codecov_auth.models import Owner
+
+
+class BaseTestCase(object):
+ pass
+
+
+class ClientMixin:
+ def force_login_owner(self, owner: Owner) -> None:
+ self.force_login(user=owner.user)
+ session = self.session
+ session["current_owner_id"] = owner.pk
+ session.save()
+
+ def logout(self) -> None:
+ session = self.session
+ session["current_owner_id"] = None
+ session.save()
+ super().logout() # type: ignore
+
+
+class Client(ClientMixin, DjangoClient):
+ pass
+
+
+class APIClient(ClientMixin, DjangoAPIClient):
+ pass
+
+
+class TestMigrations(TestCase):
+ @property
+ def app(self) -> str:
+ return apps.get_containing_app_config(type(self).__module__).name
+
+ migrate_from = None
+ migrate_to = None
+
+ def setUp(self) -> None:
+ assert self.migrate_from and self.migrate_to, (
+ "TestCase '{}' must define migrate_from and migrate_to properties".format(
+ type(self).__name__
+ )
+ )
+ self.migrate_from = [(self.app, self.migrate_from)]
+ self.migrate_to = [(self.app, self.migrate_to)]
+ executor = MigrationExecutor(connection)
+ old_apps = executor.loader.project_state(self.migrate_from).apps
+
+ # Reverse to the original migration
+ executor.migrate(self.migrate_from)
+
+ self.setUpBeforeMigration(old_apps)
+
+ # Run the migration to test
+ executor = MigrationExecutor(connection)
+ executor.loader.build_graph() # reload.
+ executor.migrate(self.migrate_to)
+
+ self.apps = executor.loader.project_state(self.migrate_to).apps
+
+ def setUpBeforeMigration(self, apps: Any) -> None:
+ pass
diff --git a/apps/codecov-api/utils/tests/unit/test_config.py b/apps/codecov-api/utils/tests/unit/test_config.py
new file mode 100644
index 0000000000..41aa901e7c
--- /dev/null
+++ b/apps/codecov-api/utils/tests/unit/test_config.py
@@ -0,0 +1,107 @@
+import pytest
+
+from utils.config import should_write_data_to_storage_config_check
+
+
+@pytest.mark.parametrize(
+ "inner_config, func_args, result",
+ [
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("commit_report", True, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("commit_report", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": True, # True is the same as "codecov_access"
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "codecov_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "codecov_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": "general_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "restricted_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ ],
+)
+def test_should_write_data_to_storage_config_check(
+ inner_config, func_args, result, mocker
+):
+ config = {"setup": {"save_report_data_in_storage": inner_config}}
+
+ def fake_config(*path, default=None):
+ curr = config
+ for key in path:
+ if key in curr:
+ curr = curr.get(key)
+ else:
+ return default
+ return curr
+
+ mocker.patch("utils.config.get_config", side_effect=fake_config)
+ assert should_write_data_to_storage_config_check(*func_args) == result
diff --git a/apps/codecov-api/utils/tests/unit/test_logging.py b/apps/codecov-api/utils/tests/unit/test_logging.py
new file mode 100644
index 0000000000..940430f49a
--- /dev/null
+++ b/apps/codecov-api/utils/tests/unit/test_logging.py
@@ -0,0 +1,19 @@
+from utils.logging_configuration import CustomLocalJsonFormatter
+
+
+class TestLoggingConfig(object):
+ def test_local_formatter(self):
+ log_record = {"levelname": "weird_level", "message": "This is a message"}
+ cljf = CustomLocalJsonFormatter()
+ res = cljf.jsonify_log_record(log_record)
+ assert "weird_level: This is a message \n {}" == res
+
+ def test_local_formatter_with_exc_info(self):
+ log_record = {
+ "levelname": "weird_level",
+ "message": "This is a message",
+ "exc_info": "Line\nWith\nbreaks",
+ }
+ cljf = CustomLocalJsonFormatter()
+ res = cljf.jsonify_log_record(log_record)
+ assert "weird_level: This is a message \n {}\nLine\nWith\nbreaks" == res
diff --git a/apps/codecov-api/utils/tests/unit/test_repos.py b/apps/codecov-api/utils/tests/unit/test_repos.py
new file mode 100644
index 0000000000..f2597ea0cc
--- /dev/null
+++ b/apps/codecov-api/utils/tests/unit/test_repos.py
@@ -0,0 +1,22 @@
+from django.test import TestCase
+from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory
+
+from utils.repos import get_bot_user
+
+
+class RepoUtilsTests(TestCase):
+ def test_repo_bot_user_bot(self):
+ bot = OwnerFactory()
+ repo = RepositoryFactory(bot=bot)
+ assert get_bot_user(repo) == bot
+
+ def test_repo_bot_user_author_bot(self):
+ bot = OwnerFactory()
+ author = OwnerFactory(bot=bot)
+ repo = RepositoryFactory(author=author)
+ assert get_bot_user(repo) == bot
+
+ def test_repo_bot_user_author(self):
+ author = OwnerFactory()
+ repo = RepositoryFactory(author=author)
+ assert get_bot_user(repo) == author
diff --git a/apps/codecov-api/utils/tests/unit/test_services.py b/apps/codecov-api/utils/tests/unit/test_services.py
new file mode 100644
index 0000000000..125384027a
--- /dev/null
+++ b/apps/codecov-api/utils/tests/unit/test_services.py
@@ -0,0 +1,42 @@
+from utils.services import get_long_service_name, get_short_service_name
+
+
+class TestServices(object):
+ def test_gitlab(self):
+ service = get_long_service_name("gl")
+ assert service == "gitlab"
+
+ service = get_long_service_name("gitlab")
+ assert service == "gitlab"
+
+ service = get_short_service_name("gitlab")
+ assert service == "gl"
+
+ service = get_short_service_name("gl")
+ assert service == "gl"
+
+ def test_bb(self):
+ service = get_long_service_name("bb")
+ assert service == "bitbucket"
+
+ service = get_long_service_name("bitbucket")
+ assert service == "bitbucket"
+
+ service = get_short_service_name("bitbucket")
+ assert service == "bb"
+
+ service = get_short_service_name("bb")
+ assert service == "bb"
+
+ def test_gh(self):
+ service = get_long_service_name("gh")
+ assert service == "github"
+
+ service = get_long_service_name("github")
+ assert service == "github"
+
+ service = get_short_service_name("github")
+ assert service == "gh"
+
+ service = get_short_service_name("gh")
+ assert service == "gh"
diff --git a/apps/codecov-api/utils/version.py b/apps/codecov-api/utils/version.py
new file mode 100644
index 0000000000..3775778725
--- /dev/null
+++ b/apps/codecov-api/utils/version.py
@@ -0,0 +1,5 @@
+import os
+
+
+def get_current_version() -> str:
+ return os.getenv("RELEASE_VERSION", "NO_VERSION")
diff --git a/apps/codecov-api/uv.lock b/apps/codecov-api/uv.lock
new file mode 100644
index 0000000000..14591c73fa
--- /dev/null
+++ b/apps/codecov-api/uv.lock
@@ -0,0 +1,1900 @@
+version = 1
+requires-python = ">=3.13"
+
+[[package]]
+name = "aiodataloader"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/dd/e92b21d0c0d09bad4d41ce39def76deef63248c65e4ddc7ac2245ae0b2cc/aiodataloader-0.4.0.tar.gz", hash = "sha256:6de9ca0eb75c4eef686754679981ebd45a933391b40473f92419b8a747504169", size = 1629645 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/11/72e151269095f4ca73416526b017b2b7ddbcbe5181ca7b0176e3ffd09db3/aiodataloader-0.4.0-py3-none-any.whl", hash = "sha256:2775d8607e1b68ded82efc93c839846d0ae9d9e0421085e444f3c1c541f3c2b6", size = 10937 },
+]
+
+[[package]]
+name = "amplitude-analytics"
+version = "1.1.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/d8/4c35f6e844e4d967c35e130570f3f663acc09db5430eab78b9fddb48d5fb/amplitude_analytics-1.1.5.tar.gz", hash = "sha256:2836fcf88d75506a1f266a5582253fdf592e14fafaf5783017baeec2118369d4", size = 21787 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/c6/39cea4c4b5791312c81b7935ef2065d3e86ff050008c069a042f77f5c583/amplitude_analytics-1.1.5-py3-none-any.whl", hash = "sha256:da7db1985087d2b9ef0456115d116525888f416e174ef79b53e1532be7a0b3bd", size = 24138 },
+]
+
+[[package]]
+name = "amqp"
+version = "5.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/32/2c/6eb09fbdeb3c060b37bd33f8873832897a83e7a428afe01aad333fc405ec/amqp-5.2.0.tar.gz", hash = "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd", size = 128754 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/f0/8e5be5d5e0653d9e1d02b1144efa33ff7d2963dfad07049e02c0fa9b2e8d/amqp-5.2.0-py3-none-any.whl", hash = "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637", size = 50917 },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/c4/fd50bbb2fb72532a4b778562e28ba581da15067cfb2537dbd3a2e64689c1/anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b", size = 140240 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/22/4cba7e1b4f45ffbefd2ca817a6800ba1c671c26f288d7705f20289872012/anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be", size = 80617 },
+]
+
+[[package]]
+name = "ariadne"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "graphql-core" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4f/74/dc0232bc311a705c3cc642007bbbea817832c7fedb9655c870240804ad05/ariadne-0.23.0.tar.gz", hash = "sha256:84c46ae27fad98e1a6553c8930e6fc8e755f8dcd54a959d33caf04ecfe026a93", size = 75112 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/14/e2e13d6d2937f8fca9d220234f775e56119bce3cc3d6a807324faf5c80df/ariadne-0.23.0-py2.py3-none-any.whl", hash = "sha256:594206e4fc20fa05d6872f0ccd6e3aafe0575b9da36945211762445126b28bcb", size = 108265 },
+]
+
+[[package]]
+name = "ariadne-django"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ariadne" },
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/f8/700f521eedc144978297bfbd8cfb67c2acfbe5b25f31a97f34f12ed8baf3/ariadne_django-0.3.0.tar.gz", hash = "sha256:19aca29d35398564ea59749a9c28b79cf11cf79f6fbb3d017d7e15d56157ae96", size = 15652 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/55/a57ab018b1c685cba22d8ca468f870cdbe299e4eb660e762947eaed5875d/ariadne_django-0.3.0-py3-none-any.whl", hash = "sha256:3c638993e2cf1d2f579ec8735a7dfdbb57d5420c8eb2ae09b93880746e2764c6", size = 18556 },
+]
+
+[[package]]
+name = "asgiref"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/2d/797c0537426266d6c9377a2ed6a4ac61e50c2d5b1ab4da101a4b9bfe26e2/asgiref-3.6.0.tar.gz", hash = "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506", size = 32748 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/29/38d10a47b322a77b2d12c2b79c789f52956f733cb701d4d5157c76b5f238/asgiref-3.6.0-py3-none-any.whl", hash = "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac", size = 23105 },
+]
+
+[[package]]
+name = "async-timeout"
+version = "4.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/6e/9678f7b2993537452710ffb1750c62d2c26df438aa621ad5fa9d1507a43a/async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", size = 8221 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/c1/8991e7c5385b897b8c020cdaad718c5b087a6626d1d11a23e1ea87e325a7/async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c", size = 5763 },
+]
+
+[[package]]
+name = "attrs"
+version = "20.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/cb/80a4a274df7da7b8baf083249b0890a0579374c3d74b5ac0ee9291f912dc/attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700", size = 164523 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/aa/cb45262569fcc047bf070b5de61813724d6726db83259222cd7b4c79821a/attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", size = 49337 },
+]
+
+[[package]]
+name = "billiard"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/52/f10d74fd56e73b430c37417658158ad8386202b069b70ff97d945c3ab67a/billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c", size = 154665 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/8d/6e9fdeeab04d803abc5a715175f87e88893934d5590595eacff23ca12b07/billiard-4.2.0-py3-none-any.whl", hash = "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d", size = 86720 },
+]
+
+[[package]]
+name = "boto3"
+version = "1.37.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/3f/135ec0771e6d0e1af2ad7023a15df6677d96112072838d948c9b5075efe1/boto3-1.37.3.tar.gz", hash = "sha256:21f3ce0ef111297e63a6eb998a25197b8c10982970c320d4c6e8db08be2157be", size = 111160 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/8c/213511a505af2239a673de4de145d013379275c569185187922f93dbdf14/boto3-1.37.3-py3-none-any.whl", hash = "sha256:2063b40af99fd02f6228ff52397b552ff3353831edaf8d25cc04801827ab9794", size = 139344 },
+]
+
+[[package]]
+name = "botocore"
+version = "1.37.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/fb/b243ab806d2e1e6b8a475b731cc59a1f1e4709eded4884b988a27bbc996b/botocore-1.37.3.tar.gz", hash = "sha256:fe8403eb55a88faf9b0f9da6615e5bee7be056d75e17af66c3c8f0a3b0648da4", size = 13574648 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/54/772118f15b5990173aa5264946cc8c9ff70c8f02d72ee6d63167a985188c/botocore-1.37.3-py3-none-any.whl", hash = "sha256:d01bd3bf4c80e61fa88d636ad9f5c9f60a551d71549b481386c6b4efe0bb2b2e", size = 13342066 },
+]
+
+[[package]]
+name = "cachetools"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/c8/0b52cf3132b4b85c9e83faa3e4d375575afeb3a1710c40b2b2cd2a3e5635/cachetools-4.1.1.tar.gz", hash = "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20", size = 23574 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/5c/f3aa86b6d5482f3051b433c7616668a9b96fbe49a622210e2c9781938a5c/cachetools-4.1.1-py3-none-any.whl", hash = "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", size = 10960 },
+]
+
+[[package]]
+name = "celery"
+version = "5.3.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "billiard" },
+ { name = "click" },
+ { name = "click-didyoumean" },
+ { name = "click-plugins" },
+ { name = "click-repl" },
+ { name = "kombu" },
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/72/45a2d2f9b45ccc6e80e2168ce169d17bf06a98711c192d7b53d5a8accf77/celery-5.3.6.tar.gz", hash = "sha256:870cc71d737c0200c397290d730344cc991d13a057534353d124c9380267aab9", size = 1544498 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/c2/4c8a67a4d98a6fcd55dbdd79b641f945d7f59637c3e885c4abbda3c431f6/celery-5.3.6-py3-none-any.whl", hash = "sha256:9da4ea0118d232ce97dff5ed4974587fb1c0ff5c10042eb15278487cdd27d1af", size = 422035 },
+]
+
+[[package]]
+name = "cerberus"
+version = "1.3.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/20/3ec65289ab0ccf5f41d38d103343f5b609263944238e299598aeba684a82/Cerberus-1.3.5.tar.gz", hash = "sha256:81011e10266ef71b6ec6d50e60171258a5b134d69f8fb387d16e4936d0d47642", size = 29898 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/17/335e0a4daf5475ada5eaa74735f765cc088ace306fbfdd2f4c2285320cc3/Cerberus-1.3.5-py3-none-any.whl", hash = "sha256:7649a5815024d18eb7c6aa5e7a95355c649a53aacfc9b050e9d0bf6bfa2af372", size = 30779 },
+]
+
+[[package]]
+name = "certifi"
+version = "2024.7.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/02/a95f2b11e207f68bc64d7aae9666fed2e2b3f307748d5123dffb72a1bbea/certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", size = 164065 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90", size = 162960 },
+]
+
+[[package]]
+name = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
+]
+
+[[package]]
+name = "cfgv"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/75/c80804e4a5eccc9acf767faf4591bb7ab289485ba236dfee542467dc7c9b/cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1", size = 7851 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/cd/3878c9248e59e5e2ebd0dc741ab984b18d86e7283ae9b127b05fc287d239/cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", size = 7277 },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "2.0.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/e4/e014e7360fc6d1ccc507fe0b563b4646d00e0d4f9beec4975026dd15850b/charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c", size = 75753 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/84/b06f6729fac8108c5fa3e13cde19b0b3de66ba5538c325496dbe39f5ff8e/charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721", size = 39257 },
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 },
+]
+
+[[package]]
+name = "click-didyoumean"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2f/a7/822fbc659be70dcb75a91fb91fec718b653326697d0e9907f4f90114b34f/click-didyoumean-0.3.0.tar.gz", hash = "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035", size = 2405 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/36/4599267417fc78b587b1588e0647a468c60b36c02bb723d450d050738fa8/click_didyoumean-0.3.0-py3-none-any.whl", hash = "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", size = 2730 },
+]
+
+[[package]]
+name = "click-plugins"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497 },
+]
+
+[[package]]
+name = "click-repl"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "prompt-toolkit" },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/30/11d3f09eff5ae3627bca79563855035e8d241444520500a3c7914eae6a74/click-repl-0.2.0.tar.gz", hash = "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8", size = 5743 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/33/15f401400cc0cf2470aa777d225e772f83a68541495e015d2fa5c77d33d0/click_repl-0.2.0-py3-none-any.whl", hash = "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", size = 5163 },
+]
+
+[[package]]
+name = "codecov-api"
+version = "0.0.1"
+source = { editable = "." }
+dependencies = [
+ { name = "aiodataloader" },
+ { name = "ariadne" },
+ { name = "ariadne-django" },
+ { name = "celery" },
+ { name = "cerberus" },
+ { name = "certifi" },
+ { name = "django" },
+ { name = "django-autocomplete-light" },
+ { name = "django-better-admin-arrayfield" },
+ { name = "django-cors-headers" },
+ { name = "django-csp" },
+ { name = "django-cursor-pagination" },
+ { name = "django-filter" },
+ { name = "django-model-utils" },
+ { name = "django-postgres-extra" },
+ { name = "django-prometheus" },
+ { name = "djangorestframework" },
+ { name = "drf-spectacular" },
+ { name = "drf-spectacular-sidecar" },
+ { name = "google-cloud-pubsub" },
+ { name = "grpcio" },
+ { name = "gunicorn" },
+ { name = "idna" },
+ { name = "minio" },
+ { name = "multidict" },
+ { name = "polars" },
+ { name = "psycopg2-binary" },
+ { name = "pydantic" },
+ { name = "pyjwt" },
+ { name = "python-dateutil" },
+ { name = "python-json-logger" },
+ { name = "python-redis-lock" },
+ { name = "pytz" },
+ { name = "redis" },
+ { name = "regex" },
+ { name = "requests" },
+ { name = "sentry-sdk", extra = ["celery"] },
+ { name = "shared" },
+ { name = "simplejson" },
+ { name = "starlette" },
+ { name = "stripe" },
+ { name = "whitenoise" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "factory-boy" },
+ { name = "fakeredis" },
+ { name = "freezegun" },
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "pytest-django" },
+ { name = "pytest-insta" },
+ { name = "pytest-mock" },
+ { name = "ruff" },
+ { name = "urllib3" },
+ { name = "vcrpy" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiodataloader", specifier = "==0.4.0" },
+ { name = "ariadne", specifier = "==0.23.0" },
+ { name = "ariadne-django", specifier = "==0.3.0" },
+ { name = "celery", specifier = ">=5.3.6" },
+ { name = "cerberus", specifier = "==1.3.5" },
+ { name = "certifi", specifier = ">=2024.7.4" },
+ { name = "django", specifier = ">=4.2.16" },
+ { name = "django-autocomplete-light", specifier = "==3.11.0" },
+ { name = "django-better-admin-arrayfield", specifier = "==1.4.2" },
+ { name = "django-cors-headers", specifier = "==3.7.0" },
+ { name = "django-csp", specifier = "==3.8.0" },
+ { name = "django-cursor-pagination", specifier = "==0.3.0" },
+ { name = "django-filter", specifier = "==2.4.0" },
+ { name = "django-model-utils", specifier = "==4.5.1" },
+ { name = "django-postgres-extra", specifier = ">=2.0.8" },
+ { name = "django-prometheus", specifier = "==2.3.1" },
+ { name = "djangorestframework", specifier = "==3.15.2" },
+ { name = "drf-spectacular", specifier = "==0.26.2" },
+ { name = "drf-spectacular-sidecar", specifier = "==2023.3.1" },
+ { name = "google-cloud-pubsub", specifier = ">=2.18.4" },
+ { name = "grpcio", specifier = ">=1.66.2" },
+ { name = "gunicorn", specifier = ">=22.0.0" },
+ { name = "idna", specifier = ">=3.7" },
+ { name = "minio", specifier = "==7.1.13" },
+ { name = "multidict", specifier = ">=6.1.0" },
+ { name = "polars", specifier = "==1.12.0" },
+ { name = "psycopg2-binary", specifier = ">=2.9.10" },
+ { name = "pydantic", specifier = ">=2.9.0" },
+ { name = "pyjwt", specifier = ">=2.4.0" },
+ { name = "python-dateutil", specifier = "==2.9.0.post0" },
+ { name = "python-json-logger", specifier = "==2.0.7" },
+ { name = "python-redis-lock", specifier = "==4.0.0" },
+ { name = "pytz", specifier = "==2022.1" },
+ { name = "redis", specifier = "==4.4.4" },
+ { name = "regex", specifier = "==2023.12.25" },
+ { name = "requests", specifier = "==2.32.3" },
+ { name = "sentry-sdk", specifier = ">=2.13.0" },
+ { name = "sentry-sdk", extras = ["celery"], specifier = "==2.13.0" },
+ { name = "shared", directory = "../../libs/shared" },
+ { name = "simplejson", specifier = "==3.17.2" },
+ { name = "starlette", specifier = "==0.40.0" },
+ { name = "stripe", specifier = ">=11.4.1" },
+ { name = "whitenoise", specifier = "==5.2.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "factory-boy", specifier = ">=3.2.0" },
+ { name = "fakeredis", specifier = "==2.10.3" },
+ { name = "freezegun", specifier = ">=1.5.1" },
+ { name = "pre-commit", specifier = ">=3.4.0" },
+ { name = "pytest", specifier = ">=8.1.1" },
+ { name = "pytest-asyncio", specifier = ">=0.14.0" },
+ { name = "pytest-cov", specifier = ">=6.0.0" },
+ { name = "pytest-django", specifier = "==4.8.0" },
+ { name = "pytest-insta", specifier = ">=0.3.0" },
+ { name = "pytest-mock", specifier = "==3.14.0" },
+ { name = "ruff", specifier = ">=0.9.6" },
+ { name = "urllib3", specifier = "==1.26.19" },
+ { name = "vcrpy", specifier = ">=6.0.1" },
+]
+
+[[package]]
+name = "codecov-ribs"
+version = "0.1.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7a/0c/da1d2367c86ccebbcd60d77a697a816b9b226031528b92c5e751a82b498d/codecov_ribs-0.1.18.tar.gz", hash = "sha256:004590ee88515aa2ee3e1ad0a05ed616a6caffcedd9effe3454efe9d927c2a46", size = 31935 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/bc/e39377099e929bd4da67e9dde23e9e4b1f765e80174a4fcbd76066ab85bc/codecov_ribs-0.1.18-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:00fe34c01f72cfb967d9abfa0a19ac65485564f1cdfc7ea634362f0f8031a57a", size = 449013 },
+ { url = "https://files.pythonhosted.org/packages/cd/26/27b1404572ece21c3cfcaebcacc77accdef87fd7f3799ae75ec9a3a986a6/codecov_ribs-0.1.18-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:509c3b5ffedb75590e5efff9fe44fa38fb9a330f3cfd2cfdbffe03ba9baf2c6f", size = 435904 },
+ { url = "https://files.pythonhosted.org/packages/90/e4/aac82889bf1233e65c05d3182f0f009f9fec7c3938143a627082a4f470dd/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:94676137c0a84fe434b6fa9d2e51e8301839b704f0561642e3104def7fdd186a", size = 1277625 },
+ { url = "https://files.pythonhosted.org/packages/46/b3/5b9677fa973a25942538c143bb1184bfd13ee838ade1f62836bae1ab4f8e/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:078a3c5b23852142ce0c362e5e61dc3e76be28820000cf8d62f0dfa02e0b9fd1", size = 1230640 },
+ { url = "https://files.pythonhosted.org/packages/48/c6/85c6d4ad866d3ed860ec4d371eaa4d55a3937dd21315d7258d6968623748/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ba82a37648c42b568513c5b8c4044c45f1dc4240a9c53259687cc2e869f415b", size = 1243114 },
+ { url = "https://files.pythonhosted.org/packages/15/30/919304e23d9599674887ae112d362e6a0b96cdb120a1766c2c0ea30bff17/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb7aeb1fce52ae4e61ad4fa1f16d9d6c5429b77c037800eec8a58586020a3b1", size = 1340437 },
+ { url = "https://files.pythonhosted.org/packages/53/55/d71a0ebf5363af6eacaf60cbf2db33d4c2e7962c8734172e6f37e378b153/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7f87052b56e5346ab2d89cad8cb801074faaca4cb9d351097e5a7a54a2af424", size = 1432376 },
+ { url = "https://files.pythonhosted.org/packages/44/26/ee24c4f22de09d04d242e21dfb4877469735a6a7d55c052b3bbcb782e541/codecov_ribs-0.1.18-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08db4118a7350f76c53fc0ba04110b606a3178fdd1acb6946cd1c0b9fea777b5", size = 1242811 },
+ { url = "https://files.pythonhosted.org/packages/00/ad/e86b5967ee91e93604554a5c9ea35175330d84e52f781a8d1295d63150e9/codecov_ribs-0.1.18-cp38-abi3-win32.whl", hash = "sha256:a7db0d0afa593f155e34ae722703b8b21840c85a94cc90e8550173610d346d0f", size = 290807 },
+ { url = "https://files.pythonhosted.org/packages/d1/e3/b1e2b06b1d7e047318608fb2b7268d594da0798b166eb6b38ae7d8c3f41f/codecov_ribs-0.1.18-cp38-abi3-win_amd64.whl", hash = "sha256:dd7a799c1eea76caebf59f4a9a3a5cda0fff7999d49452235c1db5f1d654d4b8", size = 305137 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "colour"
+version = "0.1.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a0/d4/5911a7618acddc3f594ddf144ecd8a03c29074a540f4494670ad8f153efe/colour-0.1.5.tar.gz", hash = "sha256:af20120fefd2afede8b001fbef2ea9da70ad7d49fafdb6489025dae8745c3aee", size = 24776 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/46/e81907704ab203206769dee1385dc77e1407576ff8f50a0681d0a6b541be/colour-0.1.5-py2.py3-none-any.whl", hash = "sha256:33f6db9d564fadc16e59921a56999b79571160ce09916303d35346dddc17978c", size = 23772 },
+]
+
+[[package]]
+name = "coverage"
+version = "7.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/1d/3e426526df9f320f0a08dd08434dc7b94cd621543f73f96d8a4faf216ec7/coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c", size = 784825 }
+
+[[package]]
+name = "cryptography"
+version = "44.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022 },
+ { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 },
+ { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 },
+ { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 },
+ { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 },
+ { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 },
+ { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 },
+ { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 },
+ { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 },
+ { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 },
+ { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882 },
+ { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989 },
+ { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714 },
+ { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 },
+ { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 },
+ { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 },
+ { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 },
+ { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 },
+ { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 },
+ { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 },
+ { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 },
+ { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 },
+ { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371 },
+ { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303 },
+]
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
+]
+
+[[package]]
+name = "django"
+version = "4.2.16"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/d8/a607ee443b54a4db4ad28902328b906ae6218aa556fb9b3ac45c0bcb313d/Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad", size = 10436023 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/2c/6b6c7e493d5ea789416918658ebfa16be7a64c77610307497ed09a93c8c4/Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898", size = 7992936 },
+]
+
+[[package]]
+name = "django-autocomplete-light"
+version = "3.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/32/004073fb1bc55770bc9111bac42f17f481e17d4d20bbab2b5a8b921b95a6/django-autocomplete-light-3.11.0.tar.gz", hash = "sha256:212576a17e3308ef7ca77e280b86684167916d2091d4b73640f38845d9516328", size = 173786 }
+
+[[package]]
+name = "django-better-admin-arrayfield"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/4f/e8bdf86d5bba2622d585bec983a0b59f373f4ed00eb948a6907ba35e5585/django-better-admin-arrayfield-1.4.2.tar.gz", hash = "sha256:b45423e51bbc0aa31ef658248c058ca8b533a541be4dee9fb8bcd059f8a10a58", size = 13857 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/75/8fc6cfc73c456283c89a80bd8319df61aabcd273cfa37697bc31a00e387b/django_better_admin_arrayfield-1.4.2-py2.py3-none-any.whl", hash = "sha256:bfeaa0fa8210a7ea95ee996a6caaa59ecd0c923269f573e6d8319c28dcac5c88", size = 13931 },
+]
+
+[[package]]
+name = "django-cors-headers"
+version = "3.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/69/83ac503117bca6ca013b5301c3d402fb24fcc356b4f915f9d7897ff10c48/django-cors-headers-3.7.0.tar.gz", hash = "sha256:96069c4aaacace786a34ee7894ff680780ec2644e4268b31181044410fecd12e", size = 88367 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/72/5a8907328d6b508a2c7f90a9f3d5d17f368cb5a180169078d7bf7514919e/django_cors_headers-3.7.0-py3-none-any.whl", hash = "sha256:1ac2b1213de75a251e2ba04448da15f99bcfcbe164288ae6b5ff929dc49b372f", size = 12402 },
+]
+
+[[package]]
+name = "django-csp"
+version = "3.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/16/c3c65ad59997284402e54d00797c7aca96572df911aede3e1f2cc2e029f8/django_csp-3.8.tar.gz", hash = "sha256:ef0f1a9f7d8da68ae6e169c02e9ac661c0ecf04db70e0d1d85640512a68471c0", size = 13341 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/ff/2c7a4b6706125a17bd0071802e4894c28772cfcdea20a086a2be3c5fafda/django_csp-3.8-py3-none-any.whl", hash = "sha256:19b2978b03fcd73517d7d67acbc04fbbcaec0facc3e83baa502965892d1e0719", size = 17410 },
+]
+
+[[package]]
+name = "django-cursor-pagination"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/91/73adf878757d3f10c8882e532cbcf1feda4a788bfe8088a03475486ff97c/django_cursor_pagination-0.3.0.tar.gz", hash = "sha256:b09293ea9aa93cd0f3a9f4197e1f11f09283678e6c991cf4d4517a0fe90244c1", size = 7907 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/ae/da7b25f23c97cdc05f97f83555653b5e4b69dee20ed0da5e9cce136d4e00/django_cursor_pagination-0.3.0-py3-none-any.whl", hash = "sha256:ce88147adc1e41c58427217cf54d7cbb4d04d5cf0a3a2c794d81602ad347658e", size = 6822 },
+]
+
+[[package]]
+name = "django-filter"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7b/cf/adae3e55995ea27e1dceb493e0226557d4207d8819ddb99591df5204a471/django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06", size = 146904 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/2b/b2fe483c3095b6222725dd05f9ad9e6ed6cb7347c154fdbd80238d36f1a8/django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1", size = 73156 },
+]
+
+[[package]]
+name = "django-model-utils"
+version = "4.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/77be195e7518b8a652c052cdc42e60135b56a9a460aa3bd8cb08d751c322/django_model_utils-4.5.1.tar.gz", hash = "sha256:1220f22d9a467d53a1e0f4cda4857df0b2f757edf9a29955c42461988caa648a", size = 75980 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/c5/5d14bf2d55cb843e556b6283a2fbd6ea531cf7d2fa7b8d4f6d3abfada672/django_model_utils-4.5.1-py3-none-any.whl", hash = "sha256:f1141fc71796242edeffed5ad53a8cc57f00d345eb5a3a63e3f69401cd562ee2", size = 39341 },
+]
+
+[[package]]
+name = "django-postgres-extra"
+version = "2.0.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/8a/2bf9f570e73058569e48cc3208f007fd7ff45cb28e178ea1da2b188f14e2/django-postgres-extra-2.0.8.tar.gz", hash = "sha256:9efa08c6f18ed34460af41c6f679bb375b93d12544b1105aa348b787a30b46eb", size = 48572 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/22/559559d7894648bd7f2bbfdeedd5ccba4f8d9de68855ced522011d608af5/django_postgres_extra-2.0.8-py3-none-any.whl", hash = "sha256:447d5a971759943ee63a9d4cef9c6c1fa290e518611ea521a38b6732681d2f3a", size = 75205 },
+]
+
+[[package]]
+name = "django-prometheus"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "prometheus-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/51/485b4122e00f2b8efec8a6d718ef4ce6b150231e49398e554ce1151f65c3/django-prometheus-2.3.1.tar.gz", hash = "sha256:f9c8b6c780c9419ea01043c63a437d79db2c33353451347894408184ad9c3e1e", size = 24718 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/75/fb3d4f056f9ed4f8848817d5afd7a1d949632ab117452ccd179e3839cfc4/django_prometheus-2.3.1-py2.py3-none-any.whl", hash = "sha256:cf9b26f7ba2e4568f08f8f91480a2882023f5908579681bcf06a4d2465f12168", size = 29081 },
+]
+
+[[package]]
+name = "djangorestframework"
+version = "3.15.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235 },
+]
+
+[[package]]
+name = "drf-spectacular"
+version = "0.26.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "djangorestframework" },
+ { name = "inflection" },
+ { name = "jsonschema" },
+ { name = "pyyaml" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/8b/cf2b8770d1eb563bb91b566c8494b2d5fdeac5991ddd7e654eb21f3671f7/drf-spectacular-0.26.2.tar.gz", hash = "sha256:005623d6bb9de37d2d0ec24ccd59c636e4a42f9af252f1470129ac32ccab38cb", size = 216281 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/6c/064c318c78a9ac36c802bd284612a77721301c8d2866fb10a35194d9cb28/drf_spectacular-0.26.2-py3-none-any.whl", hash = "sha256:e80eba58d9579bf6c3380ffd6d6a9b466c4bc35b23da0ba76dfcc96de1e907d7", size = 92913 },
+]
+
+[[package]]
+name = "drf-spectacular-sidecar"
+version = "2023.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/91/054e90af66573d2795480811b0766b253c68c26978a214251069af9f88bd/drf-spectacular-sidecar-2023.3.1.tar.gz", hash = "sha256:ceb78fd59971bb79e90de38bf89afa50a60b043953f72b3cdeb4ca6a34623f92", size = 2472749 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/b2/5f3e768a183e0927b893b8010b02798c6d3cdc8d8ae40a480fbe5367bfb4/drf_spectacular_sidecar-2023.3.1-py3-none-any.whl", hash = "sha256:2b5ea98d976a4ba023d03cd9dfc2506892e0f26e2ba2869b58ddf344ab69f40f", size = 2494623 },
+]
+
+[[package]]
+name = "factory-boy"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "faker" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/37/b046abce36a5fc19827e3662fd6c63ae3489b93f96dbd099cd412bbff6c3/factory_boy-3.2.0.tar.gz", hash = "sha256:401cc00ff339a022f84d64a4339503d1689e8263a4478d876e58a3295b155c5b", size = 153844 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/62/41a5a7ed8a474072d491521562ffbeb18a869c090c21506f890298433fab/factory_boy-3.2.0-py2.py3-none-any.whl", hash = "sha256:1d3db4b44b8c8c54cdd8b83ae4bdb9aeb121e464400035f1f03ae0e1eade56a4", size = 35313 },
+]
+
+[[package]]
+name = "faker"
+version = "4.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "text-unidecode" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/1f/c7d5429347075b799576b9509926ff0fd72cffae2c34d1191ee8342c7b68/Faker-4.1.3.tar.gz", hash = "sha256:075a95ac4c95765370919d787dcd958acfaea635005ad5af4d926cb0973800db", size = 1000270 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/3c/4fc4a53a24c0ae040616815eb18e73b00832d2eb9275da3837c8345c68a6/Faker-4.1.3-py3-none-any.whl", hash = "sha256:80bab8d46035a7393de827210c5d39c17109d3346d131946bde622137120c496", size = 1039477 },
+]
+
+[[package]]
+name = "fakeredis"
+version = "2.10.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "redis" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/16/981975de0d325255f9b05b6244a53e7a95b19c5104f09315068039263efc/fakeredis-2.10.3.tar.gz", hash = "sha256:c5dcb070ef3219226e1d6db8836ddad47da1fc821270f6e89cfeb5da1f7f2e38", size = 94025 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/7f/e4fdf244f46f08f2846334867dc2304ace61c768863283a503721bae3f67/fakeredis-2.10.3-py3-none-any.whl", hash = "sha256:078ad729fe7cbcc84c9ff6f25c0e503fd4e19db6956f78049f9991b10c5271ba", size = 57471 },
+]
+
+[[package]]
+name = "filelock"
+version = "3.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 },
+]
+
+[[package]]
+name = "freezegun"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 },
+]
+
+[[package]]
+name = "google-api-core"
+version = "2.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "googleapis-common-protos" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fa/6b/b98553c2061c4e2186f5bbfb1aa1a6ef13fc0775c096d18595d3c99ba023/google_api_core-2.23.0.tar.gz", hash = "sha256:2ceb087315e6af43f256704b871d99326b1f12a9d6ce99beaedec99ba26a0ace", size = 160094 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/a4/c26886d57d90032c5f74c2e80aefdc38ec58551fc46bd4ce79fb2c9389fa/google_api_core-2.23.0-py3-none-any.whl", hash = "sha256:c20100d4c4c41070cf365f1d8ddf5365915291b5eb11b83829fbd1c999b5122f", size = 156554 },
+]
+
+[package.optional-dependencies]
+grpc = [
+ { name = "grpcio" },
+ { name = "grpcio-status" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.36.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachetools" },
+ { name = "pyasn1-modules" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/71/4c5387d8a3e46e3526a8190ae396659484377a73b33030614dd3b28e7ded/google_auth-2.36.0.tar.gz", hash = "sha256:545e9618f2df0bcbb7dcbc45a546485b1212624716975a1ea5ae8149ce769ab1", size = 268336 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/9a/3d5087d27865c2f0431b942b5c4500b7d1b744dd3262fdc973a4c39d099e/google_auth-2.36.0-py2.py3-none-any.whl", hash = "sha256:51a15d47028b66fd36e5c64a82d2d57480075bccc7da37cde257fc94177a61fb", size = 209519 },
+]
+
+[[package]]
+name = "google-cloud-core"
+version = "2.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8d/96/16cc0a34f75899ace6a42bb4ef242ac4aa263089b018d1c18c007d1fd8f2/google_cloud_core-2.4.2.tar.gz", hash = "sha256:a4fcb0e2fcfd4bfe963837fad6d10943754fd79c1a50097d68540b6eb3d67f35", size = 35854 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/0f/76e813cee7568ac467d929f4f0da7ab349596e7fc4ee837b990611e07d99/google_cloud_core-2.4.2-py2.py3-none-any.whl", hash = "sha256:7459c3e83de7cb8b9ecfec9babc910efb4314030c56dd798eaad12c426f7d180", size = 29343 },
+]
+
+[[package]]
+name = "google-cloud-pubsub"
+version = "2.18.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "grpc-google-iam-v1" },
+ { name = "grpcio" },
+ { name = "grpcio-status" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/21/c934b995208027434c15df104e342b41407bb7aeb23b19d4f73bc93a1961/google-cloud-pubsub-2.18.4.tar.gz", hash = "sha256:32eb61fd4c1dc6c842f594d69d9afa80544e3b327aa640a164eb6fb0201eaf2d", size = 309745 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/58/fbdedd07206514f09f029458d95d391829b09399c1459d8892e6e20022d1/google_cloud_pubsub-2.18.4-py2.py3-none-any.whl", hash = "sha256:f32144ad9ed32331a80a2f8379a3ca7526bbc01e7bd76de2e8ab52e492d21f50", size = 265939 },
+]
+
+[[package]]
+name = "google-cloud-storage"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+ { name = "google-cloud-core" },
+ { name = "google-crc32c" },
+ { name = "google-resumable-media" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7f/d7/dfa74049c4faa3b4d68fa1a10a7eab5a76c57d0788b47c27f927bedc606d/google_cloud_storage-3.0.0.tar.gz", hash = "sha256:2accb3e828e584888beff1165e5f3ac61aa9088965eb0165794a82d8c7f95297", size = 7665253 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/ae/1a50f07161301e40a30b2e40744a7b85ffab7add16e044417925eccf9bbf/google_cloud_storage-3.0.0-py2.py3-none-any.whl", hash = "sha256:f85fd059650d2dbb0ac158a9a6b304b66143b35ed2419afec2905ca522eb2c6a", size = 173860 },
+]
+
+[[package]]
+name = "google-crc32c"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/67/72/c3298da1a3773102359c5a78f20dae8925f5ea876e37354415f68594a6fb/google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc", size = 14472 }
+
+[[package]]
+name = "google-resumable-media"
+version = "2.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-crc32c" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251 },
+]
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.59.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/9b/ea531afe585da044686ab13351c99dfbb2ca02b96c396874946d52d0e127/googleapis-common-protos-1.59.1.tar.gz", hash = "sha256:b35d530fe825fb4227857bc47ad84c33c809ac96f312e13182bdeaa2abe1178a", size = 118520 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/b7/bbaa556e9ff0580f408c64ccf4db0c1414eec79e7151d33a10bc209ffb6d/googleapis_common_protos-1.59.1-py2.py3-none-any.whl", hash = "sha256:0cbedb6fb68f1c07e18eb4c48256320777707e7d0c55063ae56c15db3224a61e", size = 224487 },
+]
+
+[package.optional-dependencies]
+grpc = [
+ { name = "grpcio" },
+]
+
+[[package]]
+name = "graphql-core"
+version = "3.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/a6/94df9045ca1bac404c7b394094cd06713f63f49c7a4d54d99b773ae81737/graphql-core-3.2.3.tar.gz", hash = "sha256:06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676", size = 529552 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/39/e5143e7ec70939d2076c1165ae9d4a3815597019c4d797b7f959cf778600/graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3", size = 202921 },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 },
+ { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 },
+ { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 },
+ { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 },
+ { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 },
+ { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 },
+ { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 },
+ { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 },
+ { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 },
+ { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 },
+ { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 },
+ { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 },
+ { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 },
+ { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 },
+ { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 },
+ { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 },
+]
+
+[[package]]
+name = "grpc-google-iam-v1"
+version = "0.12.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos", extra = ["grpc"] },
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/92/aee864f03f47c672a31d128e49981b01ca629d81541dcc9904c652dbab5b/grpc-google-iam-v1-0.12.6.tar.gz", hash = "sha256:2bc4b8fdf22115a65d751c9317329322602c39b7c86a289c9b72d228d960ef5f", size = 17760 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/72/c84e54991d452942c5a85474693c8433169104a596e9dd23b05c5f091894/grpc_google_iam_v1-0.12.6-py2.py3-none-any.whl", hash = "sha256:5c10f3d8dc2d88678ab1a9b0cb5482735c5efee71e6c0cd59f872eef22913f5c", size = 26266 },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.70.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/e1/4b21b5017c33f3600dcc32b802bb48fe44a4d36d6c066f52650c7c2690fa/grpcio-1.70.0.tar.gz", hash = "sha256:8d1584a68d5922330025881e63a6c1b54cc8117291d382e4fa69339b6d914c56", size = 12788932 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/38/66d0f32f88feaf7d83f8559cd87d899c970f91b1b8a8819b58226de0a496/grpcio-1.70.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa573896aeb7d7ce10b1fa425ba263e8dddd83d71530d1322fd3a16f31257b4a", size = 5199218 },
+ { url = "https://files.pythonhosted.org/packages/c1/96/947df763a0b18efb5cc6c2ae348e56d97ca520dc5300c01617b234410173/grpcio-1.70.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:d405b005018fd516c9ac529f4b4122342f60ec1cee181788249372524e6db429", size = 11445983 },
+ { url = "https://files.pythonhosted.org/packages/fd/5b/f3d4b063e51b2454bedb828e41f3485800889a3609c49e60f2296cc8b8e5/grpcio-1.70.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f32090238b720eb585248654db8e3afc87b48d26ac423c8dde8334a232ff53c9", size = 5663954 },
+ { url = "https://files.pythonhosted.org/packages/bd/0b/dab54365fcedf63e9f358c1431885478e77d6f190d65668936b12dd38057/grpcio-1.70.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa089a734f24ee5f6880c83d043e4f46bf812fcea5181dcb3a572db1e79e01c", size = 6304323 },
+ { url = "https://files.pythonhosted.org/packages/76/a8/8f965a7171ddd336ce32946e22954aa1bbc6f23f095e15dadaa70604ba20/grpcio-1.70.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f19375f0300b96c0117aca118d400e76fede6db6e91f3c34b7b035822e06c35f", size = 5910939 },
+ { url = "https://files.pythonhosted.org/packages/1b/05/0bbf68be8b17d1ed6f178435a3c0c12e665a1e6054470a64ce3cb7896596/grpcio-1.70.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:7c73c42102e4a5ec76608d9b60227d917cea46dff4d11d372f64cbeb56d259d0", size = 6631405 },
+ { url = "https://files.pythonhosted.org/packages/79/6a/5df64b6df405a1ed1482cb6c10044b06ec47fd28e87c2232dbcf435ecb33/grpcio-1.70.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0a5c78d5198a1f0aa60006cd6eb1c912b4a1520b6a3968e677dbcba215fabb40", size = 6190982 },
+ { url = "https://files.pythonhosted.org/packages/42/aa/aeaac87737e6d25d1048c53b8ec408c056d3ed0c922e7c5efad65384250c/grpcio-1.70.0-cp313-cp313-win32.whl", hash = "sha256:fe9dbd916df3b60e865258a8c72ac98f3ac9e2a9542dcb72b7a34d236242a5ce", size = 3598359 },
+ { url = "https://files.pythonhosted.org/packages/1f/79/8edd2442d2de1431b4a3de84ef91c37002f12de0f9b577fb07b452989dbc/grpcio-1.70.0-cp313-cp313-win_amd64.whl", hash = "sha256:4119fed8abb7ff6c32e3d2255301e59c316c22d31ab812b3fbcbaf3d0d87cc68", size = 4293938 },
+]
+
+[[package]]
+name = "grpcio-status"
+version = "1.58.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos" },
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/75/4126cf49ecb4991de5e90f3801151eb24a1dcf37cf30720324f19a081e9e/grpcio-status-1.58.0.tar.gz", hash = "sha256:0b42e70c0405a66a82d9e9867fa255fe59e618964a6099b20568c31dd9099766", size = 13524 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/48/2bcf11bc2df159564eac099ea38d80663d291a56fa2f2f561d08bf083dfa/grpcio_status-1.58.0-py3-none-any.whl", hash = "sha256:36d46072b71a00147709ebce49344ac59b4b8960942acf0f813a8a7d6c1c28e0", size = 14444 },
+]
+
+[[package]]
+name = "gunicorn"
+version = "22.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1e/88/e2f93c5738a4c1f56a458fc7a5b1676fc31dcdbb182bef6b40a141c17d66/gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63", size = 3639760 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/97/6d610ae77b5633d24b69c2ff1ac3044e0e565ecbd1ec188f02c45073054c/gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", size = 84443 },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "identify"
+version = "2.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/d5/f31f5b47b4ea076644e1311b89b9b0efe2f989085151080d72aa5d4b31e2/identify-2.2.2.tar.gz", hash = "sha256:43cb1965e84cdd247e875dec6d13332ef5be355ddc16776396d98089b9053d87", size = 98841 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/35/fac586ad912cb341d159b29ee8961dc6de54986e8e4d01eb728bdb2ce1d3/identify-2.2.2-py2.py3-none-any.whl", hash = "sha256:c7c0f590526008911ccc5ceee6ed7b085cbc92f7b6591d0ee5913a130ad64034", size = 98095 },
+]
+
+[[package]]
+name = "idna"
+version = "3.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836 },
+]
+
+[[package]]
+name = "ijson"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/83/28e9e93a3a61913e334e3a2e78ea9924bb9f9b1ac45898977f9d9dd6133f/ijson-3.3.0.tar.gz", hash = "sha256:7f172e6ba1bee0d4c8f8ebd639577bfe429dee0f3f96775a067b8bae4492d8a0", size = 60079 }
+
+[[package]]
+name = "inflection"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32", size = 8104 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", size = 4990 },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "pyrsistent" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/35/ee/889aee424a43066a06ac68e499335877775eac9b4409f7860f6af94c6688/jsonschema-4.14.0.tar.gz", hash = "sha256:15062f4cc6f591400cd528d2c355f2cfa6a57e44c820dc783aee5e23d36a831f", size = 288641 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/fe/e45dbe1cff8bbc7c84845ac4cd7c1380393d8479eb68b2392ed2ed01e1bd/jsonschema-4.14.0-py3-none-any.whl", hash = "sha256:9892b8d630a82990521a9ca630d3446bd316b5ad54dbe981338802787f3e0d2d", size = 82407 },
+]
+
+[[package]]
+name = "kombu"
+version = "5.3.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "amqp" },
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/d9/86edccdc0f6868936deddf5892d6687481dcf047727f73214e61d31b2515/kombu-5.3.6.tar.gz", hash = "sha256:f3da5b570a147a5da8280180aa80b03807283d63ea5081fcdb510d18242431d9", size = 439311 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/e8/bade4ea794047c94bb267c08aad9d5270c803c18296e876b5d97bad28daf/kombu-5.3.6-py3-none-any.whl", hash = "sha256:49f1e62b12369045de2662f62cc584e7df83481a513db83b01f87b5b9785e378", size = 200188 },
+]
+
+[[package]]
+name = "minio"
+version = "7.1.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/25/6764ec7542c6f1537334feaa2c9b1d25f0c7639a105d436b1769638f9159/minio-7.1.13.tar.gz", hash = "sha256:8828615a20cde82df79c5a52005252ad29bb022cde25177a4a43952a04c3222c", size = 115571 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/d6/2c5ea778822ece1493cfe784cc48a5cdfdcc88bf59dacbf8509f748e12b1/minio-7.1.13-py3-none-any.whl", hash = "sha256:462aebd79000d5b923b2a728352014f76292bbd81a9d00e3ed25aa6ec4dc41f2", size = 76155 },
+]
+
+[[package]]
+name = "mmh3"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/1b/1fc6888c74cbd8abad1292dde2ddfcf8fc059e114c97dd6bf16d12f36293/mmh3-5.1.0.tar.gz", hash = "sha256:136e1e670500f177f49ec106a4ebf0adf20d18d96990cc36ea492c651d2b406c", size = 33728 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/06/a098a42870db16c0a54a82c56a5bdc873de3165218cd5b3ca59dbc0d31a7/mmh3-5.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a523899ca29cfb8a5239618474a435f3d892b22004b91779fcb83504c0d5b8c", size = 56165 },
+ { url = "https://files.pythonhosted.org/packages/5a/65/eaada79a67fde1f43e1156d9630e2fb70655e1d3f4e8f33d7ffa31eeacfd/mmh3-5.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:17cef2c3a6ca2391ca7171a35ed574b5dab8398163129a3e3a4c05ab85a4ff40", size = 40569 },
+ { url = "https://files.pythonhosted.org/packages/36/7e/2b6c43ed48be583acd68e34d16f19209a9f210e4669421b0321e326d8554/mmh3-5.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52e12895b30110f3d89dae59a888683cc886ed0472dd2eca77497edef6161997", size = 40104 },
+ { url = "https://files.pythonhosted.org/packages/11/2b/1f9e962fdde8e41b0f43d22c8ba719588de8952f9376df7d73a434827590/mmh3-5.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d6719045cda75c3f40397fc24ab67b18e0cb8f69d3429ab4c39763c4c608dd", size = 102497 },
+ { url = "https://files.pythonhosted.org/packages/46/94/d6c5c3465387ba077cccdc028ab3eec0d86eed1eebe60dcf4d15294056be/mmh3-5.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d19fa07d303a91f8858982c37e6939834cb11893cb3ff20e6ee6fa2a7563826a", size = 108834 },
+ { url = "https://files.pythonhosted.org/packages/34/1e/92c212bb81796b69dddfd50a8a8f4b26ab0d38fdaf1d3e8628a67850543b/mmh3-5.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31b47a620d622fbde8ca1ca0435c5d25de0ac57ab507209245e918128e38e676", size = 106936 },
+ { url = "https://files.pythonhosted.org/packages/f4/41/f2f494bbff3aad5ffd2085506255049de76cde51ddac84058e32768acc79/mmh3-5.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00f810647c22c179b6821079f7aa306d51953ac893587ee09cf1afb35adf87cb", size = 93709 },
+ { url = "https://files.pythonhosted.org/packages/9e/a9/a2cc4a756d73d9edf4fb85c76e16fd56b0300f8120fd760c76b28f457730/mmh3-5.1.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6128b610b577eed1e89ac7177ab0c33d06ade2aba93f5c89306032306b5f1c6", size = 101623 },
+ { url = "https://files.pythonhosted.org/packages/5e/6f/b9d735533b6a56b2d56333ff89be6a55ac08ba7ff33465feb131992e33eb/mmh3-5.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1e550a45d2ff87a1c11b42015107f1778c93f4c6f8e731bf1b8fa770321b8cc4", size = 98521 },
+ { url = "https://files.pythonhosted.org/packages/99/47/dff2b54fac0d421c1e6ecbd2d9c85b2d0e6f6ee0d10b115d9364116a511e/mmh3-5.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:785ae09276342f79fd8092633e2d52c0f7c44d56e8cfda8274ccc9b76612dba2", size = 96696 },
+ { url = "https://files.pythonhosted.org/packages/be/43/9e205310f47c43ddf1575bb3a1769c36688f30f1ac105e0f0c878a29d2cd/mmh3-5.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f4be3703a867ef976434afd3661a33884abe73ceb4ee436cac49d3b4c2aaa7b", size = 105234 },
+ { url = "https://files.pythonhosted.org/packages/6b/44/90b11fd2b67dcb513f5bfe9b476eb6ca2d5a221c79b49884dc859100905e/mmh3-5.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e513983830c4ff1f205ab97152a0050cf7164f1b4783d702256d39c637b9d107", size = 98449 },
+ { url = "https://files.pythonhosted.org/packages/f0/d0/25c4b0c7b8e49836541059b28e034a4cccd0936202800d43a1cc48495ecb/mmh3-5.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9135c300535c828c0bae311b659f33a31c941572eae278568d1a953c4a57b59", size = 97796 },
+ { url = "https://files.pythonhosted.org/packages/23/fa/cbbb7fcd0e287a715f1cd28a10de94c0535bd94164e38b852abc18da28c6/mmh3-5.1.0-cp313-cp313-win32.whl", hash = "sha256:c65dbd12885a5598b70140d24de5839551af5a99b29f9804bb2484b29ef07692", size = 40828 },
+ { url = "https://files.pythonhosted.org/packages/09/33/9fb90ef822f7b734955a63851907cf72f8a3f9d8eb3c5706bfa6772a2a77/mmh3-5.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:10db7765201fc65003fa998faa067417ef6283eb5f9bba8f323c48fd9c33e91f", size = 41504 },
+ { url = "https://files.pythonhosted.org/packages/16/71/4ad9a42f2772793a03cb698f0fc42499f04e6e8d2560ba2f7da0fb059a8e/mmh3-5.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:b22fe2e54be81f6c07dcb36b96fa250fb72effe08aa52fbb83eade6e1e2d5fd7", size = 38890 },
+]
+
+[[package]]
+name = "multidict"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 },
+ { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 },
+ { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 },
+ { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 },
+ { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 },
+ { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 },
+ { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 },
+ { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 },
+ { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 },
+ { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 },
+ { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 },
+ { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 },
+ { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 },
+ { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 },
+ { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/15/d1eb0d2664e57da61622a815efe7a88db68c7a593fb86bd7cc629fc31c76/nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c", size = 33862 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/d0/efdf54539948315cc76e5a66b709212963101d002822c3b54369dbf9b5e0/nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", size = 21388 },
+]
+
+[[package]]
+name = "oauthlib"
+version = "3.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 },
+]
+
+[[package]]
+name = "orjson"
+version = "3.10.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 },
+ { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 },
+ { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 },
+ { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 },
+ { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 },
+ { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 },
+ { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 },
+ { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 },
+ { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 },
+ { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 },
+ { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 },
+ { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 },
+ { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 },
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[package]]
+name = "polars"
+version = "1.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/df/55127a3099e990b45ce3a29ab6789a083451e76e7109fb754aad5525360b/polars-1.12.0.tar.gz", hash = "sha256:fb5c92de1a8f7d0a3f923fe48ea89eb518bdf55315ae917012350fa072bd64f4", size = 4090738 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/ae/77c7ec395d9361ae2086693af1947c9a2b21346ba3faf092bb154b735227/polars-1.12.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8f3c4e4e423c373dda07b4c8a7ff12aa02094b524767d0ca306b1eba67f2d99e", size = 32923786 },
+ { url = "https://files.pythonhosted.org/packages/97/1c/60736d5588309eb528c52538e116593cb275310bab82ba28702cd87a76d1/polars-1.12.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:aa6f9862f0cec6353243920d9b8d858c21ec8f25f91af203dea6ff91980e140d", size = 28887255 },
+ { url = "https://files.pythonhosted.org/packages/5a/3e/31257118e7e087fa27c230b8fadf8ff15d521140bf58558dc889ee0c9c5e/polars-1.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afb03647b5160737d2119532ee8ffe825de1d19d87f81bbbb005131786f7d59b", size = 34126501 },
+ { url = "https://files.pythonhosted.org/packages/ad/e6/d03053e6064d262f2ec41172a5092b08fc20d10c059dda6c9460371cfd7e/polars-1.12.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:ea96aba5eb3dab8f0e6abf05ab3fc2136b329261860ef8661d20f5456a2d78e0", size = 30479546 },
+ { url = "https://files.pythonhosted.org/packages/d5/28/3d44ddf56a5c95272b202ce8aa0e9b818a1310e83525c4c29176b538ae7c/polars-1.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:a228a4b320a36d03a9ec9dfe7241b6d80a2f119b2dceb1da953166655e4cf43c", size = 33790337 },
+]
+
+[[package]]
+name = "pre-commit"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 },
+]
+
+[[package]]
+name = "prometheus-client"
+version = "0.17.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/05/aee33352594522c56eb4a4382b5acd9a706a030db9ba2fc3dc38a283e75c/prometheus_client-0.17.1.tar.gz", hash = "sha256:21e674f39831ae3f8acde238afd9a27a37d0d2fb5a28ea094f0ce25d2cbf2091", size = 90360 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/b3/6e18c89bf6bd120590ea538a62cae16dc763ff2745b18377c4be5495c4aa/prometheus_client-0.17.1-py3-none-any.whl", hash = "sha256:e537f37160f6807b8202a6fc4764cdd19bac5480ddd3e0d463c3002b34462101", size = 60601 },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.28"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/37/34/c34c376882305c5051ed7f086daf07e68563d284015839bfb74d6e61d402/prompt_toolkit-3.0.28.tar.gz", hash = "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650", size = 3057388 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/e4/beb2fad0bec554a011b321d0f6b99981f9171c0b05d03a6948e45ac8a5be/prompt_toolkit-3.0.28-py3-none-any.whl", hash = "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c", size = 380236 },
+]
+
+[[package]]
+name = "proto-plus"
+version = "1.25.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/05/74417b2061e1bf1b82776037cad97094228fa1c1b6e82d08a78d3fb6ddb6/proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91", size = 56124 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/25/0b7cc838ae3d76d46539020ec39fc92bfc9acc29367e58fe912702c2a79e/proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961", size = 50126 },
+]
+
+[[package]]
+name = "protobuf"
+version = "4.24.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/30/98dc7297ce8c3f0182800d2879703f0196e76d6a28e53ecaafc3901f8118/protobuf-4.24.3.tar.gz", hash = "sha256:12e9ad2ec079b833176d2921be2cb24281fa591f0b119b208b788adc48c2561d", size = 383885 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/1c/8cf83f294cd8abef37ca2e2251feda7413d8bde7643587e113f847cebb6b/protobuf-4.24.3-cp310-abi3-win32.whl", hash = "sha256:20651f11b6adc70c0f29efbe8f4a94a74caf61b6200472a9aea6e19898f9fcf4", size = 409991 },
+ { url = "https://files.pythonhosted.org/packages/5e/46/5b9674a33cbf690ffdd79ab1863767a66461cd06ea7aeb9f90e4e50be7a5/protobuf-4.24.3-cp310-abi3-win_amd64.whl", hash = "sha256:3d42e9e4796a811478c783ef63dc85b5a104b44aaaca85d4864d5b886e4b05e3", size = 430521 },
+ { url = "https://files.pythonhosted.org/packages/fe/f3/957db80e5b9f7fd7df97e5554fdc57919dfad24e89291223fd04a0e3c84f/protobuf-4.24.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6e514e8af0045be2b56e56ae1bb14f43ce7ffa0f68b1c793670ccbe2c4fc7d2b", size = 409444 },
+ { url = "https://files.pythonhosted.org/packages/90/76/4303d1f01e799ed0243a55ec81fb16a731faed0d7c95eaba809f4c7f408d/protobuf-4.24.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:ba53c2f04798a326774f0e53b9c759eaef4f6a568ea7072ec6629851c8435959", size = 310612 },
+ { url = "https://files.pythonhosted.org/packages/bb/c3/6a06208ecf0934ecaf509b51c52a6cf688586f54ae81ac65c56124571494/protobuf-4.24.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:f6ccbcf027761a2978c1406070c3788f6de4a4b2cc20800cc03d52df716ad675", size = 311586 },
+ { url = "https://files.pythonhosted.org/packages/fa/8a/e9c6b48b8f4651df1b1a9d46fe94a74ed99881141b4660aa855a798c7c53/protobuf-4.24.3-py3-none-any.whl", hash = "sha256:f6f8dc65625dadaad0c8545319c2e2f0424fede988368893ca3844261342c11a", size = 175704 },
+]
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699 },
+ { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245 },
+ { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631 },
+ { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140 },
+ { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762 },
+ { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967 },
+ { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326 },
+ { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712 },
+ { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155 },
+ { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356 },
+ { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 },
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.4.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145 },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.2.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/88/87/72eb9ccf8a58021c542de2588a867dbefc7556e14b2866d1e40e9e2b587e/pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", size = 242864 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/de/214830a981892a3e286c3794f41ae67a4495df1108c3da8a9f62159b9a9d/pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", size = 155269 },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.10.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
+ { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
+ { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
+ { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
+ { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
+ { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
+ { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
+ { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
+ { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
+ { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
+ { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
+ { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
+ { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
+ { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/72/8259b2bccfe4673330cea843ab23f86858a419d8f1493f66d413a76c7e3b/PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", size = 78313 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/4f/e04a8067c7c96c364cef7ef73906504e2f40d690811c021e1a1901473a19/PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320", size = 22591 },
+]
+
+[[package]]
+name = "pyparsing"
+version = "2.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c1/47/dfc9c342c9842bbe0036c7f763d2d6686bcf5eb1808ba3e170afdb282210/pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", size = 649718 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/bb/488841f56197b13700afd5658fc279a2025a39e22449b7cf29864669b15d/pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b", size = 67842 },
+]
+
+[[package]]
+name = "pyrsistent"
+version = "0.18.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/ac/455fdc7294acc4d4154b904e80d964cc9aae75b087bbf486be04df9f2abd/pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96", size = 100522 }
+
+[[package]]
+name = "pytest"
+version = "8.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/b7/7d44bbc04c531dcc753056920e0988032e5871ac674b5a84cb979de6e7af/pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044", size = 1409703 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/7e/c79cecfdb6aa85c6c2e3cf63afc56d0f165f24f5c66c03c695c4d9b84756/pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7", size = 337359 },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.23.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/ef/80107b9e939875ad613c705d99d91e4510dcf5fed29613ac9aecbcba0a8d/pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f", size = 46203 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/c9/de22c040d4c821c6c797ca1d720f1f4b2f4293d5757e811c62ae544496c4/pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a", size = 17552 },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 },
+]
+
+[[package]]
+name = "pytest-django"
+version = "4.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/cf/44510ac5479f281d6663a08dff0d93f56b21f4ee091980ea4d4b64491ad6/pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", size = 83291 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/5b/29555191e903881d05e1f7184205ec534c7021e0ee077d1e6a1ee8f1b1eb/pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7", size = 23432 },
+]
+
+[[package]]
+name = "pytest-insta"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/5b/6ca4baca60c3f8361415501668cde3abd94dbad44293325833fd89d1a7c1/pytest_insta-0.3.0.tar.gz", hash = "sha256:9e6e1c70a021f68ccc4643360b2c2f8326cf3befba85f942c1da17b9caf713f7", size = 14960 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/d5/1459b2861cf703cf49d96b6f29731ee74f4ac7e34b0c60b0ff75bdd318bc/pytest_insta-0.3.0-py3-none-any.whl", hash = "sha256:93a105e3850f2887b120a581923b10bb313d722e00d369377a1d91aa535df704", size = 13660 },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
+]
+
+[[package]]
+name = "python-json-logger"
+version = "2.0.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/da/95963cebfc578dabd323d7263958dfb68898617912bb09327dd30e9c8d13/python-json-logger-2.0.7.tar.gz", hash = "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c", size = 10508 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/a6/145655273568ee78a581e734cf35beb9e33a370b29c5d3c8fee3744de29f/python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd", size = 8067 },
+]
+
+[[package]]
+name = "python-redis-lock"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "redis" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/d7/a2a97c73d39e68aacce02667885b9e0b575eb9082866a04fbf098b4c4d99/python-redis-lock-4.0.0.tar.gz", hash = "sha256:4abd0bcf49136acad66727bf5486dd2494078ca55e49efa693f794077319091a", size = 162533 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/70/c5dfaec2085d9be10792704f108543ba1802e228bf040632c673066d8e78/python_redis_lock-4.0.0-py3-none-any.whl", hash = "sha256:ff786e587569415f31e64ca9337fce47c4206e832776e9e42b83bfb9ee1af4bd", size = 12165 },
+]
+
+[[package]]
+name = "pytz"
+version = "2022.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/5f/a0f653311adff905bbcaa6d3dfaf97edcf4d26138393c6ccd37a484851fb/pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7", size = 320473 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/2e/dec1cc18c51b8df33c7c4d0a321b084cf38e1733b98f9d15018880fb4970/pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c", size = 503520 },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201 }
+
+[[package]]
+name = "redis"
+version = "4.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "async-timeout" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ff/af/f791fa76162d1e7655a34bca58876b43388209912c6a771756fc0c8f96da/redis-4.4.4.tar.gz", hash = "sha256:68226f7ede928db8302f29ab088a157f41061fa946b7ae865452b6d7838bbffb", size = 4549578 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/68/da435219cd46d416bf5b59d66dbe36ae4aaf05b91b7be6e8241a87d4c9b1/redis-4.4.4-py3-none-any.whl", hash = "sha256:da92a39fec86438d3f1e2a1db33c312985806954fe860120b582a8430e231d8f", size = 238045 },
+]
+
+[[package]]
+name = "regex"
+version = "2023.12.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/39/31626e7e75b187fae7f121af3c538a991e725c744ac893cc2cfd70ce2853/regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5", size = 394706 }
+
+[[package]]
+name = "requests"
+version = "2.32.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
+]
+
+[[package]]
+name = "rsa"
+version = "4.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/b5/475c45a58650b0580421746504b680cd2db4e81bc941e94ca53785250269/rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9", size = 39711 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/93/0c0f002031f18b53af7a6166103c02b9c0667be528944137cc954ec921b3/rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2", size = 34505 },
+]
+
+[[package]]
+name = "ruff"
+version = "0.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 },
+ { url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 },
+ { url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 },
+ { url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 },
+ { url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 },
+ { url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 },
+ { url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 },
+ { url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 },
+ { url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 },
+ { url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 },
+ { url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 },
+ { url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 },
+ { url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 },
+ { url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 },
+ { url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 },
+ { url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 },
+ { url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.11.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/24/1390172471d569e281fcfd29b92f2f73774e95972c965d14b6c802ff2352/s3transfer-0.11.3.tar.gz", hash = "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a", size = 148042 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/81/48c41b554a54d75d4407740abb60e3a102ae416284df04d1dbdcbe3dbf24/s3transfer-0.11.3-py3-none-any.whl", hash = "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d", size = 84246 },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/41/97f673384dae5ed81cc2a568cc5c28e76deee85f8ba50def862e86150a5a/sentry_sdk-2.13.0.tar.gz", hash = "sha256:8d4a576f7a98eb2fdb40e13106e41f330e5c79d72a68be1316e7852cf4995260", size = 279937 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/7e/e9ca09f24a6c334286631a2d32c267cdc5edad5ac03fd9d20a01a82f1c35/sentry_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:6beede8fc2ab4043da7f69d95534e320944690680dd9a963178a49de71d726c6", size = 309078 },
+]
+
+[package.optional-dependencies]
+celery = [
+ { name = "celery" },
+]
+
+[[package]]
+name = "shared"
+version = "0.1.0"
+source = { directory = "../../libs/shared" }
+dependencies = [
+ { name = "amplitude-analytics" },
+ { name = "boto3" },
+ { name = "cachetools" },
+ { name = "cerberus" },
+ { name = "codecov-ribs" },
+ { name = "colour" },
+ { name = "cryptography" },
+ { name = "django" },
+ { name = "django-better-admin-arrayfield" },
+ { name = "django-model-utils" },
+ { name = "django-postgres-extra" },
+ { name = "django-prometheus" },
+ { name = "google-auth" },
+ { name = "google-cloud-pubsub" },
+ { name = "google-cloud-storage" },
+ { name = "httpx" },
+ { name = "ijson" },
+ { name = "minio" },
+ { name = "mmh3" },
+ { name = "oauthlib" },
+ { name = "orjson" },
+ { name = "prometheus-client" },
+ { name = "pydantic" },
+ { name = "pyjwt" },
+ { name = "pyparsing" },
+ { name = "python-redis-lock" },
+ { name = "pyyaml" },
+ { name = "redis" },
+ { name = "requests" },
+ { name = "sentry-sdk" },
+ { name = "sqlalchemy" },
+ { name = "zstandard" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "amplitude-analytics", specifier = ">=1.1.4" },
+ { name = "boto3", specifier = ">=1.20.25" },
+ { name = "cachetools", specifier = ">=4.1.1" },
+ { name = "cerberus", specifier = ">=1.3.5" },
+ { name = "codecov-ribs", specifier = ">=0.1.18" },
+ { name = "colour", specifier = ">=0.1.5" },
+ { name = "cryptography", specifier = ">=43.0.1" },
+ { name = "django", specifier = "<5" },
+ { name = "django-better-admin-arrayfield", specifier = ">=1.4.2" },
+ { name = "django-model-utils", specifier = ">=4.5.1" },
+ { name = "django-postgres-extra", specifier = ">=2.0.8" },
+ { name = "django-prometheus", specifier = ">=2.3.1" },
+ { name = "google-auth", specifier = ">=2.21.0" },
+ { name = "google-cloud-pubsub", specifier = ">=2.18.4" },
+ { name = "google-cloud-storage", specifier = ">=2.18.2" },
+ { name = "httpx", specifier = ">=0.23.0" },
+ { name = "ijson", specifier = ">=3.2.3" },
+ { name = "minio", specifier = ">=7.1.13" },
+ { name = "mmh3", specifier = ">=4.0.1" },
+ { name = "oauthlib", specifier = ">=3.1.0" },
+ { name = "orjson", specifier = ">=3.10.9" },
+ { name = "prometheus-client", specifier = ">=0.17.1" },
+ { name = "pydantic", specifier = ">=2.10.4" },
+ { name = "pyjwt", specifier = ">=2.8.0" },
+ { name = "pyparsing", specifier = ">=2.4.7" },
+ { name = "python-redis-lock", specifier = ">=4.0.0" },
+ { name = "pyyaml", specifier = ">=6.0.1" },
+ { name = "redis", specifier = ">=4.4.4" },
+ { name = "requests", specifier = ">=2.32.3" },
+ { name = "sentry-sdk", specifier = ">=2.13.0" },
+ { name = "sqlalchemy", specifier = "<2" },
+ { name = "zstandard", specifier = ">=0.23.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "factory-boy", specifier = ">=3.2.0" },
+ { name = "freezegun", specifier = ">=1.1.0" },
+ { name = "mock", specifier = ">=4.0.3" },
+ { name = "mypy", specifier = ">=1.13.0" },
+ { name = "pre-commit", specifier = ">=2.11.1" },
+ { name = "psycopg2-binary", specifier = ">=2.9.2" },
+ { name = "pytest", specifier = ">=8.1.1" },
+ { name = "pytest-asyncio", specifier = ">=0.14.0" },
+ { name = "pytest-codspeed", specifier = ">=3.2.0" },
+ { name = "pytest-cov", specifier = ">=5.0.0" },
+ { name = "pytest-django", specifier = ">=4.7.0" },
+ { name = "pytest-mock", specifier = ">=1.13.0" },
+ { name = "respx", specifier = ">=0.20.2" },
+ { name = "ruff", specifier = ">=0.9.0" },
+ { name = "types-mock", specifier = ">=5.1.0.20240425" },
+ { name = "types-requests", specifier = ">=2.31.0.6" },
+ { name = "urllib3", specifier = "==1.26.19" },
+ { name = "vcrpy", specifier = ">=4.1.1" },
+]
+
+[[package]]
+name = "simplejson"
+version = "3.17.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/45/a16db4f0fa383aaf0676fb7e3c660304fe390415c243f41a77c7f917d59b/simplejson-3.17.2.tar.gz", hash = "sha256:75ecc79f26d99222a084fbdd1ce5aad3ac3a8bd535cd9059528452da38b68841", size = 83210 }
+
+[[package]]
+name = "six"
+version = "1.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/44ed7978bcb1f6337a3e2bef19c941de750d73243fc9389140d62853b686/sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de", size = 17132 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/b0/7b2e028b63d092804b6794595871f936aafa5e9322dcaaad50ebf67445b3/sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", size = 10033 },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "1.4.54"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350 }
+
+[[package]]
+name = "sqlparse"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/50/26/5da251cd090ccd580f5cfaa7d36cdd8b2471e49fffce60ed520afc27f4bc/sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", size = 83475 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/5d/a0fdd88fd486b39ae1fd1a75ff75b4e29a0df96c0304d462fd407b82efe0/sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663", size = 43971 },
+]
+
+[[package]]
+name = "starlette"
+version = "0.40.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/244daf0d7be4508099ad5bca3cdfe8b8b5538acd719c5f397f614e569fff/starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35", size = 2573611 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/0f/64baf7a06492e8c12f5c4b49db286787a7255195df496fc21f5fd9eecffa/starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4", size = 73303 },
+]
+
+[[package]]
+name = "stripe"
+version = "11.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/84/b72eeda51c6033e6a88c71de74de56e3a529a60728159e200b42220b21a9/stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a", size = 1379118 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/a0/e416502214c2da3f78015956127e6e44ef789cfcfa7a78f6e564fc05d2c6/stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce", size = 1627969 },
+]
+
+[[package]]
+name = "text-unidecode"
+version = "1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+]
+
+[[package]]
+name = "tzdata"
+version = "2024.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370 },
+]
+
+[[package]]
+name = "uritemplate"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d2/5a/4742fdba39cd02a56226815abfa72fe0aa81c33bed16ed045647d6000eba/uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", size = 273898 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c0/7461b49cd25aeece13766f02ee576d1db528f1c37ce69aee300e075b485b/uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e", size = 10356 },
+]
+
+[[package]]
+name = "urllib3"
+version = "1.26.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c8/93/65e479b023bbc46dab3e092bda6b0005424ea3217d711964ccdede3f9b1b/urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429", size = 306068 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/6a/99eaaeae8becaa17a29aeb334a18e5d582d873b6f084c11f02581b8d7f7f/urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3", size = 143933 },
+]
+
+[[package]]
+name = "vcrpy"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "urllib3", marker = "platform_python_implementation == 'PyPy'" },
+ { name = "wrapt" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/59/9fe85bf7af469bdb0ab8416c76cde630cdff6d1790ecb87e5a58f259c89c/vcrpy-6.0.1.tar.gz", hash = "sha256:9e023fee7f892baa0bbda2f7da7c8ac51165c1c6e38ff8688683a12a4bde9278", size = 84836 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/eb/922cfd27d6593363c3e50b7808bcc234ec996128813fd34341685bb307b7/vcrpy-6.0.1-py2.py3-none-any.whl", hash = "sha256:621c3fb2d6bd8aa9f87532c688e4575bcbbde0c0afeb5ebdb7e14cac409edfdd", size = 41880 },
+]
+
+[[package]]
+name = "vine"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 },
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.29.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/88/dacc875dd54a8acadb4bcbfd4e3e86df8be75527116c91d8f9784f5e9cab/virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728", size = 4320272 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/fa/849483d56773ae29740ae70043ad88e068f98a6401aa819b5d6bee604683/virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a", size = 4301478 },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/38/459b727c381504f361832b9e5ace19966de1a235d73cdbdea91c771a1155/wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83", size = 34755 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/7c/e39aca596badaf1b78e8f547c807b04dae603a433d3e7a7e04d67f2ef3e5/wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", size = 30763 },
+]
+
+[[package]]
+name = "whitenoise"
+version = "5.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/8a/cd6346ffd78f5ff9aa9ce750f3e8d75cde7c60fbe197ac10bdea49d61cff/whitenoise-5.2.0.tar.gz", hash = "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7", size = 45096 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/83/5d91949e370e52578a99ef6391c3b3e19f9fd1f5b4f58d5cbd6e2862d4a8/whitenoise-5.2.0-py2.py3-none-any.whl", hash = "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d", size = 19775 },
+]
+
+[[package]]
+name = "wrapt"
+version = "1.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 },
+]
+
+[[package]]
+name = "yarl"
+version = "1.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/ad/bedcdccbcbf91363fd425a948994f3340924145c2bc8ccb296f4a1e52c28/yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", size = 141869 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/05/4d79198ae568a92159de0f89e710a8d19e3fa267b719a236582eee921f4a/yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", size = 31638 },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation == 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975 },
+ { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448 },
+ { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269 },
+ { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228 },
+ { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891 },
+ { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310 },
+ { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912 },
+ { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946 },
+ { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994 },
+ { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681 },
+ { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239 },
+ { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149 },
+ { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392 },
+ { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299 },
+ { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862 },
+ { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578 },
+]
diff --git a/apps/codecov-api/validate/__init__.py b/apps/codecov-api/validate/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/validate/tests/test_validate.py b/apps/codecov-api/validate/tests/test_validate.py
new file mode 100644
index 0000000000..acca2f22c2
--- /dev/null
+++ b/apps/codecov-api/validate/tests/test_validate.py
@@ -0,0 +1,100 @@
+from json import dumps
+from unittest.mock import patch
+
+from django.conf import settings
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from yaml import YAMLError
+
+
+class TestValidateYamlHandler(APITestCase):
+ # Wrap get and post client calls
+
+ def _get(self):
+ return self.client.get(reverse("validate-yaml"))
+
+ def _post(self, data=None):
+ return self.client.post(reverse("validate-yaml"), data=data, format="json")
+
+ # Unit tests
+
+ def test_get(self):
+ response = self._get()
+
+ assert response.status_code == status.HTTP_200_OK
+
+ expected_result = f"Usage:\n\ncurl -X POST --data-binary @codecov.yml {settings.CODECOV_URL}/validate\n"
+ assert response.content.decode() == expected_result
+
+ def test_post_no_data(self):
+ response = self._post()
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ expected_result = "No content posted."
+ assert response.content.decode() == expected_result
+
+ @patch("validate.views.safe_load")
+ def test_post_malformed_yaml(self, mock_safe_load):
+ mock_safe_load.side_effect = YAMLError("Can't parse YAML")
+
+ response = self._post(data="malformed yaml")
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ expected_result = "Can't parse YAML\n"
+ assert response.content.decode() == expected_result
+
+ def test_post_valid_yaml(self):
+ yaml = {
+ "ignore": ["Pods/.*"],
+ "coverage": {
+ "round": "down",
+ "precision": 2,
+ "range": [70.0, 100.0],
+ "status": {"project": {"default": {"base": "auto"}}},
+ "notify": {
+ "slack": {
+ "default": {
+ "url": "secret:c/nCgqn5v1HY5VFIs9i4W3UY6eleB2rTBdBKK/ilhPR7Ch4N0FE1aO6SRfAxp3Zlm4tLNusaPY7ettH6dTYj/YhiRohxiNqJMJ4L9YQmESo="
+ }
+ }
+ },
+ },
+ }
+ response = self._post(data=yaml)
+
+ assert response.status_code == status.HTTP_200_OK
+ expected_result = f"Valid!\n\n{dumps(yaml, indent=2)}\n"
+ assert response.content.decode() == expected_result
+
+ def test_post_invalid_yaml(self):
+ yaml = {
+ "ignore": ["Pods/.*"],
+ "coverage": {
+ "round": "down",
+ "precision": 2,
+ "range": [70.0, 100.0],
+ "status": {"project": {"default": {"base": "auto"}}, "patch": "nope"},
+ },
+ }
+
+ response = self._post(data=yaml)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ expected_result = "Error at ['coverage', 'status', 'patch']: \nmust be of ['dict', 'boolean'] type\n"
+ assert response.content.decode() == expected_result
+
+ def test_request_body_not_parsable_as_dict(self):
+ # String
+ response = self._post(data="codecov.yml")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ expected_result = "No file posted."
+ assert response.content.decode() == expected_result
+
+ # Number
+ response = self._post(data=123)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ expected_result = "No file posted."
+ assert response.content.decode() == expected_result
diff --git a/apps/codecov-api/validate/tests/test_validate_v2.py b/apps/codecov-api/validate/tests/test_validate_v2.py
new file mode 100644
index 0000000000..d1dbd22fa9
--- /dev/null
+++ b/apps/codecov-api/validate/tests/test_validate_v2.py
@@ -0,0 +1,73 @@
+from unittest.mock import patch
+
+from django.test import Client, TestCase
+from rest_framework.reverse import reverse
+
+
+@patch("validate.views.API_VALIDATE_V2_COUNTER.labels")
+class TestValidateYamlV2Handler(TestCase):
+ def _post(self, data, query_source=""):
+ client = Client()
+ if query_source:
+ query_source = f"?source={query_source}"
+ return client.post(
+ reverse("validate-yaml-v2") + query_source,
+ data=data,
+ content_type="text/plain",
+ )
+
+ def test_no_data(self, mock_metrics):
+ res = self._post("")
+ assert res.status_code == 400
+ assert res.json() == {"valid": False, "message": "YAML is empty"}
+ mock_metrics.assert_called_once_with(**{"source": "unknown"})
+
+ def test_list_type(self, mock_metrics):
+ res = self._post("- testing: 123")
+ assert res.status_code == 400
+ assert res.json() == {
+ "valid": False,
+ "message": "YAML must be a dictionary type",
+ }
+ mock_metrics.assert_called_once_with(**{"source": "unknown"})
+
+ def test_parse_error(self, mock_metrics):
+ res = self._post("foo: - 123")
+ assert res.status_code == 400
+ assert res.json() == {
+ "valid": False,
+ "message": "YAML could not be parsed",
+ "parse_error": {
+ "line": 1,
+ "column": 6,
+ "problem": "sequence entries are not allowed here",
+ },
+ }
+ mock_metrics.assert_called_once_with(**{"source": "unknown"})
+
+ def test_parse_invalid(self, mock_metrics):
+ res = self._post("comment: 123")
+ assert res.status_code == 400
+ assert res.json() == {
+ "valid": False,
+ "message": "YAML does not match the accepted schema",
+ "validation_error": {"comment": ["must be of ['dict', 'boolean'] type"]},
+ }
+ mock_metrics.assert_called_once_with(**{"source": "unknown"})
+
+ def test_parse_valid(self, mock_metrics):
+ res = self._post("comment: true")
+ assert res.status_code == 200
+ assert res.json() == {
+ "valid": True,
+ "message": "YAML is valid",
+ "validated_yaml": {
+ "comment": True,
+ },
+ }
+ mock_metrics.assert_called_once_with(**{"source": "unknown"})
+
+ def test_query_source_metric(self, mock_metrics):
+ self._post("comment: true", query_source="vscode")
+ mock_metrics.assert_called()
+ mock_metrics.assert_called_with(**{"source": "vscode"})
diff --git a/apps/codecov-api/validate/urls.py b/apps/codecov-api/validate/urls.py
new file mode 100644
index 0000000000..08ca6a1e57
--- /dev/null
+++ b/apps/codecov-api/validate/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from .views import V1ValidateYamlHandler, V2ValidateYamlHandler
+
+urlpatterns = [
+ path("", V1ValidateYamlHandler.as_view(), name="validate-yaml"),
+ path("v1", V1ValidateYamlHandler.as_view(), name="validate-yaml-v1"),
+ path("v2", V2ValidateYamlHandler.as_view(), name="validate-yaml-v2"),
+]
diff --git a/apps/codecov-api/validate/views.py b/apps/codecov-api/validate/views.py
new file mode 100644
index 0000000000..5c3fe6aae2
--- /dev/null
+++ b/apps/codecov-api/validate/views.py
@@ -0,0 +1,154 @@
+import logging
+from json import dumps
+from typing import Any
+
+from django.conf import settings
+from django.http import HttpRequest, HttpResponse
+from rest_framework import status
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.metrics import Counter, inc_counter
+from shared.validation.exceptions import InvalidYamlException
+from shared.yaml.validation import validate_yaml
+from yaml import YAMLError, safe_load
+
+log = logging.getLogger(__name__)
+
+API_VALIDATE_V2_COUNTER = Counter(
+ "api_validate_v2",
+ "Number of times the validate v2 endpoint has been hit",
+)
+
+
+class V1ValidateYamlHandler(APIView):
+ permission_classes = [AllowAny]
+
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return HttpResponse(
+ f"Usage:\n\ncurl -X POST --data-binary @codecov.yml {settings.CODECOV_URL}/validate\n",
+ status=status.HTTP_200_OK,
+ content_type="text/plain",
+ )
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ if not self.request.body:
+ return HttpResponse(
+ "No content posted.",
+ status=status.HTTP_400_BAD_REQUEST,
+ content_type="text/plain",
+ )
+
+ # Parse the yaml from the request body
+ try:
+ yaml_dict = safe_load(self.request.body)
+
+ if not isinstance(yaml_dict, dict):
+ log.warning(
+ "yaml_dict result from loading validate request body is not a dict",
+ extra=dict(
+ yaml_dict=yaml_dict, request_body=str(self.request.body)
+ ),
+ )
+ return HttpResponse(
+ "No file posted.",
+ status=status.HTTP_400_BAD_REQUEST,
+ content_type="text/plain",
+ )
+
+ except YAMLError:
+ return HttpResponse(
+ "Can't parse YAML\n",
+ status=status.HTTP_400_BAD_REQUEST,
+ content_type="text/plain",
+ )
+
+ # Validate the parsed yaml
+ try:
+ validated_yaml = validate_yaml(yaml_dict)
+ return HttpResponse(
+ f"Valid!\n\n{dumps(validated_yaml, indent=2)}\n",
+ status=status.HTTP_200_OK,
+ content_type="text/plain",
+ )
+
+ except InvalidYamlException as e:
+ return HttpResponse(
+ f"Error at {str(e.error_location)}: \n{e.error_message}\n",
+ status=status.HTTP_400_BAD_REQUEST,
+ content_type="text/plain",
+ )
+
+
+class V2ValidateYamlHandler(V1ValidateYamlHandler):
+ permission_classes = [AllowAny]
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ source = self.request.query_params.get("source", "unknown")
+ inc_counter(
+ API_VALIDATE_V2_COUNTER,
+ labels=dict(source=source),
+ )
+
+ if not self.request.body:
+ return Response(
+ {
+ "valid": False,
+ "message": "YAML is empty",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Parse the yaml from the request body
+ try:
+ yaml_dict = safe_load(self.request.body)
+ if not isinstance(yaml_dict, dict):
+ log.warning(
+ "yaml_dict result from loading validate request body is not a dict",
+ extra=dict(
+ yaml_dict=yaml_dict, request_body=str(self.request.body)
+ ),
+ )
+ return Response(
+ {
+ "valid": False,
+ "message": "YAML must be a dictionary type",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ except YAMLError as e:
+ mark = e.problem_mark
+ return Response(
+ {
+ "valid": False,
+ "message": "YAML could not be parsed",
+ "parse_error": {
+ "problem": e.problem,
+ "line": mark.line + 1,
+ "column": mark.column + 1,
+ },
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Validate the parsed yaml
+ try:
+ validated_yaml = validate_yaml(yaml_dict)
+ return Response(
+ {
+ "valid": True,
+ "message": "YAML is valid",
+ "validated_yaml": validated_yaml,
+ }
+ )
+
+ except InvalidYamlException as e:
+ return Response(
+ {
+ "valid": False,
+ "message": "YAML does not match the accepted schema",
+ "validation_error": e.error_dict,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apps/codecov-api/webhook_handlers/__init__.py b/apps/codecov-api/webhook_handlers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/webhook_handlers/constants.py b/apps/codecov-api/webhook_handlers/constants.py
new file mode 100644
index 0000000000..189e7520f1
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/constants.py
@@ -0,0 +1,104 @@
+class GitHubHTTPHeaders:
+ EVENT = "HTTP_X_GITHUB_EVENT"
+ DELIVERY_TOKEN = "HTTP_X_GITHUB_DELIVERY"
+ SIGNATURE = "HTTP_X_HUB_SIGNATURE"
+ SIGNATURE_256 = "HTTP_X_HUB_SIGNATURE_256"
+
+
+class GitHubWebhookEvents:
+ PULL_REQUEST = "pull_request"
+ DELETE = "delete"
+ PUSH = "push"
+ PUBLIC = "public"
+ STATUS = "status"
+ REPOSITORY = "repository"
+ PING = "ping"
+ INSTALLATION = "installation"
+ INSTALLATION_REPOSITORIES = "installation_repositories"
+ ORGANIZATION = "organization"
+ MARKETPLACE_PURCHASE = "marketplace_purchase"
+ MEMBER = "member"
+
+ repository_events = [PULL_REQUEST, DELETE, PUSH, PUBLIC, STATUS, REPOSITORY, MEMBER]
+
+
+class BitbucketHTTPHeaders:
+ EVENT = "HTTP_X_EVENT_KEY"
+ UUID = "HTTP_X_HOOK_UUID"
+
+
+class BitbucketServerHTTPHeaders:
+ EVENT = "X-Event-Key"
+ UUID = "X-Request-Id"
+
+
+class BitbucketWebhookEvents:
+ PULL_REQUEST_CREATED = "pullrequest:created"
+ PULL_REQUEST_UPDATED = "pullrequest:updated"
+ PULL_REQUEST_REJECTED = "pullrequest:rejected"
+ PULL_REQUEST_FULFILLED = "pullrequest:fulfilled"
+ REPO_PUSH = "repo:push"
+ REPO_COMMIT_STATUS_CREATED = "repo:commit_status_created"
+ REPO_COMMIT_STATUS_UPDATED = "repo:commit_status_updated"
+
+ subscribed_events = [
+ PULL_REQUEST_CREATED,
+ PULL_REQUEST_UPDATED,
+ PULL_REQUEST_FULFILLED,
+ REPO_PUSH,
+ REPO_COMMIT_STATUS_CREATED,
+ REPO_COMMIT_STATUS_UPDATED,
+ ]
+
+
+class BitbucketServerWebhookEvents:
+ REPO_MODIFIED = "repo:modified"
+ REPO_REFS_CHANGED = "repo:refs_changed"
+ PULL_REQUEST_CREATED = "pr:opened"
+ PULL_REQUEST_MERGED = "pr:merged"
+ PULL_REQUEST_REJECTED = "pr:declined"
+ PULL_REQUEST_DELETED = "pr:deleted"
+
+ subscribed_events = [
+ REPO_MODIFIED,
+ REPO_REFS_CHANGED,
+ PULL_REQUEST_CREATED,
+ PULL_REQUEST_MERGED,
+ PULL_REQUEST_REJECTED,
+ PULL_REQUEST_DELETED,
+ ]
+
+
+class GitLabHTTPHeaders:
+ EVENT = "HTTP_X_GITLAB_EVENT"
+ TOKEN = "HTTP_X_GITLAB_TOKEN"
+
+
+class GitLabWebhookEvents:
+ MERGE_REQUEST = "Merge Request Hook"
+ SYSTEM = "System Hook"
+ PUSH = "Push Hook"
+ JOB = "Job Hook"
+
+ subscribed_events = {
+ "push_events": True,
+ "issues_events": False,
+ "merge_requests_events": True,
+ "tag_push_events": False,
+ "note_events": False,
+ "job_events": False,
+ "build_events": True,
+ "pipeline_events": True,
+ "wiki_events": False,
+ }
+
+
+class WebhookHandlerErrorMessages:
+ LICENSE_EXPIRED = "License expired/invalid. Webhook rejected."
+ INVALID_SIGNATURE = "Invalid signature"
+ UNSUPPORTED_EVENT = "Unsupported event"
+ SKIP_CODECOV_STATUS = "Ok. Skip Codecov status updates."
+ SKIP_NOT_ACTIVE = "OK. Skip because repo is not active."
+ SKIP_PROCESSING = "OK. Skip because commit not found or is processing."
+ SKIP_PENDING_STATUSES = "Ok. Skip because status is pending."
+ SKIP_WEBHOOK_IGNORED = "Ok. Skip because config says to ignore this webhook"
diff --git a/apps/codecov-api/webhook_handlers/tests/__init__.py b/apps/codecov-api/webhook_handlers/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/codecov-api/webhook_handlers/tests/test_bitbucket.py b/apps/codecov-api/webhook_handlers/tests/test_bitbucket.py
new file mode 100644
index 0000000000..1aaeeb59c1
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_bitbucket.py
@@ -0,0 +1,349 @@
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from core.models import Branch, Commit, PullStates
+from webhook_handlers.constants import (
+ BitbucketHTTPHeaders,
+ BitbucketWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+
+class TestBitbucketWebhookHandler(APITestCase):
+ def _post_event_data(
+ self, event, data={}, hookid="f2e634c1-63db-44ac-b119-019fa6a71a2c"
+ ):
+ return self.client.post(
+ reverse("bitbucket-webhook"),
+ **{BitbucketHTTPHeaders.EVENT: event, BitbucketHTTPHeaders.UUID: hookid},
+ data=data,
+ format="json",
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service="bitbucket"),
+ service_id="673a6070-3421-46c9-9d48-90745f7bfe8e",
+ active=True,
+ hookid="f2e634c1-63db-44ac-b119-019fa6a71a2c",
+ )
+ self.pull = PullFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ pullid=1,
+ state=PullStates.OPEN,
+ )
+
+ def test_unknown_repo(self):
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_PUSH,
+ data={"repository": {"uuid": "{94f4c9b4-254f-46cf-a39e-97ce03fe58af}"}},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_inactive_repo(self):
+ self.repo.active = False
+ self.repo.save()
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_PUSH,
+ data={"repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"}},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_pull_request_created(self, pulls_sync_mock):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.PULL_REQUEST_CREATED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "pullrequest": {"id": pullid},
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Opening pull request in Codecov"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_pull_request_fulfilled(self):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.PULL_REQUEST_FULFILLED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "pullrequest": {"id": pullid},
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ self.pull.refresh_from_db()
+ assert self.pull.state == PullStates.MERGED
+
+ def test_pull_request_rejected(self):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.PULL_REQUEST_REJECTED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "pullrequest": {"id": pullid},
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ self.pull.refresh_from_db()
+ assert self.pull.state == PullStates.CLOSED
+
+ def test_repo_push_branch_deleted(self):
+ BranchFactory(repository=self.repo, name="name-of-branch")
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_PUSH,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "push": {
+ "changes": [
+ {
+ "new": None,
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ ]
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data is None
+ assert not Branch.objects.filter(
+ repository=self.repo, name="name-of-branch"
+ ).exists()
+
+ def test_repo_push_new_branch_sync_yaml_skipped(self):
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_PUSH,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "push": {
+ "changes": [
+ {
+ "new": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ ]
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Synchronize codecov.yml skipped"
+
+ def test_repo_push_new_branch_sync_yaml(self):
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_PUSH,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "push": {
+ "changes": [
+ {
+ "new": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ ]
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Synchronize codecov.yml skipped"
+
+ def test_repo_commit_status_change_wrong_context(self):
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_COMMIT_STATUS_CREATED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "commit_status": {
+ "name": "Unit Tests (Python)",
+ "description": "Build started",
+ "state": "INPROGRESS",
+ "key": "codecov",
+ "url": "https://my-build-tool.com/builds/MY-PROJECT/BUILD-777",
+ "type": "build",
+ "created_on": "2015-11-19T20:37:35.547563+00:00",
+ "updated_on": "2015-11-19T20:37:35.547563+00:00",
+ "links": {
+ "commit": {
+ "href": "http://api.bitbucket.org/2.0/repositories/tk/test/commit/9fec847784abb10b2fa567ee63b85bd238955d0e"
+ },
+ "self": {
+ "href": "http://api.bitbucket.org/2.0/repositories/tk/test/commit/9fec847784abb10b2fa567ee63b85bd238955d0e/statuses/build/mybuildtool"
+ },
+ },
+ },
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_CODECOV_STATUS
+
+ def test_repo_commit_status_change_in_progress(self):
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_COMMIT_STATUS_CREATED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "commit_status": {
+ "name": "Unit Tests (Python)",
+ "description": "Build started",
+ "state": "INPROGRESS",
+ "key": "not_codecov_context",
+ "url": "https://my-build-tool.com/builds/MY-PROJECT/BUILD-777",
+ "type": "build",
+ "created_on": "2015-11-19T20:37:35.547563+00:00",
+ "updated_on": "2015-11-19T20:37:35.547563+00:00",
+ "links": {
+ "commit": {
+ "href": "http://api.bitbucket.org/2.0/repositories/tk/test/commit/9fec847784abb10b2fa567ee63b85bd238955d0e"
+ },
+ "self": {
+ "href": "http://api.bitbucket.org/2.0/repositories/tk/test/commit/9fec847784abb10b2fa567ee63b85bd238955d0e/statuses/build/mybuildtool"
+ },
+ },
+ },
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES
+
+ def test_repo_commit_status_change_commit_skip_processing(self):
+ commitid = "9fec847784abb10b2fa567ee63b85bd238955d0e"
+ CommitFactory(
+ commitid=commitid, repository=self.repo, state=Commit.CommitStates.PENDING
+ )
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_COMMIT_STATUS_CREATED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "commit_status": {
+ "name": "Unit Tests (Python)",
+ "description": "Build started",
+ "state": "SUCCESSFUL",
+ "key": "not_codecov_context",
+ "url": "https://my-build-tool.com/builds/MY-PROJECT/BUILD-777",
+ "type": "build",
+ "created_on": "2015-11-19T20:37:35.547563+00:00",
+ "updated_on": "2015-11-19T20:37:35.547563+00:00",
+ "links": {
+ "commit": {
+ "href": f"http://api.bitbucket.org/2.0/repositories/tk/test/commit/{commitid}"
+ },
+ "self": {
+ "href": f"http://api.bitbucket.org/2.0/repositories/tk/test/commit/{commitid}/statuses/build/mybuildtool"
+ },
+ },
+ },
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ @patch("services.task.TaskService.notify")
+ def test_repo_commit_status_change_commit_notifies(self, notify_mock):
+ commitid = "9fec847784abb10b2fa567ee63b85bd238955d0e"
+ CommitFactory(
+ commitid=commitid, repository=self.repo, state=Commit.CommitStates.COMPLETE
+ )
+ response = self._post_event_data(
+ event=BitbucketWebhookEvents.REPO_COMMIT_STATUS_CREATED,
+ data={
+ "repository": {"uuid": "{673a6070-3421-46c9-9d48-90745f7bfe8e}"},
+ "commit_status": {
+ "name": "Unit Tests (Python)",
+ "description": "Build started",
+ "state": "SUCCESSFUL",
+ "key": "not_codecov_context",
+ "url": "https://my-build-tool.com/builds/MY-PROJECT/BUILD-777",
+ "type": "build",
+ "created_on": "2015-11-19T20:37:35.547563+00:00",
+ "updated_on": "2015-11-19T20:37:35.547563+00:00",
+ "links": {
+ "commit": {
+ "href": f"http://api.bitbucket.org/2.0/repositories/tk/test/commit/{commitid}"
+ },
+ "self": {
+ "href": f"http://api.bitbucket.org/2.0/repositories/tk/test/commit/{commitid}/statuses/build/mybuildtool"
+ },
+ },
+ },
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Notify queued"
+ notify_mock.assert_called_once_with(repoid=self.repo.repoid, commitid=commitid)
diff --git a/apps/codecov-api/webhook_handlers/tests/test_bitbucket_server.py b/apps/codecov-api/webhook_handlers/tests/test_bitbucket_server.py
new file mode 100644
index 0000000000..7c37a614e5
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_bitbucket_server.py
@@ -0,0 +1,243 @@
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from core.models import Branch, PullStates
+from webhook_handlers.constants import (
+ BitbucketServerHTTPHeaders,
+ BitbucketServerWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+
+class TestBitbucketServerWebhookHandler(APITestCase):
+ def _post_event_data(
+ self, event, data={}, hookid="f2e634c1-63db-44ac-b119-019fa6a71a2c"
+ ):
+ return self.client.post(
+ reverse("bitbucket-server-webhook"),
+ **{
+ BitbucketServerHTTPHeaders.EVENT: event,
+ BitbucketServerHTTPHeaders.UUID: hookid,
+ },
+ data=data,
+ format="json",
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service="bitbucket_server"),
+ service_id="673a6070-3421-46c9-9d48-90745f7bfe8e",
+ active=True,
+ hookid="f2e634c1-63db-44ac-b119-019fa6a71a2c",
+ )
+ self.pull = PullFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ pullid=1,
+ state=PullStates.OPEN,
+ )
+
+ def test_unknown_repo(self):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.PULL_REQUEST_CREATED,
+ data={
+ "pullRequest": {
+ "id": pullid,
+ "toRef": {"repository": {"id": "some-unknown-value"}},
+ }
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_inactive_repo(self):
+ self.repo.active = False
+ self.repo.save()
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.PULL_REQUEST_CREATED,
+ data={
+ "pullRequest": {
+ "toRef": {
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"}
+ }
+ }
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_pull_request_created(self, pulls_sync_mock):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.PULL_REQUEST_CREATED,
+ data={
+ "pullRequest": {
+ "id": pullid,
+ "toRef": {
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"}
+ },
+ }
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Opening pull request in Codecov"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_pull_request_fulfilled(self):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.PULL_REQUEST_MERGED,
+ data={
+ "pullRequest": {
+ "id": pullid,
+ "toRef": {
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"}
+ },
+ }
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ self.pull.refresh_from_db()
+ assert self.pull.state == PullStates.MERGED
+
+ def test_pull_request_rejected(self):
+ pullid = 1
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.PULL_REQUEST_REJECTED,
+ data={
+ "pullRequest": {
+ "id": pullid,
+ "toRef": {
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"}
+ },
+ }
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ self.pull.refresh_from_db()
+ assert self.pull.state == PullStates.CLOSED
+
+ def test_repo_push_branch_deleted(self):
+ BranchFactory(repository=self.repo, name="name-of-branch")
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.REPO_REFS_CHANGED,
+ data={
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"},
+ "push": {
+ "changes": {
+ "new": None,
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data is None
+ assert not Branch.objects.filter(
+ repository=self.repo, name="name-of-branch"
+ ).exists()
+
+ def test_repo_push_new_branch_sync_yaml_skipped(self):
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.REPO_REFS_CHANGED,
+ data={
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"},
+ "push": {
+ "changes": {
+ "new": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Synchronize codecov.yml skipped"
+
+ def test_repo_push_new_branch_sync_yaml(self):
+ response = self._post_event_data(
+ event=BitbucketServerWebhookEvents.REPO_REFS_CHANGED,
+ data={
+ "repository": {"id": "673a6070-3421-46c9-9d48-90745f7bfe8e"},
+ "push": {
+ "changes": {
+ "new": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "old": {
+ "type": "branch",
+ "name": "name-of-branch",
+ "target": {},
+ },
+ "links": {},
+ "created": False,
+ "forced": False,
+ "closed": False,
+ "commits": [
+ {
+ "hash": "03f4a7270240708834de475bcf21532d6134777e",
+ "type": "commit",
+ "message": "commit message\n",
+ "author": {},
+ "links": {},
+ }
+ ],
+ "truncated": False,
+ }
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Synchronize codecov.yml skipped"
diff --git a/apps/codecov-api/webhook_handlers/tests/test_github.py b/apps/codecov-api/webhook_handlers/tests/test_github.py
new file mode 100644
index 0000000000..9e11cc7e5b
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_github.py
@@ -0,0 +1,1422 @@
+import hmac
+import json
+import uuid
+from hashlib import sha1, sha256
+from unittest.mock import call, patch
+
+import pytest
+from freezegun import freeze_time
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.plan.constants import PlanName
+from shared.utils.test_utils import mock_config_helper
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.models import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+ Owner,
+ Service,
+)
+from webhook_handlers.constants import (
+ GitHubHTTPHeaders,
+ GitHubWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+
+class MockedSubscription(object):
+ def __init__(self, status, plan_name, quantity):
+ self.status = status
+ self.plan = {
+ "name": plan_name,
+ }
+ self.quantity = quantity
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+WEBHOOK_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
+DEFAULT_APP_ID = 1234
+
+
+class GithubWebhookHandlerTests(APITestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(request, mocker):
+ request.mocker = mocker
+
+ @pytest.fixture(autouse=True)
+ def mock_webhook_secret(self, mocker):
+ mock_config_helper(mocker, configs={"github.webhook_secret": WEBHOOK_SECRET})
+
+ @pytest.fixture(autouse=True)
+ def mock_default_app_id(self, mocker):
+ mock_config_helper(mocker, configs={"github.integration.id": DEFAULT_APP_ID})
+
+ def _post_event_data(self, event, data={}):
+ return self.client.post(
+ reverse("github-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: event,
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE_256: "sha256="
+ + hmac.new(
+ WEBHOOK_SECRET,
+ json.dumps(data, separators=(",", ":")).encode("utf-8"),
+ digestmod=sha256,
+ ).hexdigest(),
+ },
+ data=data,
+ format="json",
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service=Service.GITHUB.value),
+ service_id=12345,
+ active=True,
+ )
+
+ def test_get_repo_paths_dont_crash(self):
+ with self.subTest("with ownerid success"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": self.repo.service_id,
+ "owner": {"id": self.repo.author.service_id},
+ },
+ },
+ )
+
+ with self.subTest("with not found owner"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": self.repo.service_id,
+ "owner": {"id": -239450},
+ },
+ },
+ )
+
+ with self.subTest("with not found owner and not found repo"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {"id": -1948503, "owner": {"id": -239450}},
+ },
+ )
+
+ with self.subTest("with owner and not found repo"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": -1948503,
+ "owner": {"id": self.repo.author.service_id},
+ },
+ },
+ )
+
+ def test_ping_returns_pong_and_200(self):
+ response = self._post_event_data(event=GitHubWebhookEvents.PING)
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_repository_publicized_sets_activated_false_and_private_false(self):
+ self.repo.private = True
+ self.repo.activated = True
+
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "publicized", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.repo.refresh_from_db()
+
+ assert self.repo.private == False
+ assert self.repo.activated == False
+
+ def test_repository_privatized_sets_private_true(self):
+ self.repo.private = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "privatized", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.repo.refresh_from_db()
+
+ assert self.repo.private == True
+
+ def test_repository_deleted_sets_deleted_activated_and_active(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "deleted", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ self.repo.refresh_from_db()
+ assert self.repo.deleted is True
+ assert self.repo.active is False
+ assert self.repo.activated is False
+
+ def test_repository_delete_renames_repo(self):
+ self.repo.name = "testing"
+ self.repo.save()
+ assert self.repo.deleted == False
+
+ other_repo = RepositoryFactory(
+ name="testing",
+ author=OwnerFactory(service=Service.GITHUB.value),
+ service_id=67890,
+ active=True,
+ )
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "deleted", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ self.repo.refresh_from_db()
+ assert self.repo.deleted is True
+ assert self.repo.name == "testing-deleted"
+
+ # renaming the deleted repo allows the other repo to potentially be moved to a
+ # new owner (uniqueness constraints would have prevented this otherwise)
+ other_repo.author = self.repo.author
+ other_repo.save()
+
+ def test_delete_event_deletes_branch(self):
+ branch = BranchFactory(repository=self.repo)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.DELETE,
+ data={
+ "ref": "refs/heads/" + branch.name,
+ "ref_type": "branch",
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert not self.repo.branches.filter(name=branch.name).exists()
+
+ def test_public_sets_repo_private_false_and_activated_false(self):
+ self.repo.private = True
+ self.repo.activated = True
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUBLIC,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ self.repo.refresh_from_db()
+ assert not self.repo.private
+ assert not self.repo.activated
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ def test_push_updates_only_unmerged_commits_with_branch_name(self):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+ commit2 = CommitFactory(merged=False, repository=self.repo)
+
+ merged_branch_name = "merged"
+ unmerged_branch_name = "unmerged"
+
+ merged_commit = CommitFactory(
+ merged=True, repository=self.repo, branch=merged_branch_name
+ )
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + unmerged_branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ {"id": merged_commit.commitid, "message": merged_commit.message},
+ ],
+ },
+ )
+
+ commit1.refresh_from_db()
+ commit2.refresh_from_db()
+ merged_commit.refresh_from_db()
+
+ assert not commit1.merged
+ assert not commit2.merged
+
+ assert merged_commit.branch == merged_branch_name
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ def test_push_updates_commit_on_default_branch(self):
+ commit1 = CommitFactory(
+ merged=False, repository=self.repo, branch="feature-branch"
+ )
+ commit2 = CommitFactory(
+ merged=False, repository=self.repo, branch="feature-branch"
+ )
+
+ merged_branch_name = "merged"
+ repo_branch = self.repo.branch
+
+ merged_commit = CommitFactory(
+ merged=True, repository=self.repo, branch=merged_branch_name
+ )
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + repo_branch,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ {"id": merged_commit.commitid, "message": merged_commit.message},
+ ],
+ },
+ )
+
+ commit1.refresh_from_db()
+ commit2.refresh_from_db()
+ merged_commit.refresh_from_db()
+
+ assert commit1.branch == repo_branch
+ assert commit2.branch == repo_branch
+ assert commit1.merged
+ assert commit2.merged
+
+ assert merged_commit.branch == merged_branch_name
+
+ def test_push_exits_early_with_200_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+ unmerged_commit = CommitFactory(repository=self.repo, merged=False)
+ branch_name = "new-branch-name"
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": unmerged_commit.commitid, "message": unmerged_commit.message}
+ ],
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ unmerged_commit.refresh_from_db()
+ assert unmerged_commit.branch != branch_name
+
+ @patch("webhook_handlers.views.github.get_config")
+ def test_push_exits_early_with_200_if_repo_name_is_ignored(self, get_config_mock):
+ get_config_mock.side_effect = [WEBHOOK_SECRET.decode("utf-8"), [self.repo.name]]
+
+ self.repo.save()
+ unmerged_commit = CommitFactory(repository=self.repo, merged=False)
+ branch_name = "new-branch-name"
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": unmerged_commit.commitid, "message": unmerged_commit.message}
+ ],
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ unmerged_commit.refresh_from_db()
+
+ assert unmerged_commit.branch != branch_name
+
+ @patch("redis.Redis.sismember", lambda x, y, z: True)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_triggers_set_pending_task_on_most_recent_commit(
+ self, set_pending_mock
+ ):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+ commit2 = CommitFactory(merged=False, repository=self.repo)
+ unmerged_branch_name = "unmerged"
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + unmerged_branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ ],
+ },
+ )
+
+ set_pending_mock.assert_called_once_with(
+ repoid=self.repo.repoid,
+ commitid=commit2.commitid,
+ branch=unmerged_branch_name,
+ on_a_pull_request=False,
+ )
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_doesnt_trigger_task_if_repo_not_part_of_beta_set(
+ self, set_pending_mock
+ ):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + "derp",
+ "repository": {"id": self.repo.service_id},
+ "commits": [{"id": commit1.commitid, "message": commit1.message}],
+ },
+ )
+
+ set_pending_mock.assert_not_called()
+
+ @patch("redis.Redis.sismember", lambda x, y, z: True)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_doesnt_trigger_task_if_ci_skipped(self, set_pending_mock):
+ commit1 = CommitFactory(merged=False, repository=self.repo, message="[ci skip]")
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + "derp",
+ "repository": {"id": self.repo.service_id},
+ "commits": [{"id": commit1.commitid, "message": commit1.message}],
+ },
+ )
+
+ assert response.data == "CI Skipped"
+ set_pending_mock.assert_not_called()
+
+ def test_status_exits_early_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ def test_status_exits_early_for_codecov_statuses(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"context": "codecov/", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_CODECOV_STATUS
+
+ def test_status_exits_early_for_pending_statuses(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"state": "pending", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES
+
+ def test_status_exits_early_if_commit_not_complete(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "sha": CommitFactory(repository=self.repo, state="pending").commitid,
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ @patch("services.task.TaskService.notify")
+ def test_status_triggers_notify_task(self, notify_mock):
+ commit = CommitFactory(repository=self.repo)
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"repository": {"id": self.repo.service_id}, "sha": commit.commitid},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ notify_mock.assert_called_once_with(
+ repoid=self.repo.repoid, commitid=commit.commitid
+ )
+
+ def test_pull_request_exits_early_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_pull_request_triggers_pulls_sync_task_for_valid_actions(
+ self, pulls_sync_mock
+ ):
+ pull = PullFactory(repository=self.repo)
+
+ valid_actions = ["opened", "closed", "reopened", "synchronize"]
+
+ for action in valid_actions:
+ self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "action": action,
+ "number": pull.pullid,
+ },
+ )
+
+ pulls_sync_mock.assert_has_calls(
+ [call(repoid=self.repo.repoid, pullid=pull.pullid)] * len(valid_actions)
+ )
+
+ def test_pull_request_updates_title_if_edited(self):
+ pull = PullFactory(repository=self.repo)
+ new_title = "brand new dang title"
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "action": "edited",
+ "number": pull.pullid,
+ "pull_request": {"title": new_title},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ pull.refresh_from_db()
+ assert pull.title == new_title
+
+ @freeze_time("2024-03-28T00:00:00")
+ @patch("services.task.TaskService.refresh")
+ def test_installation_creates_new_owner_if_dne_default_app(self, mock_refresh):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "selected",
+ "account": {"id": service_id, "login": username},
+ "app_id": DEFAULT_APP_ID,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service="github", service_id=service_id, username=username
+ )
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+ assert owner.createstamp.isoformat() == "2024-03-28T00:00:00+00:00"
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.app_id == DEFAULT_APP_ID
+ assert installation.name == GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ assert installation.repository_service_ids == ["12321", "12343"]
+
+ assert mock_refresh.call_count == 1
+ _, kwargs = mock_refresh.call_args_list[0]
+ # Because we throw these into a set we need to order them here
+ # In practive it doesn't matter, but for the test it does.
+ kwargs["repos_affected"].sort()
+ assert kwargs == dict(
+ ownerid=owner.ownerid,
+ username=username,
+ sync_teams=False,
+ sync_repos=True,
+ using_integration=True,
+ repos_affected=[("12321", "R_kgDOG2tZYQ"), ("12343", "R_kgDOG2tABC")],
+ )
+
+ @patch("shared.events.amplitude.AmplitudeEventPublisher.publish")
+ @patch("services.task.TaskService.refresh")
+ def test_installation_publishes_amplitude_event_without_installer(
+ self, mock_refresh, mock_amplitude_publish
+ ):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "selected",
+ "account": {"id": service_id, "login": username},
+ "app_id": DEFAULT_APP_ID,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service="github", service_id=service_id, username=username
+ )
+ assert owner_set.exists()
+ owner = owner_set.first()
+
+ mock_amplitude_publish.assert_called_with(
+ "App Installed",
+ {
+ "user_ownerid": owner.ownerid,
+ "ownerid": owner.ownerid,
+ },
+ )
+
+ @patch("shared.events.amplitude.AmplitudeEventPublisher.publish")
+ @patch("services.task.TaskService.refresh")
+ def test_installation_publishes_amplitude_event_with_installer(
+ self, mock_refresh, mock_amplitude_publish
+ ):
+ installer = OwnerFactory(service="github", username="installer_username")
+
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "selected",
+ "account": {"id": service_id, "login": username},
+ "app_id": DEFAULT_APP_ID,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User", "login": "installer_username"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service="github", service_id=service_id, username=username
+ )
+ assert owner_set.exists()
+ owner = owner_set.first()
+
+ mock_amplitude_publish.assert_called_with(
+ "App Installed",
+ {
+ "user_ownerid": installer.ownerid,
+ "ownerid": owner.ownerid,
+ },
+ )
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_creates_new_owner_if_dne_all_repos_non_default_app(self):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "all",
+ "account": {"id": service_id, "login": username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service="github", service_id=service_id, username=username
+ )
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.app_id == 15
+ assert installation.name == "unconfigured_app"
+ assert installation.repository_service_ids is None
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_creates_new_owner_if_dne(self):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "all",
+ "account": {"id": service_id, "login": username},
+ "app_id": 15,
+ },
+ "repository_selection": "all",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(service="github", service_id=service_id)
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.app_id == 15
+ assert installation.name == "unconfigured_app"
+ assert installation.repository_service_ids is None
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_update_repos_existing_ghapp_installation(self):
+ owner = OwnerFactory(service=Service.GITHUB.value)
+ owner.save()
+ installation = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=["repo1", "repo2"],
+ installation_id=4,
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ )
+ installation.save()
+ assert owner.github_app_installations.count() == 1
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "repo1", "node_id": "R_node1"},
+ {"id": "repo2", "node_id": "R_node2"},
+ {"id": "repo3", "node_id": "R_node3"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+ installation.refresh_from_db()
+
+ assert (
+ owner.github_app_installations.count() == 1
+ ) # no new installations created
+ installation = owner.github_app_installations.first()
+ assert installation.installation_id == 4
+ # This installation changed names because it's not configured
+ # AND doesn't have the default app id
+ assert installation.name == "unconfigured_app"
+ assert installation.repository_service_ids == ["repo1", "repo2", "repo3"]
+
+ def test_installation_with_deleted_action_nulls_values(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+
+ owner.integration_id = 12
+ owner.save()
+
+ repo1.using_integration, repo2.using_integration = True, True
+ repo1.bot, repo2.bot = owner, owner
+
+ repo1.save()
+ repo2.save()
+
+ ghapp_installation = GithubAppInstallation(
+ installation_id=25,
+ repository_service_ids=[repo1.service_id, repo2.service_id],
+ owner=owner,
+ )
+ ghapp_installation.save()
+
+ assert owner.github_app_installations.exists()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 25,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "action": "deleted",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+ repo1.refresh_from_db()
+ repo2.refresh_from_db()
+
+ assert owner.integration_id is None
+ assert repo1.using_integration == False
+ assert repo2.using_integration == False
+
+ assert repo1.bot is None
+ assert repo2.bot is None
+
+ assert not owner.github_app_installations.exists()
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_update_existing_ghapp(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+ installation = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=[repo1.service_id],
+ installation_id=12,
+ app_id=2500,
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ pem_path="some_path",
+ )
+ owner.save()
+ repo1.save()
+ repo2.save()
+ installation.save()
+
+ assert owner.github_app_installations.exists()
+ assert installation.is_repo_covered_by_integration(repo2) is False
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": installation.installation_id,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories_added": [
+ {"id": repo2.service_id, "node_id": "R_xDOGxCAT"}
+ ],
+ "repositories_removed": [
+ {"id": repo1.service_id, "node_id": "R_xCATxCAT"}
+ ],
+ "repository_selection": "selected",
+ "action": "added",
+ "sender": {"type": "User"},
+ },
+ )
+
+ installation.refresh_from_db()
+ assert installation.installation_id == 12
+ # This app is not the default app, but it's configured
+ # So it should keep it's name
+ assert installation.app_id != DEFAULT_APP_ID
+ assert installation.name == GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ assert installation.repository_service_ids == [repo2.service_id]
+ assert installation.is_repo_covered_by_integration(repo2) is True
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self, ownerid, username, sync_teams, sync_repos, using_integration: None,
+ )
+ def test_installation_repositories_update_existing_ghapp_all_repos(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+ installation = GithubAppInstallation(
+ owner=owner, repository_service_ids=[repo1.service_id], installation_id=12
+ )
+
+ owner.save()
+ repo1.save()
+ repo2.save()
+ installation.save()
+
+ assert owner.github_app_installations.exists()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": 12,
+ "repository_selection": "all",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories_added": [{"id": repo2.service_id}],
+ "repositories_removed": [],
+ "repository_selection": "all",
+ "action": "deleted",
+ "sender": {"type": "User"},
+ },
+ )
+
+ installation.refresh_from_db()
+ assert installation.installation_id == 12
+ assert installation.repository_service_ids is None
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_with_other_actions_sets_owner_integration_id_if_none(
+ self,
+ ):
+ installation_id = 44
+ owner = OwnerFactory(service=Service.GITHUB.value)
+
+ owner.integration_id = None
+ owner.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": installation_id,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": DEFAULT_APP_ID,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "action": "suspend",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+
+ assert owner.integration_id == installation_id
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == installation_id
+ assert installation.app_id == DEFAULT_APP_ID
+ assert installation.name == GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ assert installation.is_suspended == True
+ assert installation.repository_service_ids == ["12321", "12343"]
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_with_other_actions_sets_owner_itegration_id_if_none(
+ self,
+ ):
+ installation_id = 44
+ owner = OwnerFactory(service=Service.GITHUB.value)
+
+ owner.integration_id = None
+ owner.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": installation_id,
+ "repository_selection": "all",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repository_selection": "all",
+ "action": "added",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+
+ assert owner.integration_id == installation_id
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == installation_id
+ assert installation.repository_service_ids is None
+
+ @patch("services.task.TaskService.refresh")
+ def test_installation_trigger_refresh_with_other_actions(self, refresh_mock):
+ owner = OwnerFactory(service=Service.GITHUB.value)
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 11,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "action": "added",
+ "sender": {"type": "User"},
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ },
+ )
+
+ assert refresh_mock.call_count == 1
+ _, kwargs = refresh_mock.call_args_list[0]
+ # Because we throw these into a set we need to order them here
+ # In practive it doesn't matter, but for the test it does.
+ kwargs["repos_affected"].sort()
+ assert kwargs == dict(
+ ownerid=owner.ownerid,
+ username=owner.username,
+ sync_teams=False,
+ sync_repos=True,
+ using_integration=True,
+ repos_affected=[("12321", "R_kgDOG2tZYQ"), ("12343", "R_kgDOG2tABC")],
+ )
+
+ @patch("services.task.TaskService.refresh")
+ def test_organization_with_removed_action_removes_user_from_org_and_activated_user_list(
+ self,
+ mock_refresh,
+ ):
+ org = OwnerFactory(service_id="4321", service=Service.GITHUB.value)
+ user = OwnerFactory(
+ organizations=[org.ownerid], service_id="12", service=Service.GITHUB.value
+ )
+ org.plan_activated_users = [user.ownerid]
+ org.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": org.service_id},
+ },
+ )
+
+ user.refresh_from_db()
+ org.refresh_from_db()
+
+ mock_refresh.assert_called_with(
+ ownerid=user.ownerid,
+ username=user.username,
+ sync_teams=True,
+ sync_repos=True,
+ using_integration=False,
+ )
+ assert user.ownerid not in org.plan_activated_users
+
+ def test_organization_member_removed_with_nonexistent_org_doesnt_crash(self):
+ user = OwnerFactory(service_id="12", service=Service.GITHUB.value)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": 65000},
+ },
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_organization_member_removed_with_nonexistent_or_nonactivated_member(self):
+ mock_all_plans_and_tiers()
+ org = OwnerFactory(
+ service_id="4321",
+ plan_activated_users=[50392],
+ service=Service.GITHUB.value,
+ )
+ user = OwnerFactory(
+ service_id="12", organizations=[60798], service=Service.GITHUB.value
+ )
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": org.service_id},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_organization_member_removed_with_nonexistent_member_doesnt_crash(self):
+ org = OwnerFactory(service_id="4321", service=Service.GITHUB.value)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": 101010}},
+ "organization": {"id": org.service_id},
+ },
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.task.TaskService.sync_plans")
+ def test_marketplace_purchase_triggers_sync_plans_task(
+ self, sync_plans_mock, subscription_retrieve_mock
+ ):
+ sender = {"id": 545, "login": "buddy@guy.com"}
+ action = "purchased"
+ account = {"type": "Organization", "id": 54678, "login": "username"}
+ subscription_retrieve_mock.return_value = None
+ self._post_event_data(
+ event=GitHubWebhookEvents.MARKETPLACE_PURCHASE,
+ data={
+ "action": action,
+ "sender": sender,
+ "marketplace_purchase": {"account": account},
+ },
+ )
+
+ sync_plans_mock.assert_called_once_with(
+ sender=sender, account=account, action=action
+ )
+
+ @patch("logging.Logger.warning")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.task.TaskService.sync_plans")
+ def test_marketplace_purchase_but_user_has_stripe_subscription(
+ self, sync_plans_mock, subscription_retrieve_mock, log_warning_mock
+ ):
+ sender = {"id": 545, "login": "buddy@guy.com"}
+ action = "purchased"
+ account = {"type": "Organization", "id": 54678, "login": "username"}
+ OwnerFactory(
+ username=account["login"], service="github", stripe_subscription_id="abc"
+ )
+ quantity = 14
+ plan = PlanName.CODECOV_PRO_MONTHLY.value
+ subscription_retrieve_mock.return_value = MockedSubscription(
+ "active", plan, quantity
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MARKETPLACE_PURCHASE,
+ data={
+ "action": action,
+ "sender": sender,
+ "marketplace_purchase": {
+ "account": account,
+ "plan": {"name": "gh-marketplace"},
+ "unit_count": 200,
+ },
+ },
+ )
+
+ log_warning_mock.assert_called_with(
+ "GHM webhook - user purchasing but has a Stripe Subscription",
+ extra={
+ "username": "username",
+ "old_plan_name": plan,
+ "old_plan_seats": quantity,
+ "new_plan_name": "gh-marketplace",
+ "new_plan_seats": 200,
+ },
+ )
+
+ sync_plans_mock.assert_called_once_with(
+ sender=sender, account=account, action=action
+ )
+
+ def test_signature_validation(self):
+ response = self.client.post(
+ reverse("github-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: "",
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE: "",
+ },
+ data={},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ response = self.client.post(
+ reverse("github-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: "",
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE_256: "sha256="
+ + hmac.new(
+ WEBHOOK_SECRET,
+ json.dumps({}, separators=(",", ":")).encode("utf-8"),
+ digestmod=sha256,
+ ).hexdigest(),
+ },
+ data={},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ response = self.client.post(
+ reverse("github-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: "",
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE: "sha1="
+ + hmac.new(
+ WEBHOOK_SECRET,
+ json.dumps({}, separators=(",", ":")).encode("utf-8"),
+ digestmod=sha1,
+ ).hexdigest(),
+ },
+ data={},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ @patch("webhook_handlers.views.github.get_config")
+ def test_signature_validation_with_string_key(self, get_config_mock):
+ # The hmac function requires a bytestring, and we're creating hmacs
+ # throughout these tests so we've been using bytestrings. However,
+ # `get_config` normally returns a UTF-8 string; make sure that still
+ # works.
+ get_config_mock.return_value = WEBHOOK_SECRET.decode("utf-8")
+ response = self._post_event_data(event="", data={})
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_member_removes_repo_permissions_if_member_removed(self):
+ member = OwnerFactory(
+ permission=[self.repo.repoid], service_id=6098, service=Service.GITHUB.value
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ member.refresh_from_db()
+ assert self.repo.repoid not in member.permission
+
+ def test_member_doesnt_crash_if_member_permission_array_is_None(self):
+ member = OwnerFactory(
+ permission=None, service_id=6098, service=Service.GITHUB.value
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ def test_member_doesnt_crash_if_member_didnt_have_permission(self):
+ member = OwnerFactory(
+ permission=[self.repo.service_id + 1],
+ service_id=6098,
+ service=Service.GITHUB.value,
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ def test_member_doesnt_crash_if_member_dne(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": 604945829}, # some random number
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ assert response.status_code == 404
+
+ def test_returns_404_if_repo_not_found(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "publicized", "repository": {"id": -29384}},
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_repo_not_found_when_owner_has_integration_creates_repo(self):
+ owner = OwnerFactory(
+ integration_id=4850403, service_id=97968493, service=Service.GITHUB.value
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": 506003,
+ "name": "testrepo",
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": owner.service_id},
+ },
+ },
+ )
+
+ assert owner.repository_set.filter(name="testrepo").exists()
+
+ def test_repo_creation_doesnt_crash_for_forked_repo(self):
+ owner = OwnerFactory(
+ integration_id=4850403, service_id=97968493, service=Service.GITHUB.value
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": 506003,
+ "name": "testrepo",
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": owner.service_id},
+ "fork": True,
+ "parent": {
+ "name": "mainrepo",
+ "language": "python",
+ "id": 7940284,
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": 8495712939, "login": "alogin"},
+ },
+ },
+ },
+ )
+
+ assert owner.repository_set.filter(name="testrepo").exists()
diff --git a/apps/codecov-api/webhook_handlers/tests/test_github_enterprise.py b/apps/codecov-api/webhook_handlers/tests/test_github_enterprise.py
new file mode 100644
index 0000000000..523074c6da
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_github_enterprise.py
@@ -0,0 +1,1197 @@
+import hmac
+import json
+import uuid
+from hashlib import sha256
+from unittest.mock import call, patch
+
+import pytest
+from freezegun import freeze_time
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.plan.constants import PlanName
+
+from billing.helpers import mock_all_plans_and_tiers
+from codecov_auth.models import GithubAppInstallation, Owner, Service
+from utils.config import get_config
+from webhook_handlers.constants import (
+ GitHubHTTPHeaders,
+ GitHubWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+from webhook_handlers.tests.test_github import MockedSubscription
+
+WEBHOOK_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
+
+
+class GithubEnterpriseWebhookHandlerTests(APITestCase):
+ @pytest.fixture(autouse=True)
+ def mock_webhook_secret(self, mocker):
+ orig_get_config = get_config
+
+ def override(*args, default=None):
+ if args[0] == "github" and args[1] == "webhook_secret":
+ return WEBHOOK_SECRET
+
+ return orig_get_config(*args, default=default)
+
+ mock_get_config = mocker.patch("webhook_handlers.views.github.get_config")
+ mock_get_config.side_effect = override
+
+ def _post_event_data(self, event, data={}):
+ return self.client.post(
+ reverse("github_enterprise-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: event,
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE_256: "sha256="
+ + hmac.new(
+ WEBHOOK_SECRET,
+ json.dumps(data, separators=(",", ":")).encode("utf-8"),
+ digestmod=sha256,
+ ).hexdigest(),
+ },
+ data=data,
+ format="json",
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service=Service.GITHUB_ENTERPRISE.value),
+ service_id=12345,
+ active=True,
+ )
+
+ def test_get_repo_paths_dont_crash(self):
+ with self.subTest("with ownerid success"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": self.repo.service_id,
+ "owner": {"id": self.repo.author.service_id},
+ },
+ },
+ )
+
+ with self.subTest("with not found owner"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": self.repo.service_id,
+ "owner": {"id": -239450},
+ },
+ },
+ )
+
+ with self.subTest("with not found owner and not found repo"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {"id": -1948503, "owner": {"id": -239450}},
+ },
+ )
+
+ with self.subTest("with owner and not found repo"):
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": -1948503,
+ "owner": {"id": self.repo.author.service_id},
+ },
+ },
+ )
+
+ def test_ping_returns_pong_and_200(self):
+ response = self._post_event_data(event=GitHubWebhookEvents.PING)
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_repository_publicized_sets_activated_false_and_private_false(self):
+ self.repo.private = True
+ self.repo.activated = True
+
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "publicized", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.repo.refresh_from_db()
+
+ assert self.repo.private == False
+ assert self.repo.activated == False
+
+ def test_repository_privatized_sets_private_true(self):
+ self.repo.private = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "privatized", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ self.repo.refresh_from_db()
+
+ assert self.repo.private == True
+
+ def test_repository_deleted_sets_deleted_activated_and_active(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "deleted", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ self.repo.refresh_from_db()
+ assert self.repo.deleted is True
+ assert self.repo.active is False
+ assert self.repo.activated is False
+
+ def test_delete_event_deletes_branch(self):
+ branch = BranchFactory(repository=self.repo)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.DELETE,
+ data={
+ "ref": "refs/heads/" + branch.name,
+ "ref_type": "branch",
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert not self.repo.branches.filter(name=branch.name).exists()
+
+ def test_public_sets_repo_private_false_and_activated_false(self):
+ self.repo.private = True
+ self.repo.activated = True
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUBLIC,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ self.repo.refresh_from_db()
+ assert not self.repo.private
+ assert not self.repo.activated
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ def test_push_updates_only_unmerged_commits_with_branch_name(self):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+ commit2 = CommitFactory(merged=False, repository=self.repo)
+
+ merged_branch_name = "merged"
+ unmerged_branch_name = "unmerged"
+
+ merged_commit = CommitFactory(
+ merged=True, repository=self.repo, branch=merged_branch_name
+ )
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + unmerged_branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ {"id": merged_commit.commitid, "message": merged_commit.message},
+ ],
+ },
+ )
+
+ commit1.refresh_from_db()
+ commit2.refresh_from_db()
+ merged_commit.refresh_from_db()
+
+ assert not commit1.merged
+ assert not commit2.merged
+
+ assert merged_commit.branch == merged_branch_name
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ def test_push_updates_commit_on_default_branch(self):
+ commit1 = CommitFactory(
+ merged=False, repository=self.repo, branch="feature-branch"
+ )
+ commit2 = CommitFactory(
+ merged=False, repository=self.repo, branch="feature-branch"
+ )
+
+ merged_branch_name = "merged"
+ repo_branch = self.repo.branch
+
+ merged_commit = CommitFactory(
+ merged=True, repository=self.repo, branch=merged_branch_name
+ )
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + repo_branch,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ {"id": merged_commit.commitid, "message": merged_commit.message},
+ ],
+ },
+ )
+
+ commit1.refresh_from_db()
+ commit2.refresh_from_db()
+ merged_commit.refresh_from_db()
+
+ assert commit1.branch == repo_branch
+ assert commit2.branch == repo_branch
+ assert commit1.merged
+ assert commit2.merged
+
+ assert merged_commit.branch == merged_branch_name
+
+ def test_push_exits_early_with_200_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+ unmerged_commit = CommitFactory(repository=self.repo, merged=False)
+ branch_name = "new-branch-name"
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": unmerged_commit.commitid, "message": unmerged_commit.message}
+ ],
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ unmerged_commit.refresh_from_db()
+ assert unmerged_commit.branch != branch_name
+
+ @patch("redis.Redis.sismember", lambda x, y, z: True)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_triggers_set_pending_task_on_most_recent_commit(
+ self, set_pending_mock
+ ):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+ commit2 = CommitFactory(merged=False, repository=self.repo)
+ unmerged_branch_name = "unmerged"
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + unmerged_branch_name,
+ "repository": {"id": self.repo.service_id},
+ "commits": [
+ {"id": commit1.commitid, "message": commit1.message},
+ {"id": commit2.commitid, "message": commit2.message},
+ ],
+ },
+ )
+
+ set_pending_mock.assert_called_once_with(
+ repoid=self.repo.repoid,
+ commitid=commit2.commitid,
+ branch=unmerged_branch_name,
+ on_a_pull_request=False,
+ )
+
+ @patch("redis.Redis.sismember", lambda x, y, z: False)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_doesnt_trigger_task_if_repo_not_part_of_beta_set(
+ self, set_pending_mock
+ ):
+ commit1 = CommitFactory(merged=False, repository=self.repo)
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + "derp",
+ "repository": {"id": self.repo.service_id},
+ "commits": [{"id": commit1.commitid, "message": commit1.message}],
+ },
+ )
+
+ set_pending_mock.assert_not_called()
+
+ @patch("redis.Redis.sismember", lambda x, y, z: True)
+ @patch("services.task.TaskService.status_set_pending")
+ def test_push_doesnt_trigger_task_if_ci_skipped(self, set_pending_mock):
+ commit1 = CommitFactory(merged=False, repository=self.repo, message="[ci skip]")
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PUSH,
+ data={
+ "ref": "refs/heads/" + "derp",
+ "repository": {"id": self.repo.service_id},
+ "commits": [{"id": commit1.commitid, "message": commit1.message}],
+ },
+ )
+
+ assert response.data == "CI Skipped"
+ set_pending_mock.assert_not_called()
+
+ def test_status_exits_early_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ def test_status_exits_early_for_codecov_statuses(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"context": "codecov/", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_CODECOV_STATUS
+
+ def test_status_exits_early_for_pending_statuses(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"state": "pending", "repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES
+
+ def test_status_exits_early_if_commit_not_complete(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "sha": CommitFactory(repository=self.repo, state="pending").commitid,
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ @patch("services.task.TaskService.notify")
+ def test_status_triggers_notify_task(self, notify_mock):
+ commit = CommitFactory(repository=self.repo)
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.STATUS,
+ data={"repository": {"id": self.repo.service_id}, "sha": commit.commitid},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ notify_mock.assert_called_once_with(
+ repoid=self.repo.repoid, commitid=commit.commitid
+ )
+
+ def test_pull_request_exits_early_if_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={"repository": {"id": self.repo.service_id}},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_pull_request_triggers_pulls_sync_task_for_valid_actions(
+ self, pulls_sync_mock
+ ):
+ pull = PullFactory(repository=self.repo)
+
+ valid_actions = ["opened", "closed", "reopened", "synchronize"]
+
+ for action in valid_actions:
+ self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "action": action,
+ "number": pull.pullid,
+ },
+ )
+
+ pulls_sync_mock.assert_has_calls(
+ [call(repoid=self.repo.repoid, pullid=pull.pullid)] * len(valid_actions)
+ )
+
+ def test_pull_request_updates_title_if_edited(self):
+ pull = PullFactory(repository=self.repo)
+ new_title = "brand new dang title"
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.PULL_REQUEST,
+ data={
+ "repository": {"id": self.repo.service_id},
+ "action": "edited",
+ "number": pull.pullid,
+ "pull_request": {"title": new_title},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ pull.refresh_from_db()
+ assert pull.title == new_title
+
+ @freeze_time("2024-03-28T00:00:00")
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_creates_new_owner_if_dne(self):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "selected",
+ "account": {"id": service_id, "login": username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service=Service.GITHUB_ENTERPRISE.value,
+ service_id=service_id,
+ username=username,
+ )
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+ assert owner.createstamp.isoformat() == "2024-03-28T00:00:00+00:00"
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.repository_service_ids == ["12321", "12343"]
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_creates_new_owner_if_dne_all_repos(self):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "all",
+ "account": {"id": service_id, "login": username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service=Service.GITHUB_ENTERPRISE.value,
+ service_id=service_id,
+ username=username,
+ )
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.repository_service_ids is None
+
+ @freeze_time("2024-03-28T00:00:00")
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_creates_new_owner_if_dne(self):
+ username, service_id = "newuser", 123456
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": 4,
+ "repository_selection": "all",
+ "account": {"id": service_id, "login": username},
+ "app_id": 15,
+ },
+ "repository_selection": "all",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner_set = Owner.objects.filter(
+ service=Service.GITHUB_ENTERPRISE.value,
+ service_id=service_id,
+ username=username,
+ )
+
+ assert owner_set.exists()
+
+ owner = owner_set.first()
+ assert owner.createstamp.isoformat() == "2024-03-28T00:00:00+00:00"
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == 4
+ assert installation.repository_service_ids is None
+
+ def test_installation_with_deleted_action_nulls_values(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+
+ owner.integration_id = 12
+ owner.save()
+
+ repo1.using_integration, repo2.using_integration = True, True
+ repo1.bot, repo2.bot = owner, owner
+
+ repo1.save()
+ repo2.save()
+
+ ghapp_installation = GithubAppInstallation(
+ installation_id=25,
+ repository_service_ids=[repo1.service_id, repo2.service_id],
+ owner=owner,
+ )
+ ghapp_installation.save()
+
+ assert owner.github_app_installations.exists()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 25,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_kgDOG2tZYQ"},
+ {"id": "12343", "node_id": "R_kgDOG2tABC"},
+ ],
+ "action": "deleted",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+ repo1.refresh_from_db()
+ repo2.refresh_from_db()
+
+ assert owner.integration_id is None
+ assert repo1.using_integration == False
+ assert repo2.using_integration == False
+
+ assert repo1.bot is None
+ assert repo2.bot is None
+
+ assert not owner.github_app_installations.exists()
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_update_existing_ghapp(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+ installation = GithubAppInstallation(
+ owner=owner, repository_service_ids=[repo1.service_id], installation_id=12
+ )
+
+ owner.integration_id = 12
+ owner.save()
+
+ repo1.using_integration, repo2.using_integration = True, True
+ repo1.bot, repo2.bot = owner, owner
+
+ repo1.save()
+ repo2.save()
+
+ installation.save()
+
+ assert owner.github_app_installations.exists()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": 12,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories_added": [{"id": repo2.service_id, "node_id": "R_repo2"}],
+ "repositories_removed": [
+ {"id": repo1.service_id, "node_id": "R_repo1"}
+ ],
+ "repository_selection": "selected",
+ "action": "added",
+ "sender": {"type": "User"},
+ },
+ )
+
+ installation.refresh_from_db()
+ assert installation.installation_id == 12
+ assert installation.repository_service_ids == [repo2.service_id]
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_update_existing_ghapp_all_repos(self):
+ # Should set integration_id to null for owner,
+ # and set using_integration=False and bot=null for repos
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+ repo1 = RepositoryFactory(author=owner)
+ repo2 = RepositoryFactory(author=owner)
+ installation = GithubAppInstallation(
+ owner=owner, repository_service_ids=[repo1.service_id], installation_id=12
+ )
+
+ owner.integration_id = 12
+ owner.save()
+
+ repo1.using_integration, repo2.using_integration = True, True
+ repo1.bot, repo2.bot = owner, owner
+
+ repo1.save()
+ repo2.save()
+
+ installation.save()
+
+ assert owner.github_app_installations.exists()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": 12,
+ "repository_selection": "all",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories_added": [{"id": repo2.service_id, "node_id": "R_repo2"}],
+ "repositories_removed": [],
+ "repository_selection": "all",
+ "action": "deleted",
+ "sender": {"type": "User"},
+ },
+ )
+
+ installation.refresh_from_db()
+ assert installation.installation_id == 12
+ assert installation.repository_service_ids is None
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_with_other_actions_sets_owner_itegration_id_if_none(
+ self,
+ ):
+ installation_id = 44
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+
+ owner.integration_id = None
+ owner.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": installation_id,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repositories": [
+ {"id": "12321", "node_id": "R_12321CAT"},
+ {"id": "12343", "node_id": "R_12343DOG"},
+ ],
+ "action": "added",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+
+ assert owner.integration_id == installation_id
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == installation_id
+ assert installation.repository_service_ids == ["12321", "12343"]
+
+ @patch(
+ "services.task.TaskService.refresh",
+ lambda self,
+ ownerid,
+ username,
+ sync_teams,
+ sync_repos,
+ using_integration,
+ repos_affected: None,
+ )
+ def test_installation_repositories_with_other_actions_sets_owner_itegration_id_if_none(
+ self,
+ ):
+ installation_id = 44
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+
+ owner.integration_id = None
+ owner.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ data={
+ "installation": {
+ "id": installation_id,
+ "repository_selection": "all",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "repository_selection": "all",
+ "action": "added",
+ "sender": {"type": "User"},
+ },
+ )
+
+ owner.refresh_from_db()
+
+ assert owner.integration_id == installation_id
+
+ ghapp_installations_set = GithubAppInstallation.objects.filter(
+ owner_id=owner.ownerid
+ )
+ assert ghapp_installations_set.count() == 1
+ installation = ghapp_installations_set.first()
+ assert installation.installation_id == installation_id
+ assert installation.repository_service_ids is None
+
+ @patch("services.task.TaskService.refresh")
+ def test_installation_trigger_refresh_with_other_actions(self, refresh_mock):
+ owner = OwnerFactory(service=Service.GITHUB_ENTERPRISE.value)
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.INSTALLATION,
+ data={
+ "installation": {
+ "id": 11,
+ "repository_selection": "selected",
+ "account": {"id": owner.service_id, "login": owner.username},
+ "app_id": 15,
+ },
+ "action": "added",
+ "sender": {"type": "User"},
+ "repositories": [
+ {"id": "12321", "node_id": "R_12321CAT"},
+ {"id": "12343", "node_id": "R_12343DOG"},
+ ],
+ },
+ )
+
+ assert refresh_mock.call_count == 1
+ _, kwargs = refresh_mock.call_args_list[0]
+ # Because we throw these into a set we need to order them here
+ # In practive it doesn't matter, but for the test it does.
+ kwargs["repos_affected"].sort()
+ assert kwargs == dict(
+ ownerid=owner.ownerid,
+ username=owner.username,
+ sync_teams=False,
+ sync_repos=True,
+ using_integration=True,
+ repos_affected=[("12321", "R_12321CAT"), ("12343", "R_12343DOG")],
+ )
+
+ @patch("services.task.TaskService.refresh")
+ def test_organization_with_removed_action_removes_user_from_org_and_activated_user_list(
+ self,
+ mock_refresh,
+ ):
+ org = OwnerFactory(service_id="4321", service=Service.GITHUB_ENTERPRISE.value)
+ user = OwnerFactory(
+ organizations=[org.ownerid],
+ service_id="12",
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ org.plan_activated_users = [user.ownerid]
+ org.save()
+
+ self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": org.service_id},
+ },
+ )
+
+ user.refresh_from_db()
+ org.refresh_from_db()
+
+ mock_refresh.assert_called_with(
+ ownerid=user.ownerid,
+ username=user.username,
+ sync_teams=True,
+ sync_repos=True,
+ using_integration=False,
+ )
+ assert user.ownerid not in org.plan_activated_users
+
+ def test_organization_member_removed_with_nonexistent_org_doesnt_crash(self):
+ user = OwnerFactory(service_id="12", service=Service.GITHUB_ENTERPRISE.value)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": 65000},
+ },
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_organization_member_removed_with_nonexistent_or_nonactivated_member(self):
+ mock_all_plans_and_tiers()
+ org = OwnerFactory(
+ service_id="4321",
+ plan_activated_users=[50392],
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ user = OwnerFactory(
+ service_id="12",
+ organizations=[60798],
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": user.service_id}},
+ "organization": {"id": org.service_id},
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_organization_member_removed_with_nonexistent_member_doesnt_crash(self):
+ org = OwnerFactory(service_id="4321", service=Service.GITHUB_ENTERPRISE.value)
+
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.ORGANIZATION,
+ data={
+ "action": "member_removed",
+ "membership": {"user": {"id": 101010}},
+ "organization": {"id": org.service_id},
+ },
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.task.TaskService.sync_plans")
+ def test_marketplace_purchase_triggers_sync_plans_task(
+ self, sync_plans_mock, subscription_retrieve_mock
+ ):
+ sender = {"id": 545, "login": "buddy@guy.com"}
+ action = "purchased"
+ account = {"type": "Organization", "id": 54678, "login": "username"}
+ subscription_retrieve_mock.return_value = None
+ self._post_event_data(
+ event=GitHubWebhookEvents.MARKETPLACE_PURCHASE,
+ data={
+ "action": action,
+ "sender": sender,
+ "marketplace_purchase": {"account": account},
+ },
+ )
+
+ sync_plans_mock.assert_called_once_with(
+ sender=sender, account=account, action=action
+ )
+
+ @patch("logging.Logger.warning")
+ @patch("services.billing.stripe.Subscription.retrieve")
+ @patch("services.task.TaskService.sync_plans")
+ def test_marketplace_purchase_but_user_has_stripe_subscription(
+ self, sync_plans_mock, subscription_retrieve_mock, log_warning_mock
+ ):
+ sender = {"id": 545, "login": "buddy@guy.com"}
+ action = "purchased"
+ account = {"type": "Organization", "id": 54678, "login": "username"}
+ OwnerFactory(
+ username=account["login"],
+ service="github_enterprise",
+ stripe_subscription_id="abc",
+ )
+ quantity = 14
+ plan = PlanName.CODECOV_PRO_MONTHLY.value
+ subscription_retrieve_mock.return_value = MockedSubscription(
+ "active", plan, quantity
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MARKETPLACE_PURCHASE,
+ data={
+ "action": action,
+ "sender": sender,
+ "marketplace_purchase": {
+ "account": account,
+ "plan": {"name": "gh-marketplace"},
+ "unit_count": 14,
+ },
+ },
+ )
+
+ log_warning_mock.assert_called_with(
+ "GHM webhook - user purchasing but has a Stripe Subscription",
+ extra={
+ "username": "username",
+ "old_plan_name": plan,
+ "old_plan_seats": quantity,
+ "new_plan_name": "gh-marketplace",
+ "new_plan_seats": 14,
+ },
+ )
+
+ sync_plans_mock.assert_called_once_with(
+ sender=sender, account=account, action=action
+ )
+
+ def test_signature_validation(self):
+ response = self.client.post(
+ reverse("github-webhook"),
+ **{
+ GitHubHTTPHeaders.EVENT: "",
+ GitHubHTTPHeaders.DELIVERY_TOKEN: uuid.UUID(int=5),
+ GitHubHTTPHeaders.SIGNATURE: "",
+ },
+ data={},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ @patch("webhook_handlers.views.github.get_config")
+ def test_signature_validation_with_string_key(self, get_config_mock):
+ # make get_config return string
+ get_config_mock.return_value = "testixik8qdauiab1yiffydimvi72ekq"
+ response = self._post_event_data(event="", data={})
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_member_removes_repo_permissions_if_member_removed(self):
+ member = OwnerFactory(
+ permission=[self.repo.repoid],
+ service_id=6098,
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ member.refresh_from_db()
+ assert self.repo.repoid not in member.permission
+
+ def test_member_doesnt_crash_if_member_permission_array_is_None(self):
+ member = OwnerFactory(
+ permission=None, service_id=6098, service=Service.GITHUB_ENTERPRISE.value
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ def test_member_doesnt_crash_if_member_didnt_have_permission(self):
+ member = OwnerFactory(
+ permission=[self.repo.service_id + 1],
+ service_id=6098,
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": member.service_id},
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ def test_member_doesnt_crash_if_member_dne(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.MEMBER,
+ data={
+ "action": "removed",
+ "member": {"id": 604945829}, # some random number
+ "repository": {"id": self.repo.service_id},
+ },
+ )
+
+ assert response.status_code == 404
+
+ def test_returns_404_if_repo_not_found(self):
+ response = self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={"action": "publicized", "repository": {"id": -29384}},
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_repo_not_found_when_owner_has_integration_creates_repo(self):
+ owner = OwnerFactory(
+ integration_id=4850403,
+ service_id=97968493,
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": 506003,
+ "name": "testrepo",
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": owner.service_id},
+ },
+ },
+ )
+
+ assert owner.repository_set.filter(name="testrepo").exists()
+
+ def test_repo_creation_doesnt_crash_for_forked_repo(self):
+ owner = OwnerFactory(
+ integration_id=4850403,
+ service_id=97968493,
+ service=Service.GITHUB_ENTERPRISE.value,
+ )
+ self._post_event_data(
+ event=GitHubWebhookEvents.REPOSITORY,
+ data={
+ "action": "publicized",
+ "repository": {
+ "id": 506003,
+ "name": "testrepo",
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": owner.service_id},
+ "fork": True,
+ "parent": {
+ "name": "mainrepo",
+ "language": "python",
+ "id": 7940284,
+ "private": False,
+ "default_branch": "master",
+ "owner": {"id": 8495712939, "login": "alogin"},
+ },
+ },
+ },
+ )
+
+ assert owner.repository_set.filter(name="testrepo").exists()
diff --git a/apps/codecov-api/webhook_handlers/tests/test_gitlab.py b/apps/codecov-api/webhook_handlers/tests/test_gitlab.py
new file mode 100644
index 0000000000..8ee14074f9
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_gitlab.py
@@ -0,0 +1,313 @@
+import uuid
+from unittest.mock import patch
+
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+
+from core.models import Commit, PullStates
+from webhook_handlers.constants import (
+ GitLabHTTPHeaders,
+ GitLabWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+
+def get_config_mock(*args, **kwargs):
+ if args == ("setup", "enterprise_license"):
+ return False
+ elif args == ("gitlab", "webhook_validation"):
+ return False
+ else:
+ return kwargs.get("default")
+
+
+class TestGitlabWebhookHandler(APITestCase):
+ def _post_event_data(self, event, data):
+ return self.client.post(
+ reverse("gitlab-webhook"),
+ data=data,
+ format="json",
+ **{
+ GitLabHTTPHeaders.EVENT: event,
+ },
+ )
+
+ def setUp(self):
+ self.get_config_patcher = patch("webhook_handlers.views.gitlab.get_config")
+ self.get_config_mock = self.get_config_patcher.start()
+ self.get_config_mock.side_effect = get_config_mock
+
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service="gitlab"), service_id=123, active=True
+ )
+
+ def tearDown(self):
+ self.get_config_patcher.stop()
+
+ def test_unknown_repo(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH, data={"project_id": 1404}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_push_event_no_yaml_cached(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH,
+ data={"object_kind": "push", "project_id": self.repo.service_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "No yaml cached yet."
+
+ def test_push_event_yaml_cached(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH,
+ data={"object_kind": "push", "project_id": self.repo.service_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "No yaml cached yet."
+
+ def test_job_event_build_pending(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "pending",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES
+
+ def test_job_event_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ def test_job_event_commit_not_found(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ def test_job_event_commit_not_complete(self):
+ commit_sha = "2293ada6b400935a1378653304eaf6221e0fdb8f"
+ CommitFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ commitid=commit_sha,
+ state=Commit.CommitStates.PENDING,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ "sha": commit_sha,
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ @patch("services.task.TaskService.notify")
+ def test_job_event_triggers_notify(self, notify_mock):
+ commit_sha = "2293ada6b400935a1378653304eaf6221e0fdb8f"
+ commit = CommitFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ commitid=commit_sha,
+ state=Commit.CommitStates.COMPLETE,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ "sha": commit_sha,
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Notify queued."
+ notify_mock.assert_called_once_with(
+ repoid=self.repo.repoid, commitid=commit.commitid
+ )
+
+ def test_merge_request_event_repo_not_found(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {"target_project_id": 1404},
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_open(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "open",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Opening pull request in Codecov"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_merge_request_event_action_close(self):
+ pull = PullFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ pullid=1,
+ state=PullStates.OPEN,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "close",
+ "target_project_id": self.repo.service_id,
+ "iid": pull.pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request closed"
+
+ pull.refresh_from_db()
+ assert pull.state == PullStates.CLOSED
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_merge(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "merge",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request merged"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_update(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "update",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request synchronize queued"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_handle_system_hook_when_not_enterprise(self):
+ owner = OwnerFactory(service="gitlab")
+ repo = RepositoryFactory(author=owner)
+
+ system_hook_events = [
+ "project_create",
+ "project_destroy",
+ "project_rename",
+ "project_transfer",
+ "user_add_to_team",
+ "user_remove_from_team",
+ ]
+
+ event_data = {
+ "event": GitLabWebhookEvents.SYSTEM,
+ "data": {
+ "event_name": "project_create",
+ "project_id": repo.service_id,
+ },
+ }
+
+ for event in system_hook_events:
+ event_data["data"]["event_name"] = event
+ response = self._post_event_data(**event_data)
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_secret_validation(self):
+ owner = OwnerFactory(service="gitlab")
+ repo = RepositoryFactory(
+ author=owner,
+ service_id=uuid.uuid4(),
+ webhook_secret=uuid.uuid4(), # if repo has webhook secret, requires validation
+ )
+ owner.permission = [repo.repoid]
+ owner.save()
+
+ response = self.client.post(
+ reverse("gitlab-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: "",
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ response = self.client.post(
+ reverse("gitlab-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: repo.webhook_secret,
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_200_OK
diff --git a/apps/codecov-api/webhook_handlers/tests/test_gitlab_enterprise.py b/apps/codecov-api/webhook_handlers/tests/test_gitlab_enterprise.py
new file mode 100644
index 0000000000..d166ced9fb
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/tests/test_gitlab_enterprise.py
@@ -0,0 +1,892 @@
+import uuid
+from unittest.mock import patch
+
+import pytest
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase
+from shared.django_apps.core.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from shared.utils.test_utils import mock_config_helper
+
+from core.models import Commit, PullStates, Repository
+from webhook_handlers.constants import (
+ GitLabHTTPHeaders,
+ GitLabWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+
+class TestGitlabEnterpriseWebhookHandler(APITestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(request, mocker):
+ request.mocker = mocker
+
+ @pytest.fixture(autouse=True)
+ def mock_config(self, mocker):
+ mock_config_helper(
+ mocker,
+ configs={
+ "setup.enterprise_license": True,
+ "gitlab_enterprise.webhook_validation": False,
+ },
+ )
+
+ def _post_event_data(self, event, data, token=None):
+ return self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: event,
+ GitLabHTTPHeaders.TOKEN: token,
+ },
+ data=data,
+ format="json",
+ )
+
+ def setUp(self):
+ self.repo = RepositoryFactory(
+ author=OwnerFactory(service="gitlab_enterprise"),
+ service_id=123,
+ active=True,
+ )
+
+ def test_unknown_repo(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH, data={"project_id": 1404}
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_push_event_no_yaml_cached(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH,
+ data={"object_kind": "push", "project_id": self.repo.service_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "No yaml cached yet."
+
+ def test_push_event_yaml_cached(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.PUSH,
+ data={"object_kind": "push", "project_id": self.repo.service_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "No yaml cached yet."
+
+ def test_job_event_build_pending(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "pending",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES
+
+ def test_job_event_repo_not_active(self):
+ self.repo.active = False
+ self.repo.save()
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ def test_job_event_commit_not_found(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ def test_job_event_commit_not_complete(self):
+ commit_sha = "2293ada6b400935a1378653304eaf6221e0fdb8f"
+ CommitFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ commitid=commit_sha,
+ state=Commit.CommitStates.PENDING,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ "sha": commit_sha,
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == WebhookHandlerErrorMessages.SKIP_PROCESSING
+
+ @patch("services.task.TaskService.notify")
+ def test_job_event_triggers_notify(self, notify_mock):
+ commit_sha = "2293ada6b400935a1378653304eaf6221e0fdb8f"
+ commit = CommitFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ commitid=commit_sha,
+ state=Commit.CommitStates.COMPLETE,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.JOB,
+ data={
+ "object_kind": "build",
+ "project_id": self.repo.service_id,
+ "build_status": "success",
+ "sha": commit_sha,
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Notify queued."
+ notify_mock.assert_called_once_with(
+ repoid=self.repo.repoid, commitid=commit.commitid
+ )
+
+ def test_merge_request_event_repo_not_found(self):
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {"target_project_id": 1404},
+ },
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_open(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "open",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Opening pull request in Codecov"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_merge_request_event_action_close(self):
+ pull = PullFactory(
+ author=self.repo.author,
+ repository=self.repo,
+ pullid=1,
+ state=PullStates.OPEN,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "close",
+ "target_project_id": self.repo.service_id,
+ "iid": pull.pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request closed"
+
+ pull.refresh_from_db()
+ assert pull.state == PullStates.CLOSED
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_merge(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "merge",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request merged"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ @patch("services.task.TaskService.pulls_sync")
+ def test_merge_request_event_action_update(self, pulls_sync_mock):
+ pullid = 2
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.MERGE_REQUEST,
+ data={
+ "object_kind": "merge_request",
+ "object_attributes": {
+ "action": "update",
+ "target_project_id": self.repo.service_id,
+ "iid": pullid,
+ },
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Pull request synchronize queued"
+
+ pulls_sync_mock.assert_called_once_with(repoid=self.repo.repoid, pullid=pullid)
+
+ def test_handle_system_hook_not_enterprise(self):
+ mock_config_helper(self.mocker, configs={"setup.enterprise_license": None})
+ owner = OwnerFactory(service="gitlab_enterprise", username="jsmith")
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data={
+ "created_at": "2020-01-21T07:30:54Z",
+ "updated_at": "2020-01-21T07:38:22Z",
+ "event_name": "project_create",
+ "name": "StoreCloud",
+ "owner_email": "johnsmith@gmail.com",
+ "owner_name": "John Smith",
+ "path": "storecloud",
+ "path_with_namespace": f"{owner.username}/storecloud",
+ "project_id": 74,
+ "project_visibility": "private",
+ },
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ new_repo = Repository.objects.filter(
+ author__ownerid=owner.ownerid, service_id=74
+ ).first()
+ assert new_repo is None
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_project_create(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:54Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "project_create",
+ "name": "StoreCloud",
+ "owner_email": "johnsmith@example.com",
+ "owner_name": "John Smith",
+ "owners": [{"name": "John", "email": "user1@example.com"}],
+ "path": "storecloud",
+ "path_with_namespace": "jsmith/storecloud",
+ "project_id": 74,
+ "project_visibility": "private",
+ }
+
+ owner = OwnerFactory(
+ service="gitlab_enterprise",
+ username="jsmith",
+ name=sample_payload_from_gitlab_docs["owner_name"],
+ email=sample_payload_from_gitlab_docs["owner_email"],
+ oauth_token="123",
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_called_once_with(
+ ownerid=owner.ownerid,
+ username=owner.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_project_destroy(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:58Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "project_destroy",
+ "name": "Underscore",
+ "owner_email": "johnsmith@example.com",
+ "owner_name": "John Smith",
+ "owners": [{"name": "John", "email": "user1@example.com"}],
+ "path": "underscore",
+ "path_with_namespace": "jsmith/underscore",
+ "project_id": 73,
+ "project_visibility": "internal",
+ }
+
+ OwnerFactory(
+ service="gitlab_enterprise",
+ username="jsmith",
+ name=sample_payload_from_gitlab_docs["owner_name"],
+ email=sample_payload_from_gitlab_docs["owner_email"],
+ oauth_token="123",
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ )
+
+ repo = RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Repository deleted"
+
+ mock_refresh_task.assert_not_called()
+
+ repo.refresh_from_db()
+ assert repo.active is False
+ assert repo.activated is False
+ assert repo.deleted is True
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_project_rename(self, mock_refresh_task):
+ # testing get owner by namespace in payload
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:58Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "project_rename",
+ "name": "Underscore",
+ "path": "underscore",
+ "path_with_namespace": "jsmith/underscore",
+ "project_id": 73,
+ "owner_name": "John Smith",
+ "owner_email": "johnsmith@example.com",
+ "owners": [{"name": "John", "email": "user1@example.com"}],
+ "project_visibility": "internal",
+ "old_path_with_namespace": "jsmith/overscore",
+ }
+
+ OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token="123",
+ username="jsmith",
+ )
+
+ owner_bot = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token="123",
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ bot=owner_bot,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_called_once_with(
+ ownerid=owner_bot.ownerid,
+ username=owner_bot.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_project_transfer(self, mock_refresh_task):
+ # moving this repo from one namespace to another
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:58Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "project_transfer",
+ "name": "Underscore",
+ "path": "underscore",
+ "path_with_namespace": "scores/underscore",
+ "project_id": 73,
+ "owner_name": "John Smith",
+ "owner_email": "johnsmith@example.com",
+ "owners": [{"name": "John", "email": "user1@example.com"}],
+ "project_visibility": "internal",
+ "old_path_with_namespace": "jsmith/overscore",
+ }
+
+ owner_user = OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["owner_name"],
+ email=sample_payload_from_gitlab_docs["owner_email"],
+ oauth_token="123",
+ )
+
+ non_usable_bot = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ bot=non_usable_bot,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_called_once_with(
+ ownerid=owner_user.ownerid,
+ username=owner_user.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_user_create(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:44:07Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "email": "js@gitlabhq.com",
+ "event_name": "user_create",
+ "name": "John Smith",
+ "username": "js",
+ "user_id": 41,
+ }
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ mock_refresh_task.assert_not_called()
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_user_add_to_team(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:56Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "user_add_to_team",
+ "access_level": "Maintainer",
+ "project_id": 74,
+ "project_name": "StoreCloud",
+ "project_path": "storecloud",
+ "project_path_with_namespace": "jsmith/storecloud",
+ "user_email": "johnsmith@example.com",
+ "user_name": "John Smith",
+ "user_username": "johnsmith",
+ "user_id": 41,
+ "project_visibility": "private",
+ }
+
+ owner_user = OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["user_name"],
+ email=sample_payload_from_gitlab_docs["user_email"],
+ oauth_token="123",
+ username=sample_payload_from_gitlab_docs["user_username"],
+ service_id=sample_payload_from_gitlab_docs["user_id"],
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_called_once_with(
+ ownerid=owner_user.ownerid,
+ username=owner_user.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_user_add_to_team_repo_public(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:56Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "user_add_to_team",
+ "access_level": "Maintainer",
+ "project_id": 74,
+ "project_name": "StoreCloud",
+ "project_path": "storecloud",
+ "project_path_with_namespace": "jsmith/storecloud",
+ "user_email": "johnsmith@example.com",
+ "user_name": "John Smith",
+ "user_username": "johnsmith",
+ "user_id": 41,
+ "project_visibility": "public",
+ }
+
+ OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["user_name"],
+ email=sample_payload_from_gitlab_docs["user_email"],
+ oauth_token="123",
+ username=sample_payload_from_gitlab_docs["user_username"],
+ service_id=sample_payload_from_gitlab_docs["user_id"],
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data is None
+
+ mock_refresh_task.assert_not_called()
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_user_remove_from_team(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:56Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "user_remove_from_team",
+ "access_level": "Maintainer",
+ "project_id": 74,
+ "project_name": "StoreCloud",
+ "project_path": "storecloud",
+ "project_path_with_namespace": "jsmith/storecloud",
+ "user_email": "johnsmith@example.com",
+ "user_name": "John Smith",
+ "user_username": "johnsmith",
+ "user_id": 41,
+ "project_visibility": "private",
+ }
+
+ owner_user = OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["user_name"],
+ email=sample_payload_from_gitlab_docs["user_email"],
+ oauth_token="123",
+ username=sample_payload_from_gitlab_docs["user_username"],
+ service_id=sample_payload_from_gitlab_docs["user_id"],
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_called_once_with(
+ ownerid=owner_user.ownerid,
+ username=owner_user.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_unknown_repo(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:56Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "user_add_to_team",
+ "access_level": "Maintainer",
+ "project_id": 74,
+ "project_name": "StoreCloud",
+ "project_path": "storecloud",
+ "project_path_with_namespace": "jsmith/storecloud",
+ "user_email": "johnsmith@example.com",
+ "user_name": "John Smith",
+ "user_username": "johnsmith",
+ "user_id": 41,
+ "project_visibility": "private",
+ }
+
+ OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["user_name"],
+ email=sample_payload_from_gitlab_docs["user_email"],
+ oauth_token="123",
+ username=sample_payload_from_gitlab_docs["user_username"],
+ service_id=sample_payload_from_gitlab_docs["user_id"],
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"] + 1,
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_user_add_to_team_unknown_user(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:56Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "user_add_to_team",
+ "access_level": "Maintainer",
+ "project_id": 74,
+ "project_name": "StoreCloud",
+ "project_path": "storecloud",
+ "project_path_with_namespace": "jsmith/storecloud",
+ "user_email": "johnsmith@example.com",
+ "user_name": "John Smith",
+ "user_username": "johnsmith",
+ "user_id": 41,
+ "project_visibility": "private",
+ }
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_not_called()
+
+ @patch("services.refresh.RefreshService.trigger_refresh")
+ def test_handle_system_hook_no_bot_or_user_match(self, mock_refresh_task):
+ sample_payload_from_gitlab_docs = {
+ "created_at": "2012-07-21T07:30:58Z",
+ "updated_at": "2012-07-21T07:38:22Z",
+ "event_name": "project_rename",
+ "name": "Underscore",
+ "path": "underscore",
+ "path_with_namespace": "jsmith/underscore",
+ "project_id": 73,
+ "owner_name": "John Smith",
+ "owner_email": "johnsmith@example.com",
+ "owners": [{"name": "John", "email": "user1@example.com"}],
+ "project_visibility": "internal",
+ "old_path_with_namespace": "jsmith/overscore",
+ }
+
+ OwnerFactory(
+ service="gitlab_enterprise",
+ name=sample_payload_from_gitlab_docs["owner_name"],
+ oauth_token="123",
+ )
+
+ owner_org = OwnerFactory(
+ service="gitlab_enterprise",
+ oauth_token=None,
+ username="jsmith",
+ )
+
+ RepositoryFactory(
+ author=owner_org,
+ service_id=sample_payload_from_gitlab_docs["project_id"],
+ active=True,
+ activated=True,
+ deleted=False,
+ )
+
+ response = self._post_event_data(
+ event=GitLabWebhookEvents.SYSTEM,
+ data=sample_payload_from_gitlab_docs,
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data == "Sync initiated"
+
+ mock_refresh_task.assert_not_called()
+
+ def test_secret_validation(self):
+ owner = OwnerFactory(service="gitlab_enterprise")
+ repo = RepositoryFactory(
+ author=owner,
+ service_id=uuid.uuid4(),
+ webhook_secret=uuid.uuid4(), # if repo has webhook secret, requires validation
+ )
+ owner.permission = [repo.repoid]
+ owner.save()
+
+ response = self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: "",
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ response = self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: repo.webhook_secret,
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_secret_validation_required_by_config(self):
+ webhook_secret = uuid.uuid4()
+ # if repo has webhook_validation config set to True, requires validation
+ mock_config_helper(
+ self.mocker,
+ configs={
+ "gitlab_enterprise.webhook_validation": True,
+ },
+ )
+ owner = OwnerFactory(service="gitlab_enterprise")
+ repo = RepositoryFactory(
+ author=owner,
+ service_id=uuid.uuid4(),
+ webhook_secret=None,
+ )
+ owner.permission = [repo.repoid]
+ owner.save()
+
+ response = self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: "",
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ response = self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: webhook_secret,
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ repo.webhook_secret = webhook_secret
+ repo.save()
+ response = self.client.post(
+ reverse("gitlab_enterprise-webhook"),
+ **{
+ GitLabHTTPHeaders.EVENT: "",
+ GitLabHTTPHeaders.TOKEN: webhook_secret,
+ },
+ data={
+ "project_id": repo.service_id,
+ },
+ format="json",
+ )
+ assert response.status_code == status.HTTP_200_OK
diff --git a/apps/codecov-api/webhook_handlers/urls.py b/apps/codecov-api/webhook_handlers/urls.py
new file mode 100644
index 0000000000..ff73bb7a64
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/urls.py
@@ -0,0 +1,31 @@
+from django.urls import path
+
+# to remove when in production we send the webhooks to /billing/stripe/webhooks
+from billing.views import StripeWebhookHandler
+
+from .views.bitbucket import BitbucketWebhookHandler
+from .views.bitbucket_server import BitbucketServerWebhookHandler
+from .views.github import GithubEnterpriseWebhookHandler, GithubWebhookHandler
+from .views.gitlab import GitLabEnterpriseWebhookHandler, GitLabWebhookHandler
+
+urlpatterns = [
+ path("github", GithubWebhookHandler.as_view(), name="github-webhook"),
+ path(
+ "github_enterprise",
+ GithubEnterpriseWebhookHandler.as_view(),
+ name="github_enterprise-webhook",
+ ),
+ path("bitbucket", BitbucketWebhookHandler.as_view(), name="bitbucket-webhook"),
+ path("gitlab", GitLabWebhookHandler.as_view(), name="gitlab-webhook"),
+ path(
+ "gitlab_enterprise",
+ GitLabEnterpriseWebhookHandler.as_view(),
+ name="gitlab_enterprise-webhook",
+ ),
+ path(
+ "bitbucket_server",
+ BitbucketServerWebhookHandler.as_view(),
+ name="bitbucket-server-webhook",
+ ),
+ path("stripe", StripeWebhookHandler.as_view(), name="old-stripe-webhook"),
+]
diff --git a/apps/codecov-api/webhook_handlers/views/__init__.py b/apps/codecov-api/webhook_handlers/views/__init__.py
new file mode 100644
index 0000000000..93c20fabd6
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/views/__init__.py
@@ -0,0 +1,22 @@
+from shared.metrics import Counter
+
+WEBHOOKS_RECEIVED = Counter(
+ "api_webhooks_received",
+ "Incoming webhooks, broken down by service, event type, and action",
+ [
+ "service",
+ "event",
+ "action",
+ ],
+)
+
+WEBHOOKS_ERRORED = Counter(
+ "api_webhooks_errored",
+ "Webhooks that cannot be processed, broken down by service and error reason",
+ [
+ "service",
+ "event",
+ "action",
+ "error_reason",
+ ],
+)
diff --git a/apps/codecov-api/webhook_handlers/views/bitbucket.py b/apps/codecov-api/webhook_handlers/views/bitbucket.py
new file mode 100644
index 0000000000..5ea4655173
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/views/bitbucket.py
@@ -0,0 +1,138 @@
+import logging
+
+from django.shortcuts import get_object_or_404
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.helpers.yaml import walk
+
+from core.models import Branch, Commit, Pull, PullStates, Repository
+from services.task import TaskService
+from webhook_handlers.constants import (
+ BitbucketHTTPHeaders,
+ BitbucketWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+from . import WEBHOOKS_ERRORED, WEBHOOKS_RECEIVED
+
+log = logging.getLogger(__name__)
+
+
+class BitbucketWebhookHandler(APIView):
+ permission_classes = [AllowAny]
+ service_name = "bitbucket"
+
+ def _inc_recv(self):
+ event, _, action = self.event.partition(":")
+ WEBHOOKS_RECEIVED.labels(
+ service=self.service_name, event=event, action=action
+ ).inc()
+
+ def _inc_err(self, reason: str):
+ event, _, action = self.event.partition(":")
+ WEBHOOKS_ERRORED.labels(
+ service=self.service_name,
+ event=event,
+ action=action,
+ error_reason=reason,
+ ).inc()
+
+ def post(self, request, *args, **kwargs):
+ self.event = self.request.META.get(BitbucketHTTPHeaders.EVENT)
+ event_hook_id = self.request.META.get(BitbucketHTTPHeaders.UUID)
+
+ try:
+ repo = get_object_or_404(
+ Repository,
+ author__service="bitbucket",
+ service_id=self.request.data["repository"]["uuid"][1:-1],
+ hookid=event_hook_id,
+ )
+ except Exception as e:
+ self._inc_err("repo_not_found")
+ raise e
+
+ if not repo.active:
+ self._inc_err("repo_not_active")
+ return Response(data=WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE)
+
+ log.info(
+ "Bitbucket webhook message received",
+ extra=dict(event=self.event, hookid=event_hook_id, repoid=repo.repoid),
+ )
+
+ if self.event == BitbucketWebhookEvents.PULL_REQUEST_CREATED:
+ self._inc_recv()
+ return self._handle_pull_request_created_event(repo)
+ elif self.event in (
+ BitbucketWebhookEvents.PULL_REQUEST_FULFILLED,
+ BitbucketWebhookEvents.PULL_REQUEST_REJECTED,
+ ):
+ self._inc_recv()
+ return self._handle_pull_request_state_change(repo)
+ elif self.event == BitbucketWebhookEvents.REPO_PUSH:
+ self._inc_recv()
+ return self._handle_repo_push_event(repo)
+ elif self.event in (
+ BitbucketWebhookEvents.REPO_COMMIT_STATUS_CREATED,
+ BitbucketWebhookEvents.REPO_COMMIT_STATUS_UPDATED,
+ ):
+ self._inc_recv()
+ return self._handle_repo_commit_status_change(repo)
+
+ self._inc_err("unhandled_event")
+ return Response()
+
+ def _handle_pull_request_created_event(self, repo):
+ TaskService().pulls_sync(
+ repoid=repo.repoid, pullid=self.request.data["pullrequest"]["id"]
+ )
+ return Response(data="Opening pull request in Codecov")
+
+ def _handle_pull_request_state_change(self, repo):
+ state = {
+ BitbucketWebhookEvents.PULL_REQUEST_FULFILLED: PullStates.MERGED,
+ BitbucketWebhookEvents.PULL_REQUEST_REJECTED: PullStates.CLOSED,
+ }.get(self.event)
+
+ Pull.objects.filter(
+ repository__repoid=repo.repoid,
+ pullid=self.request.data["pullrequest"]["id"],
+ ).update(state=state)
+
+ return Response()
+
+ def _handle_repo_push_event(self, repo):
+ for change in self.request.data["push"]["changes"]:
+ if walk(change, ("old", "type")) == "branch" and change["new"] is None:
+ # when a branch is deleted, new is null
+ branch_name = change["old"]["name"]
+ Branch.objects.filter(repository=repo, name=branch_name).delete()
+
+ for change in self.request.data["push"]["changes"]:
+ if change["new"]:
+ return Response(data="Synchronize codecov.yml skipped")
+
+ return Response()
+
+ def _handle_repo_commit_status_change(self, repo):
+ if self.request.data["commit_status"]["key"].startswith("codecov"):
+ # a codecov/* context
+ return Response(data=WebhookHandlerErrorMessages.SKIP_CODECOV_STATUS)
+
+ if self.request.data["commit_status"]["state"] == "INPROGRESS":
+ # skip pending
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES)
+
+ commitid = self.request.data["commit_status"]["links"]["commit"]["href"].split(
+ "/"
+ )[-1]
+
+ if not Commit.objects.filter(
+ repository=repo, commitid=commitid, state=Commit.CommitStates.COMPLETE
+ ).exists():
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PROCESSING)
+
+ TaskService().notify(repoid=repo.repoid, commitid=commitid)
+ return Response(data="Notify queued")
diff --git a/apps/codecov-api/webhook_handlers/views/bitbucket_server.py b/apps/codecov-api/webhook_handlers/views/bitbucket_server.py
new file mode 100644
index 0000000000..99b5423683
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/views/bitbucket_server.py
@@ -0,0 +1,114 @@
+import logging
+
+from django.shortcuts import get_object_or_404
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from core.models import Branch, Pull, PullStates, Repository
+from services.task import TaskService
+from webhook_handlers.constants import (
+ BitbucketServerHTTPHeaders,
+ BitbucketServerWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+from . import WEBHOOKS_ERRORED, WEBHOOKS_RECEIVED
+
+log = logging.getLogger(__name__)
+
+
+class BitbucketServerWebhookHandler(APIView):
+ # https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html
+ permission_classes = [AllowAny]
+ service_name = "bitbucket_server"
+
+ def _inc_recv(self):
+ event, _, action = self.event.partition(":")
+ WEBHOOKS_RECEIVED.labels(
+ service=self.service_name, event=event, action=action
+ ).inc()
+
+ def _inc_err(self, reason: str):
+ event, _, action = self.event.partition(":")
+ WEBHOOKS_ERRORED.labels(
+ service=self.service_name,
+ event=event,
+ action=action,
+ error_reason=reason,
+ ).inc()
+
+ def _get_repo(self, event, body):
+ if event.startswith("repo:"):
+ repo_id = body["repository"]["id"]
+ elif event.startswith("pr:"):
+ repo_id = body["pullRequest"]["toRef"]["repository"]["id"]
+
+ try:
+ return get_object_or_404(
+ Repository, author__service="bitbucket_server", service_id=repo_id
+ )
+ except Exception as e:
+ self._inc_err("repo_not_found")
+ raise e
+
+ def post(self, request, *args, **kwargs):
+ self.event = self.request.META.get(BitbucketServerHTTPHeaders.EVENT)
+ event_hook_id = self.request.META.get(BitbucketServerHTTPHeaders.UUID)
+
+ repo = self._get_repo(self.event, self.request.data)
+ if not repo.active:
+ self._inc_err("repo_not_active")
+ return Response(data=WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE)
+
+ log.info(
+ "BitbucketServer webhook message received",
+ extra=dict(event=self.event, hookid=event_hook_id, repoid=repo.repoid),
+ )
+
+ if self.event == BitbucketServerWebhookEvents.PULL_REQUEST_CREATED:
+ self._inc_recv()
+ return self._handle_pull_request_created_event(repo)
+ elif self.event in (
+ BitbucketServerWebhookEvents.PULL_REQUEST_MERGED,
+ BitbucketServerWebhookEvents.PULL_REQUEST_REJECTED,
+ ):
+ self._inc_recv()
+ return self._handle_pull_request_state_change(repo)
+ elif self.event == BitbucketServerWebhookEvents.REPO_REFS_CHANGED:
+ self._inc_recv()
+ return self._handle_repo_refs_change(repo)
+
+ self._inc_err("unhandled_event")
+ return Response()
+
+ def _handle_pull_request_created_event(self, repo):
+ TaskService().pulls_sync(
+ repoid=repo.repoid, pullid=self.request.data["pullRequest"]["id"]
+ )
+ return Response(data="Opening pull request in Codecov")
+
+ def _handle_pull_request_state_change(self, repo):
+ state = {
+ BitbucketServerWebhookEvents.PULL_REQUEST_MERGED: PullStates.MERGED,
+ BitbucketServerWebhookEvents.PULL_REQUEST_DELETED: PullStates.CLOSED,
+ BitbucketServerWebhookEvents.PULL_REQUEST_REJECTED: PullStates.CLOSED,
+ }.get(self.event)
+
+ Pull.objects.filter(
+ repository__repoid=repo.repoid,
+ pullid=self.request.data["pullRequest"]["id"],
+ ).update(state=state)
+
+ return Response()
+
+ def _handle_repo_refs_change(self, repo):
+ ref_type = self.request.data["push"]["changes"]["old"]["type"]
+ if ref_type == "branch":
+ Branch.objects.filter(
+ repository=repo,
+ name=self.request.data["push"]["changes"]["old"]["name"],
+ ).delete()
+ if self.request.data["push"]["changes"]["new"]:
+ return Response(data="Synchronize codecov.yml skipped")
+ return Response()
diff --git a/apps/codecov-api/webhook_handlers/views/github.py b/apps/codecov-api/webhook_handlers/views/github.py
new file mode 100644
index 0000000000..b3d3a56112
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/views/github.py
@@ -0,0 +1,766 @@
+import hmac
+import logging
+import re
+from contextlib import suppress
+from hashlib import sha1, sha256
+from typing import Optional
+
+from django.db.models import Q
+from django.utils import timezone
+from django.utils.crypto import constant_time_compare
+from rest_framework import status
+from rest_framework.exceptions import NotFound, PermissionDenied
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from shared.events.amplitude import AmplitudeEventPublisher
+from shared.helpers.redis import get_redis_connection
+
+from codecov_auth.models import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+ Owner,
+)
+from core.models import Branch, Commit, Pull, Repository
+from services.billing import BillingService
+from services.task import TaskService
+from utils.config import get_config
+from webhook_handlers.constants import (
+ GitHubHTTPHeaders,
+ GitHubWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+from . import WEBHOOKS_ERRORED, WEBHOOKS_RECEIVED
+
+log = logging.getLogger(__name__)
+
+
+# This should probably go somewhere where it can be easily shared
+regexp_ci_skip = re.compile(r"\[(ci|skip| |-){3,}\]").search
+
+
+class GithubWebhookHandler(APIView):
+ """
+ GitHub Webhook Handler. Method names correspond to events as defined in
+
+ webhook_handlers.constants.GitHubWebhookEvents
+ """
+
+ permission_classes = [AllowAny]
+ redis = get_redis_connection()
+
+ service_name = "github"
+
+ def _inc_recv(self):
+ action = self.request.data.get("action", "")
+ WEBHOOKS_RECEIVED.labels(
+ service=self.service_name, event=self.event, action=action
+ ).inc()
+
+ def _inc_err(self, reason: str):
+ action = self.request.data.get("action", "")
+ WEBHOOKS_ERRORED.labels(
+ service=self.service_name,
+ event=self.event,
+ action=action,
+ error_reason=reason,
+ ).inc()
+
+ def validate_signature(self, request):
+ key = get_config(
+ self.service_name,
+ "webhook_secret",
+ default=b"testixik8qdauiab1yiffydimvi72ekq",
+ )
+ if isinstance(key, str):
+ # If "key" comes from k8s secret, it is of type str, so
+ # must convert to bytearray for use with hmac
+ key = bytes(key, "utf-8")
+
+ expected_sig = None
+ computed_sig = None
+ if GitHubHTTPHeaders.SIGNATURE_256 in request.META:
+ expected_sig = request.META.get(GitHubHTTPHeaders.SIGNATURE_256)
+ computed_sig = (
+ "sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest()
+ )
+ elif GitHubHTTPHeaders.SIGNATURE in request.META:
+ expected_sig = request.META.get(GitHubHTTPHeaders.SIGNATURE)
+ computed_sig = (
+ "sha1=" + hmac.new(key, request.body, digestmod=sha1).hexdigest()
+ )
+
+ if (
+ computed_sig is None
+ or expected_sig is None
+ or len(computed_sig) != len(expected_sig)
+ or not constant_time_compare(computed_sig, expected_sig)
+ ):
+ self._inc_err("validation_failed")
+ raise PermissionDenied()
+
+ def unhandled_webhook_event(self, request, *args, **kwargs):
+ return Response(data=WebhookHandlerErrorMessages.UNSUPPORTED_EVENT)
+
+ def _get_repo(self, request):
+ """
+ Attempts to fetch the repo first via the index on o(wnerid, service_id),
+ then naively on service, service_id if that fails.
+ """
+ repo_data = self.request.data.get("repository", {})
+ repo_service_id = repo_data.get("id")
+ owner_service_id = repo_data.get("owner", {}).get("id")
+ repo_slug = repo_data.get("full_name")
+
+ try:
+ owner = Owner.objects.get(
+ service=self.service_name, service_id=owner_service_id
+ )
+ except Owner.DoesNotExist:
+ log.info(
+ f"Error fetching owner with service_id {owner_service_id}, "
+ f"using repository service id to get repo",
+ extra=dict(repo_service_id=repo_service_id, repo_slug=repo_slug),
+ )
+ try:
+ log.info(
+ "Unable to find repository owner, fetching repo with service, service_id",
+ extra=dict(repo_service_id=repo_service_id, repo_slug=repo_slug),
+ )
+ return Repository.objects.get(
+ author__service=self.service_name, service_id=repo_service_id
+ )
+ except Repository.DoesNotExist:
+ log.info(
+ "Received event for non-existent repository",
+ extra=dict(repo_service_id=repo_service_id, repo_slug=repo_slug),
+ )
+ self._inc_err("repo_not_found")
+ raise NotFound("Repository does not exist")
+ else:
+ try:
+ log.debug(
+ "Found repository owner, fetching repo with ownerid, service_id",
+ extra=dict(repo_service_id=repo_service_id, repo_slug=repo_slug),
+ )
+ return Repository.objects.get(
+ author__ownerid=owner.ownerid, service_id=repo_service_id
+ )
+ except Repository.DoesNotExist:
+ default_ghapp_installation = owner.github_app_installations.filter(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ ).first()
+ if default_ghapp_installation or owner.integration_id:
+ log.info(
+ "Repository no found but owner is using integration, creating repository"
+ )
+ return Repository.objects.get_or_create_from_git_repo(
+ repo_data, owner
+ )[0]
+ log.info(
+ "Received event for non-existent repository",
+ extra=dict(repo_service_id=repo_service_id, repo_slug=repo_slug),
+ )
+ self._inc_err("repo_not_found")
+ raise NotFound("Repository does not exist")
+
+ def ping(self, request, *args, **kwargs):
+ return Response(data="pong")
+
+ def repository(self, request, *args, **kwargs):
+ action, repo = self.request.data.get("action"), self._get_repo(request)
+ if action == "publicized":
+ repo.private, repo.activated = False, False
+ repo.save()
+ log.info(
+ "Repository publicized",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ elif action == "privatized":
+ repo.private = True
+ repo.save()
+ log.info(
+ "Repository privatized",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ elif action == "deleted":
+ log.info(f"Request to delete repository: {repo.repoid}")
+ repo.deleted = True
+ repo.activated = False
+ repo.active = False
+ repo.name = f"{repo.name}-deleted"
+ repo.save(update_fields=["deleted", "activated", "active", "name"])
+ log.info(
+ "Repository soft-deleted",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ else:
+ log.warning(
+ f"Unknown repository action: {action}", extra=dict(repoid=repo.repoid)
+ )
+ return Response()
+
+ def delete(self, request, *args, **kwargs):
+ ref_type = request.data.get("ref_type", "")
+ repo = self._get_repo(request)
+ if ref_type != "branch":
+ log.info(
+ f"Unsupported ref type: {ref_type}, exiting",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response("Unsupported ref type")
+ branch_name = self.request.data.get("ref")[11:]
+ Branch.objects.filter(
+ repository=self._get_repo(request), name=branch_name
+ ).delete()
+ log.info(
+ f"Branch '{branch_name}' deleted",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response()
+
+ def public(self, request, *args, **kwargs):
+ repo = self._get_repo(request)
+ repo.private, repo.activated = False, False
+ repo.save()
+ log.info(
+ "Repository publicized",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response()
+
+ def push(self, request, *args, **kwargs):
+ ref_type = "branch" if request.data.get("ref", "")[5:10] == "heads" else "tag"
+ repo = self._get_repo(request)
+ if ref_type != "branch":
+ log.debug(
+ "Ref is tag, not branch, ignoring push event",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response("Unsupported ref type")
+
+ if not repo.active:
+ log.debug(
+ "Repository is not active, ignoring push event",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE)
+
+ push_webhook_ignore_repos = get_config(
+ "setup", "push_webhook_ignore_repo_names", default=[]
+ )
+ if repo.name in push_webhook_ignore_repos:
+ log.debug(
+ "Codecov is configured to ignore this repository name",
+ extra=dict(
+ repoid=repo.repoid,
+ github_webhook_event=self.event,
+ repo_name=repo.name,
+ ),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_WEBHOOK_IGNORED)
+
+ pushed_to_branch_name = self.request.data.get("ref")[11:]
+ commits = self.request.data.get("commits", [])
+
+ if not commits:
+ log.debug(
+ f"No commits in webhook payload for branch {pushed_to_branch_name}",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response()
+
+ if pushed_to_branch_name == repo.branch:
+ commits_queryset = Commit.objects.filter(
+ ~Q(branch=pushed_to_branch_name),
+ repository=repo,
+ commitid__in=[commit.get("id") for commit in commits],
+ merged=False,
+ )
+ commits_queryset.update(branch=pushed_to_branch_name, merged=True)
+ log.info(
+ f"Branch name updated for commits to {pushed_to_branch_name}; setting merged to True",
+ extra=dict(
+ repoid=repo.repoid,
+ github_webhook_event=self.event,
+ commits=[commit.get("id") for commit in commits],
+ ),
+ )
+
+ most_recent_commit = commits[-1]
+
+ if regexp_ci_skip(most_recent_commit.get("message")):
+ log.info(
+ "CI skip tag on head commit, not setting status",
+ extra=dict(
+ repoid=repo.repoid,
+ commit=most_recent_commit.get("id"),
+ github_webhook_event=self.event,
+ ),
+ )
+ return Response(data="CI Skipped")
+
+ if self.redis.sismember("beta.pending", repo.repoid):
+ log.info(
+ "Triggering status set pending task",
+ extra=dict(
+ repoid=repo.repoid,
+ commit=most_recent_commit.get("id"),
+ github_webhook_event=self.event,
+ ),
+ )
+ TaskService().status_set_pending(
+ repoid=repo.repoid,
+ commitid=most_recent_commit.get("id"),
+ branch=pushed_to_branch_name,
+ on_a_pull_request=False,
+ )
+
+ return Response()
+
+ def status(self, request, *args, **kwargs):
+ repo = self._get_repo(request)
+ commitid = request.data.get("sha")
+
+ if not repo.active:
+ log.debug(
+ "Repository is not active, ignoring status event",
+ extra=dict(
+ repoid=repo.repoid, commit=commitid, github_webhook_event=self.event
+ ),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE)
+ if request.data.get("context", "")[:8] == "codecov/":
+ log.debug(
+ "Recieved a web hook for a Codecov status from GitHub. We ignore these, skipping.",
+ extra=dict(
+ repoid=repo.repoid, commit=commitid, github_webhook_event=self.event
+ ),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_CODECOV_STATUS)
+ if request.data.get("state") == "pending":
+ log.debug(
+ "Recieved a web hook for a `pending` status from GitHub. We ignore these, skipping.",
+ extra=dict(
+ repoid=repo.repoid, commit=commitid, github_webhook_event=self.event
+ ),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES)
+
+ if not Commit.objects.filter(
+ repository=repo, commitid=commitid, state="complete"
+ ).exists():
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PROCESSING)
+
+ log.info(
+ "Triggering notify task",
+ extra=dict(
+ repoid=repo.repoid, commit=commitid, github_webhook_event=self.event
+ ),
+ )
+
+ TaskService().notify(repoid=repo.repoid, commitid=commitid)
+
+ return Response()
+
+ def pull_request(self, request, *args, **kwargs):
+ repo = self._get_repo(request)
+
+ if not repo.active:
+ log.info(
+ "Repository is not active, ignoring pull request event",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response(data=WebhookHandlerErrorMessages.SKIP_NOT_ACTIVE)
+
+ action, pullid = request.data.get("action"), request.data.get("number")
+
+ if action in ["opened", "closed", "reopened", "synchronize", "labeled"]:
+ log.info(
+ f"Pull request action is '{action}', triggering pulls_sync task",
+ extra=dict(
+ repoid=repo.repoid, github_webhook_event=self.event, pullid=pullid
+ ),
+ )
+ TaskService().pulls_sync(repoid=repo.repoid, pullid=pullid)
+ elif action == "edited":
+ log.info(
+ f"Pull request action is 'edited', updating pull title to "
+ f"'{request.data.get('pull_request', {}).get('title')}'",
+ extra=dict(
+ repoid=repo.repoid, github_webhook_event=self.event, pullid=pullid
+ ),
+ )
+ Pull.objects.filter(repository=repo, pullid=pullid).update(
+ title=request.data.get("pull_request", {}).get("title")
+ )
+
+ return Response()
+
+ def _decide_app_name(self, ghapp: GithubAppInstallation) -> str:
+ """Possibly updated the name of a GithubAppInstallation that has been fetched from DB or created.
+ Only the real default installation maybe use the name `GITHUB_APP_INSTALLATION_DEFAULT_NAME`
+ (otherwise we break the app)
+ We check that apps:
+ * already were given a custom name (do nothing);
+ * app_id match the configured default app app_id (use default name);
+ * none of the above (use 'unconfigured_app');
+
+ Returns the app name that should be used
+ """
+ if ghapp.is_configured():
+ return ghapp.name
+ log.warning(
+ "Github installation is unconfigured. Changing name to 'unconfigured_app'",
+ extra=dict(installation=ghapp.external_id, previous_name=ghapp.name),
+ )
+ return "unconfigured_app"
+
+ def _handle_installation_repository_events(self, request, *args, **kwargs):
+ # https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories
+ service_id = request.data["installation"]["account"]["id"]
+ username = request.data["installation"]["account"]["login"]
+ owner, _ = Owner.objects.get_or_create(
+ service=self.service_name,
+ service_id=service_id,
+ defaults={
+ "username": username,
+ "createstamp": timezone.now(),
+ },
+ )
+
+ installation_id = request.data["installation"]["id"]
+
+ ghapp_installation, _ = GithubAppInstallation.objects.get_or_create(
+ installation_id=installation_id, owner=owner
+ )
+ app_id = request.data["installation"]["app_id"]
+ # Either update or set
+ # But this value shouldn't change for the installation, so doesn't matter
+ ghapp_installation.app_id = app_id
+ ghapp_installation.name = self._decide_app_name(ghapp_installation)
+
+ all_repos_affected = request.data.get("repository_selection") == "all"
+ if all_repos_affected:
+ ghapp_installation.repository_service_ids = None
+ else:
+ repo_list_to_save = set(ghapp_installation.repository_service_ids or [])
+ repositories_added_service_ids = {
+ obj["id"] for obj in request.data.get("repositories_added", [])
+ }
+ repositories_removed_service_ids = {
+ obj["id"] for obj in request.data.get("repositories_removed", [])
+ }
+ repo_list_to_save = repo_list_to_save.union(
+ repositories_added_service_ids
+ ).difference(repositories_removed_service_ids)
+ ghapp_installation.repository_service_ids = list(repo_list_to_save)
+ ghapp_installation.save()
+
+ def _handle_installation_events(
+ self, request, *args, event=GitHubWebhookEvents.INSTALLATION, **kwargs
+ ):
+ service_id = request.data["installation"]["account"]["id"]
+ username = request.data["installation"]["account"]["login"]
+ action = request.data.get("action")
+
+ owner, _ = Owner.objects.get_or_create(
+ service=self.service_name,
+ service_id=service_id,
+ defaults={
+ "username": username,
+ "createstamp": timezone.now(),
+ },
+ )
+
+ installation_id = request.data["installation"]["id"]
+
+ # https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation
+ if action == "deleted":
+ if event == GitHubWebhookEvents.INSTALLATION:
+ ghapp_installation: Optional[GithubAppInstallation] = (
+ owner.github_app_installations.filter(
+ installation_id=installation_id
+ ).first()
+ )
+ if ghapp_installation is not None:
+ ghapp_installation.delete()
+ # Deprecated flow - BEGIN
+ owner.integration_id = None
+ owner.save()
+ owner.repository_set.all().update(using_integration=False, bot=None)
+ # Deprecated flow - END
+ log.info(
+ "Owner deleted app integration",
+ extra=dict(ownerid=owner.ownerid, github_webhook_event=self.event),
+ )
+ else:
+ # GithubWebhookEvents.INSTALLTION_REPOSITORIES also execute this code
+ # because of deprecated flow. But the GithubAppInstallation shouldn't be changed
+ if event == GitHubWebhookEvents.INSTALLATION:
+ ghapp_installation, was_created = (
+ GithubAppInstallation.objects.get_or_create(
+ installation_id=installation_id, owner=owner
+ )
+ )
+ if was_created:
+ installer_username = request.data.get("sender", {}).get(
+ "login", None
+ )
+ installer = (
+ Owner.objects.filter(
+ service=self.service_name,
+ username=installer_username,
+ ).first()
+ if installer_username
+ else None
+ )
+ # If installer does not exist, just attribute the action to the org owner.
+ AmplitudeEventPublisher().publish(
+ "App Installed",
+ {
+ "user_ownerid": installer.ownerid
+ if installer is not None
+ else owner.ownerid,
+ "ownerid": owner.ownerid,
+ },
+ )
+
+ app_id = request.data["installation"]["app_id"]
+ # Either update or set
+ # But this value shouldn't change for the installation, so doesn't matter
+ ghapp_installation.app_id = app_id
+ ghapp_installation.name = self._decide_app_name(ghapp_installation)
+
+ affects_all_repositories = (
+ request.data["installation"]["repository_selection"] == "all"
+ )
+ if affects_all_repositories:
+ ghapp_installation.repository_service_ids = None
+ else:
+ repositories_service_ids = [
+ obj["id"] for obj in request.data.get("repositories", [])
+ ]
+ ghapp_installation.repository_service_ids = repositories_service_ids
+
+ if action in ["suspend", "unsuspend"]:
+ log.info(
+ "Request to suspend/unsuspend App",
+ extra=dict(
+ action=action,
+ is_currently_suspended=ghapp_installation.is_suspended,
+ ownerid=owner.ownerid,
+ installation_id=request.data["installation"]["id"],
+ ),
+ )
+ ghapp_installation.is_suspended = action == "suspend"
+
+ ghapp_installation.save()
+
+ # This flow is deprecated and should be removed once the
+ # work on github app integration model is complete and backfilled
+ # Deprecated flow - BEGIN
+ if owner.integration_id is None:
+ owner.integration_id = request.data["installation"]["id"]
+ owner.save()
+ # Deprecated flow - END
+
+ log.info(
+ "Triggering refresh task to sync repos",
+ extra=dict(ownerid=owner.ownerid, github_webhook_event=self.event),
+ )
+
+ repos_affected = (
+ request.data.get("repositories", [])
+ + request.data.get("repositories_added", [])
+ + request.data.get("repositories_removed", [])
+ )
+ repos_affected_clean = {
+ (obj["id"], obj["node_id"]) for obj in repos_affected
+ }
+
+ TaskService().refresh(
+ ownerid=owner.ownerid,
+ username=username,
+ sync_teams=False,
+ sync_repos=True,
+ using_integration=True,
+ repos_affected=list(repos_affected_clean),
+ )
+
+ return Response(data="Integration webhook received")
+
+ def installation(self, request, *args, **kwargs):
+ return self._handle_installation_events(
+ request, *args, **kwargs, event=GitHubWebhookEvents.INSTALLATION
+ )
+
+ def installation_repositories(self, request, *args, **kwargs):
+ self._handle_installation_repository_events(request, *args, **kwargs)
+ # This is kept to preserve the logic for deprecated usage of owner.installation_id
+ # It can be removed once the move to codecov_auth.GithubAppInstallation is complete
+ return self._handle_installation_events(
+ request,
+ *args,
+ **kwargs,
+ event=GitHubWebhookEvents.INSTALLATION_REPOSITORIES,
+ )
+
+ def organization(self, request, *args, **kwargs):
+ action = request.data.get("action")
+ if action == "member_removed":
+ log.info(
+ f"Removing user with service-id {request.data['membership']['user']['id']} "
+ f"from organization with service-id {request.data['organization']['id']}",
+ extra=dict(github_webhook_event=self.event),
+ )
+
+ try:
+ org = Owner.objects.get(
+ service=self.service_name,
+ service_id=request.data["organization"]["id"],
+ )
+ except Owner.DoesNotExist:
+ log.info("Organization does not exist, exiting")
+ return Response(
+ status=status.HTTP_400_BAD_REQUEST,
+ data="Attempted to remove member from non-Codecov org failed",
+ )
+
+ try:
+ member = Owner.objects.get(
+ service=self.service_name,
+ service_id=request.data["membership"]["user"]["id"],
+ )
+ except Owner.DoesNotExist:
+ log.info(
+ f"Member with service-id {request.data['membership']['user']['id']} "
+ f"does not exist, exiting",
+ extra=dict(ownerid=org.ownerid, github_webhook_event=self.event),
+ )
+ return Response(
+ status=status.HTTP_400_BAD_REQUEST,
+ data="Attempted to remove non Codecov user from Codecov org failed",
+ )
+
+ # Force a sync for the removed member to remove their access to the
+ # org and its private repositories.
+ TaskService().refresh(
+ ownerid=member.ownerid,
+ username=member.username,
+ sync_teams=True,
+ sync_repos=True,
+ using_integration=False,
+ )
+
+ try:
+ if org.plan_activated_users:
+ org.plan_activated_users.remove(member.ownerid)
+ org.save(update_fields=["plan_activated_users"])
+ except ValueError:
+ pass
+
+ log.info(
+ f"User removal of {member.ownerid}, success",
+ extra=dict(ownerid=org.ownerid, github_webhook_event=self.event),
+ )
+
+ return Response()
+
+ def _handle_marketplace_events(self, request, *args, **kwargs):
+ log.info(
+ "Triggering sync_plans task", extra=dict(github_webhook_event=self.event)
+ )
+ with suppress(Exception):
+ # log if users purchase GHM plans while having a stripe plan
+ username = request.data["marketplace_purchase"]["account"]["login"]
+ new_plan_seats = request.data["marketplace_purchase"]["unit_count"]
+ new_plan_name = request.data["marketplace_purchase"]["plan"]["name"]
+ owner = Owner.objects.get(service=self.service_name, username=username)
+ subscription = BillingService(requesting_user=owner).get_subscription(owner)
+ if subscription.status == "active":
+ log.warning(
+ "GHM webhook - user purchasing but has a Stripe Subscription",
+ extra=dict(
+ username=username,
+ old_plan_name=subscription.plan.get("name", None),
+ old_plan_seats=subscription.quantity,
+ new_plan_name=new_plan_name,
+ new_plan_seats=new_plan_seats,
+ ),
+ )
+ TaskService().sync_plans(
+ sender=request.data["sender"],
+ account=request.data["marketplace_purchase"]["account"],
+ action=request.data["action"],
+ )
+ return Response()
+
+ def marketplace_purchase(self, request, *args, **kwargs):
+ return self._handle_marketplace_events(request, *args, **kwargs)
+
+ def member(self, request, *args, **kwargs):
+ action = request.data["action"]
+ if action == "removed":
+ repo = self._get_repo(request)
+ log.info(
+ "Request to remove read permissions for user",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ try:
+ member = Owner.objects.get(
+ service=self.service_name, service_id=request.data["member"]["id"]
+ )
+ except Owner.DoesNotExist:
+ log.info(
+ "Repository permissions unchanged -- owner doesn't exist",
+ extra=dict(repoid=repo.repoid, github_webhook_event=self.event),
+ )
+ return Response(status=status.HTTP_404_NOT_FOUND)
+
+ try:
+ member.permission.remove(repo.repoid)
+ member.save(update_fields=["permission"])
+ log.info(
+ "Successfully updated read permissions for repository",
+ extra=dict(
+ repoid=repo.repoid,
+ ownerid=member.ownerid,
+ github_webhook_event=self.event,
+ ),
+ )
+ except (ValueError, AttributeError):
+ log.info(
+ "Member didn't have read permissions, didn't update",
+ extra=dict(
+ repoid=repo.repoid,
+ ownerid=member.ownerid,
+ github_webhook_event=self.event,
+ ),
+ )
+
+ return Response()
+
+ def post(self, request, *args, **kwargs):
+ self.event = self.request.META.get(GitHubHTTPHeaders.EVENT)
+ log.info(
+ "GitHub Webhook Handler invoked",
+ extra=dict(
+ github_webhook_event=self.event,
+ delivery=self.request.META.get(GitHubHTTPHeaders.DELIVERY_TOKEN),
+ ),
+ )
+
+ self.validate_signature(request)
+
+ if handler := getattr(self, self.event, None):
+ self._inc_recv()
+ return handler(request, *args, **kwargs)
+ else:
+ self._inc_err("unhandled_event")
+ return self.unhandled_webhook_event(request, *args, **kwargs)
+
+
+class GithubEnterpriseWebhookHandler(GithubWebhookHandler):
+ service_name = "github_enterprise"
diff --git a/apps/codecov-api/webhook_handlers/views/gitlab.py b/apps/codecov-api/webhook_handlers/views/gitlab.py
new file mode 100644
index 0000000000..f9a79c6dc6
--- /dev/null
+++ b/apps/codecov-api/webhook_handlers/views/gitlab.py
@@ -0,0 +1,296 @@
+import logging
+
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from django.utils.crypto import constant_time_compare
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from codecov_auth.models import Owner
+from core.models import Commit, Pull, PullStates, Repository
+from services.refresh import RefreshService
+from services.task import TaskService
+from utils.config import get_config
+from webhook_handlers.constants import (
+ GitLabHTTPHeaders,
+ GitLabWebhookEvents,
+ WebhookHandlerErrorMessages,
+)
+
+from . import WEBHOOKS_ERRORED, WEBHOOKS_RECEIVED
+
+log = logging.getLogger(__name__)
+
+
+class GitLabWebhookHandler(APIView):
+ permission_classes = [AllowAny]
+ service_name = "gitlab"
+
+ def _inc_recv(self):
+ event_name = self.request.data.get("event_name")
+ if not event_name:
+ event_name = self.request.data.get("object_kind")
+ action = self.request.data.get("object_attributes", {}).get("action", "")
+
+ WEBHOOKS_RECEIVED.labels(
+ service=self.service_name, event=event_name, action=action
+ ).inc()
+
+ def _inc_err(self, reason: str):
+ event_name = self.request.data.get("event_name")
+ if not event_name:
+ event_name = self.request.data.get("object_kind")
+ action = self.request.data.get("object_attributes", {}).get("action", "")
+
+ WEBHOOKS_ERRORED.labels(
+ service=self.service_name,
+ event=event_name,
+ action=action,
+ error_reason=reason,
+ ).inc()
+
+ def post(self, request, *args, **kwargs):
+ """
+ Helpful docs for working with GitLab webhooks
+ https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#webhook-receiver-requirements
+ for those special system hooks: https://docs.gitlab.com/ee/administration/system_hooks.html#hooks-request-example
+ all the other hooks: https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html
+ """
+ event = self.request.META.get(GitLabHTTPHeaders.EVENT)
+
+ log.info("GitLab webhook message received", extra=dict(event=event))
+
+ project_id = request.data.get("project_id") or request.data.get(
+ "object_attributes", {}
+ ).get("target_project_id")
+
+ event_name = self.request.data.get(
+ "event_name", self.request.data.get("object_kind")
+ )
+
+ is_enterprise = True if get_config("setup", "enterprise_license") else False
+
+ # special case - only event that doesn't have a repo yet
+ if event_name == "project_create":
+ if event == GitLabWebhookEvents.SYSTEM and is_enterprise:
+ self._inc_recv()
+ return self._handle_system_project_create_hook_event()
+ else:
+ self._inc_err("permission_denied")
+ raise PermissionDenied()
+
+ try:
+ # all other events should correspond to a repo in the db
+ repo = get_object_or_404(
+ Repository, author__service=self.service_name, service_id=project_id
+ )
+ except Exception as e:
+ self._inc_err("repo_not_found")
+ raise e
+
+ webhook_validation = bool(
+ get_config(
+ self.service_name, "webhook_validation", default=False
+ ) # TODO: backfill migration then switch to True
+ )
+ if webhook_validation or repo.webhook_secret:
+ self._validate_secret(request, repo.webhook_secret)
+
+ if event == GitLabWebhookEvents.PUSH:
+ self._inc_recv()
+ return self._handle_push_event(repo)
+ elif event == GitLabWebhookEvents.JOB:
+ self._inc_recv()
+ return self._handle_job_event(repo)
+ elif event == GitLabWebhookEvents.MERGE_REQUEST:
+ self._inc_recv()
+ return self._handle_merge_request_event(repo)
+ elif event == GitLabWebhookEvents.SYSTEM:
+ # SYSTEM events have always been gated behind is_enterprise, requires an enterprise_license
+ if not is_enterprise:
+ self._inc_err("permission_denied")
+ raise PermissionDenied()
+ self._inc_recv()
+ return self._handle_system_hook_event(repo, event_name)
+
+ self._inc_err("unhandled_event")
+ return Response()
+
+ def _handle_push_event(self, repo):
+ """
+ Triggered when pushing to the repository except when pushing tags.
+
+ https://docs.gitlab.com/ce/user/project/integrations/webhooks.html#push-events
+ """
+ message = "No yaml cached yet."
+ return Response(data=message)
+
+ def _handle_job_event(self, repo):
+ """
+ Triggered on status change of a job.
+
+ This is equivalent to legacy "Build Hook" handling (https://gitlab.com/gitlab-org/gitlab-foss/issues/28226)
+
+ https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#job-events
+ """
+ if self.request.data.get("build_status") == "pending":
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PENDING_STATUSES)
+
+ if repo.active is not True:
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PROCESSING)
+
+ commitid = self.request.data.get("sha")
+
+ try:
+ commit = repo.commits.get(
+ commitid=commitid, state=Commit.CommitStates.COMPLETE
+ )
+ except Commit.DoesNotExist:
+ return Response(data=WebhookHandlerErrorMessages.SKIP_PROCESSING)
+
+ TaskService().notify(repoid=commit.repository.repoid, commitid=commitid)
+ return Response(data="Notify queued.")
+
+ def _handle_merge_request_event(self, repo):
+ """
+ Triggered when a new merge request is created, an existing merge request was updated/merged/closed or
+ a commit is added in the source branch.
+
+ https://docs.gitlab.com/ce/user/project/integrations/webhooks.html#merge-request-events
+ """
+ repoid = repo.repoid
+
+ pull = self.request.data.get("object_attributes", {})
+ action = pull.get("action")
+ message = None
+ if action == "open":
+ TaskService().pulls_sync(repoid=repoid, pullid=pull["iid"])
+ message = "Opening pull request in Codecov"
+
+ elif action == "close":
+ Pull.objects.filter(repository__repoid=repoid, pullid=pull["iid"]).update(
+ state=PullStates.CLOSED
+ )
+ message = "Pull request closed"
+
+ elif action == "merge":
+ TaskService().pulls_sync(repoid=repoid, pullid=pull["iid"])
+ message = "Pull request merged"
+
+ elif action == "update":
+ TaskService().pulls_sync(repoid=repoid, pullid=pull["iid"])
+ message = "Pull request synchronize queued"
+
+ else:
+ log.warning(
+ "Unhandled Gitlab webhook merge_request action",
+ extra=dict(action=action),
+ )
+
+ return Response(data=message)
+
+ def _initiate_sync_for_owner(self, owner):
+ """
+ default: will sync_teams and sync_repos for owner
+ sync_teams to update owner.organizations list (expired memberships are removed and new memberships are added),
+ and username, name, email, and avatar of each Org in owner.organizations.
+ sync_repos to update owner.permission list (private repo access),
+ and name, language, private, repoid, and deleted=False for each repo the owner has access to.
+ """
+ RefreshService().trigger_refresh(
+ ownerid=owner.ownerid,
+ username=owner.username,
+ using_integration=False,
+ manual_trigger=False,
+ )
+
+ def _try_initiate_sync_for_owner(self):
+ owner_email = self.request.data.get("owner_email")
+
+ # email is a strong identifier (GL users must have a unique email)
+ try:
+ owner = Owner.objects.get(
+ service=self.service_name,
+ oauth_token__isnull=False,
+ email=owner_email,
+ )
+ except (Owner.DoesNotExist, Owner.MultipleObjectsReturned):
+ # could be the username of the OwnerUser or OwnerOrg. Sync only works with an OwnerUser.
+ owner_username_best_guess = self.request.data.get(
+ "path_with_namespace", ""
+ ).split("/")[0]
+ try:
+ owner = Owner.objects.get(
+ service=self.service_name,
+ oauth_token__isnull=False,
+ username=owner_username_best_guess,
+ )
+ except (Owner.DoesNotExist, Owner.MultipleObjectsReturned):
+ return
+
+ self._initiate_sync_for_owner(owner)
+
+ def _handle_system_project_create_hook_event(self):
+ self._try_initiate_sync_for_owner()
+ return Response(data="Sync initiated")
+
+ def _try_initiate_sync_for_repo(self, repo):
+ # most GL repos have bots - try to sync with bot as Owner
+ if repo.bot:
+ bot_owner = Owner.objects.filter(
+ service=self.service_name,
+ ownerid=repo.bot.ownerid,
+ oauth_token__isnull=False,
+ ).first()
+ if bot_owner:
+ return self._initiate_sync_for_owner(owner=bot_owner)
+ self._try_initiate_sync_for_owner()
+
+ def _handle_system_hook_event(self, repo, event_name):
+ """
+ GitLab Enterprise instance can send system hooks for changes on user, group, project, etc
+ """
+ message = None
+
+ if event_name == "project_destroy":
+ repo.deleted = True
+ repo.activated = False
+ repo.active = False
+ repo.name = f"{repo.name}-deleted"
+ repo.save(update_fields=["deleted", "activated", "active", "name"])
+ message = "Repository deleted"
+
+ elif event_name in ("project_rename", "project_transfer"):
+ self._try_initiate_sync_for_repo(repo=repo)
+ message = "Sync initiated"
+
+ elif (
+ event_name in ("user_add_to_team", "user_remove_from_team")
+ and self.request.data.get("project_visibility") == "private"
+ ):
+ # the payload from these hooks includes the ownerid
+ ownerid = self.request.data.get("user_id")
+ user = Owner.objects.filter(
+ service=self.service_name,
+ service_id=ownerid,
+ oauth_token__isnull=False,
+ ).first()
+ message = "Sync initiated"
+ if user:
+ self._initiate_sync_for_owner(owner=user)
+
+ return Response(data=message)
+
+ def _validate_secret(self, request: HttpRequest, webhook_secret: str):
+ token = request.META.get(GitLabHTTPHeaders.TOKEN)
+ if token and webhook_secret:
+ if constant_time_compare(webhook_secret, token):
+ return
+ self._inc_err("validation_failed")
+ raise PermissionDenied()
+
+
+class GitLabEnterpriseWebhookHandler(GitLabWebhookHandler):
+ service_name = "gitlab_enterprise"
diff --git a/apps/worker b/apps/worker
deleted file mode 160000
index ad52223de9..0000000000
--- a/apps/worker
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit ad52223de9313e23dc140a3bf6248dab4646beb7
diff --git a/apps/worker/.dockerignore b/apps/worker/.dockerignore
new file mode 100644
index 0000000000..968559ca43
--- /dev/null
+++ b/apps/worker/.dockerignore
@@ -0,0 +1,2 @@
+service.json
+gha-creds-*.json
diff --git a/apps/worker/.envrc b/apps/worker/.envrc
new file mode 100644
index 0000000000..5647e8722a
--- /dev/null
+++ b/apps/worker/.envrc
@@ -0,0 +1,2 @@
+uv sync
+source .venv/bin/activate
\ No newline at end of file
diff --git a/apps/worker/.github/ISSUE_TEMPLATE/bug_report.md b/apps/worker/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000..86c0fb8633
--- /dev/null
+++ b/apps/worker/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,16 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+# 🐛 Bugs
+
+Do you want to file a bug report? [Please use our feedback repo instead](https://github.com/codecov/feedback/issues).
diff --git a/apps/worker/.github/ISSUE_TEMPLATE/feature_request.md b/apps/worker/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000..03313301fe
--- /dev/null
+++ b/apps/worker/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,16 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+# 📣 Feedback / 🐛 Bugs
+
+Do you want to file a bug report and/or feature request for Codecov? [Please use our feedback repo instead](https://github.com/codecov/feedback/issues).
diff --git a/apps/worker/.github/pull_request_template.md b/apps/worker/.github/pull_request_template.md
new file mode 100644
index 0000000000..9fcafcedc1
--- /dev/null
+++ b/apps/worker/.github/pull_request_template.md
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+### Legal Boilerplate
+
+Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. In 2022 this entity acquired Codecov and as result Sentry is going to need some rights from me in order to utilize my contributions in this PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
\ No newline at end of file
diff --git a/apps/worker/.github/workflows/cache_cleanup.yml b/apps/worker/.github/workflows/cache_cleanup.yml
new file mode 100644
index 0000000000..a8da8d01a7
--- /dev/null
+++ b/apps/worker/.github/workflows/cache_cleanup.yml
@@ -0,0 +1,33 @@
+name: cleanup caches by a branch
+on:
+ pull_request:
+ types:
+ - closed
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Cleanup
+ run: |
+ gh extension install actions/gh-actions-cache
+
+ REPO=${{ github.repository }}
+ BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
+
+ echo "Fetching list of cache key"
+ cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
+
+ ## Setting this to not fail the workflow while deleting cache keys.
+ set +e
+ echo "Deleting caches..."
+ for cacheKey in $cacheKeysForPR
+ do
+ gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
+ done
+ echo "Done"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/apps/worker/.github/workflows/ci.yml b/apps/worker/.github/workflows/ci.yml
new file mode 100644
index 0000000000..95fc3b0db6
--- /dev/null
+++ b/apps/worker/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: Worker CI
+
+on:
+ push:
+ tags:
+ - prod-*
+ branches:
+ - main
+ - staging
+ pull_request:
+ merge_group:
+
+permissions:
+ contents: "read"
+ id-token: "write"
+ issues: "write"
+ pull-requests: "write"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Run Lint
+
+ uses: codecov/gha-workflows/.github/workflows/lint.yml@v1.2.33
+
+ build:
+ name: Build Worker
+ uses: codecov/gha-workflows/.github/workflows/build-app.yml@v1.2.33
+
+ secrets: inherit
+ with:
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+ cache_file: "uv.lock"
+
+ test:
+ name: Test
+ needs: [build]
+ uses: codecov/gha-workflows/.github/workflows/run-tests.yml@v1.2.33
+
+ secrets: inherit
+ with:
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+
+ build-self-hosted:
+ name: Build Self Hosted Worker
+ needs: [build, test]
+
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ secrets: inherit
+ with:
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+ cache_file: "uv.lock"
+
+ staging:
+ name: Push Staging Image
+ needs: [build, test]
+ if: ${{ github.event_name == 'push' && (github.event.ref == 'refs/heads/main' || github.event.ref == 'refs/heads/staging') && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.33
+ secrets: inherit
+ with:
+ environment: staging
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+
+ production:
+ name: Push Production Image
+ needs: [build, test]
+ if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/push-env.yml@v1.2.33
+ secrets: inherit
+ with:
+ environment: production
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+
+ self-hosted:
+ name: Push Self Hosted Image
+ needs: [build-self-hosted, test]
+ secrets: inherit
+ if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ with:
+ push_rolling: true
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+ cache_file: "uv.lock"
diff --git a/apps/worker/.github/workflows/enforce-license-compliance.yml b/apps/worker/.github/workflows/enforce-license-compliance.yml
new file mode 100644
index 0000000000..86be74100e
--- /dev/null
+++ b/apps/worker/.github/workflows/enforce-license-compliance.yml
@@ -0,0 +1,14 @@
+name: Enforce License Compliance
+
+on:
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ enforce-license-compliance:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Enforce License Compliance'
+ uses: getsentry/action-enforce-license-compliance@57ba820387a1a9315a46115ee276b2968da51f3d # main
+ with:
+ fossa_api_key: ${{ secrets.FOSSA_API_KEY }}
diff --git a/apps/worker/.github/workflows/mypy.yml b/apps/worker/.github/workflows/mypy.yml
new file mode 100644
index 0000000000..d51e19b1e5
--- /dev/null
+++ b/apps/worker/.github/workflows/mypy.yml
@@ -0,0 +1,14 @@
+name: "Patch typing check"
+
+on:
+ push:
+ branches:
+ - main
+ - staging
+ pull_request:
+ merge_group:
+
+jobs:
+ patch-typing-check:
+ name: Run Patch Type Check
+ uses: codecov/gha-workflows/.github/workflows/mypy.yml@v1.2.33
diff --git a/apps/worker/.github/workflows/pr_detect_shared_changes.yml b/apps/worker/.github/workflows/pr_detect_shared_changes.yml
new file mode 100644
index 0000000000..15d4e96d9f
--- /dev/null
+++ b/apps/worker/.github/workflows/pr_detect_shared_changes.yml
@@ -0,0 +1,14 @@
+name: Detect dep version changes
+
+on:
+ pull_request:
+
+permissions:
+ pull-requests: "write"
+
+jobs:
+ shared-change-checker:
+ name: See if shared changed
+ uses: codecov/gha-workflows/.github/workflows/diff-dep.yml@main
+ with:
+ dep: 'shared'
diff --git a/apps/worker/.github/workflows/self-hosted-release-pr.yml b/apps/worker/.github/workflows/self-hosted-release-pr.yml
new file mode 100644
index 0000000000..aa094c802d
--- /dev/null
+++ b/apps/worker/.github/workflows/self-hosted-release-pr.yml
@@ -0,0 +1,14 @@
+name: Create Self Hosted Release PR
+
+on:
+ workflow_dispatch:
+ inputs:
+ versionName:
+ description: "Name of version (ie 23.9.5)"
+ required: true
+
+jobs:
+ create-release-pr:
+ name: Create PR for Release ${{ github.event.inputs.versionName }}
+ uses: codecov/gha-workflows/.github/workflows/create-release-pr.yml@v1.2.33
+ secrets: inherit
diff --git a/apps/worker/.github/workflows/self-hosted-release.yml b/apps/worker/.github/workflows/self-hosted-release.yml
new file mode 100644
index 0000000000..03d6eb7141
--- /dev/null
+++ b/apps/worker/.github/workflows/self-hosted-release.yml
@@ -0,0 +1,30 @@
+name: Create Self Hosted Release
+
+on:
+ pull_request:
+ branches:
+ - main
+ types: [closed]
+
+permissions:
+ contents: "read"
+ id-token: "write"
+
+jobs:
+ create-release:
+ name: Tag Release ${{ github.head_ref }} and Push Docker image to Docker Hub
+ if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/create-release.yml@v1.2.33
+ with:
+ tag_to_prepend: self-hosted-
+ secrets: inherit
+
+ push-image:
+ needs: [create-release]
+ if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') && github.repository_owner == 'codecov' }}
+ uses: codecov/gha-workflows/.github/workflows/self-hosted.yml@v1.2.33
+ secrets: inherit
+ with:
+ push_release: true
+ repo: ${{ vars.CODECOV_IMAGE_V2 || 'codecov/self-hosted-worker' }}
+ cache_file: "uv.lock"
diff --git a/apps/worker/.github/workflows/upload-overwatch.yml b/apps/worker/.github/workflows/upload-overwatch.yml
new file mode 100644
index 0000000000..6c60a079a3
--- /dev/null
+++ b/apps/worker/.github/workflows/upload-overwatch.yml
@@ -0,0 +1,37 @@
+name: Upload Overwatch
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+
+jobs:
+ upload-overwatch:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - name: Install UV
+ run: pip install uv
+ - name: Install Project Dependencies
+ run: |
+ uv export --format requirements-txt > requirements.txt
+ uv pip install -r requirements.txt --system
+ - name: Install Static Analysis Tools
+ run: |
+ pip install mypy==1.15.0
+ pip install ruff==0.9.8
+ - name: Install Overwatch CLI
+ run: |
+ curl -o overwatch-cli https://overwatch.codecov.dev/linux/cli
+ chmod +x overwatch-cli
+ - name: Run Overwatch CLI
+ run: |
+ ./overwatch-cli \
+ --auth-token ${{ secrets.SENTRY_AUTH_TOKEN }} \
+ --organization-slug codecov \
+ python \
+ --python-path $(which python3)
\ No newline at end of file
diff --git a/apps/worker/.gitignore b/apps/worker/.gitignore
new file mode 100644
index 0000000000..54e9959b0f
--- /dev/null
+++ b/apps/worker/.gitignore
@@ -0,0 +1,137 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+.mutmut-cache
+gha-creds-*.json
+
+*.pem
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+*coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+*.junit.xml
+junit.xml
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+.testenv
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+background_app/.fdk/metrics
+background_app/.report.json
+
+.pytype
+
+# Vim temporary files
+*.swp
+*.swo
+
+# Pycharm/Intellij
+.idea/
+
+# VCS
+.sl
+.git
+
+# MacOS
+.DS_Store
\ No newline at end of file
diff --git a/apps/worker/.pre-commit-config.yaml b/apps/worker/.pre-commit-config.yaml
new file mode 100644
index 0000000000..f9cebd057e
--- /dev/null
+++ b/apps/worker/.pre-commit-config.yaml
@@ -0,0 +1,16 @@
+repos:
+ - repo: local
+ hooks:
+ - id: lint
+ name: lint
+ description: "Lint and sort"
+ entry: make lint.local
+ pass_filenames: false
+ require_serial: true
+ language: system
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v1.10.0"
+ hooks:
+ - id: mypy
+ verbose: true
+ entry: bash -c 'mypy "$@" --explicit-package-bases || true' --
diff --git a/apps/worker/.python-version b/apps/worker/.python-version
new file mode 100644
index 0000000000..3a4f41ef34
--- /dev/null
+++ b/apps/worker/.python-version
@@ -0,0 +1 @@
+3.13
\ No newline at end of file
diff --git a/apps/worker/.vscode/README.md b/apps/worker/.vscode/README.md
new file mode 100644
index 0000000000..2c266ab448
--- /dev/null
+++ b/apps/worker/.vscode/README.md
@@ -0,0 +1,11 @@
+As everyone will have their personal vscode preferences,
+and might want to override settings inside of the workspace,
+here is how you can make changes locally, without git prompting you to commit
+your changes to these settings files:
+
+```
+git update-index --assume-unchanged .vscode/settings.json
+```
+
+This way, git will ignore your local changes, and you are free to change these
+files as you see fit.
diff --git a/apps/worker/.vscode/extensions.json b/apps/worker/.vscode/extensions.json
new file mode 100644
index 0000000000..1fc832ae26
--- /dev/null
+++ b/apps/worker/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "ms-python.python",
+ "ms-python.mypy-type-checker",
+ "charliermarsh.ruff"
+ ]
+}
diff --git a/apps/worker/.vscode/settings.json b/apps/worker/.vscode/settings.json
new file mode 100644
index 0000000000..21a2e64474
--- /dev/null
+++ b/apps/worker/.vscode/settings.json
@@ -0,0 +1,10 @@
+{
+ "[python]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "charliermarsh.ruff",
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "explicit",
+ "source.organizeImports": "explicit"
+ }
+ }
+}
diff --git a/apps/worker/CHANGELOG.md b/apps/worker/CHANGELOG.md
new file mode 100644
index 0000000000..bdef09d6ad
--- /dev/null
+++ b/apps/worker/CHANGELOG.md
@@ -0,0 +1,43 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+
+## [Unreleased]
+
+### Added
+
+### Changed
+
+### Deprecated
+
+### Removed
+- Removed safeguard protecting against small downtimes when `Upload` objects were not created yet
+
+### Fixed
+
+### Security
+
+## [v4.5.10]
+
+### Added
+- Added `flag_management` field and subfields to user YAML
+- Added support for PR billing licenses on enterprise cases
+
+### Changed
+
+### Deprecated
+
+### Removed
+- Retired Gitlab v3 specific support
+
+### Fixed
+- Fixed visibility timeout issues, that in some cases could have caused the same task to be rerun more than once.
+
+### Security
+
+
+[unreleased]: https://github.com/codecov/worker/compare/v4.5.9...HEAD
+[v4.5.10]: https://github.com/codecov/worker/compare/v4.5.9...v4.5.10
+
diff --git a/apps/worker/LICENSE.md b/apps/worker/LICENSE.md
new file mode 100644
index 0000000000..630ce64ac6
--- /dev/null
+++ b/apps/worker/LICENSE.md
@@ -0,0 +1,105 @@
+# Functional Source License, Version 1.1, Apache 2.0 Future License
+
+## Abbreviation
+
+FSL-1.1-Apache-2.0
+
+## Notice
+
+Copyright 2018-2024 Functional Software, Inc. dba Sentry
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under
+these Terms and Conditions, as indicated by our inclusion of these Terms and
+Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents,
+Redistribution and Trademark clauses below, we hereby grant you the right to
+use, copy, modify, create derivative works, publicly perform, publicly display
+and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
+means making the Software available to others in a commercial product or
+service that:
+
+1. substitutes for the Software;
+
+2. substitutes for any other product or service we offer using the Software
+ that exists as of the date we make the Software available; or
+
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+
+2. for non-commercial education;
+
+3. for non-commercial research; and
+
+4. in connection with professional services that you provide to a licensee
+ using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our
+patents, the license grant above includes a license under our patents. If you
+make a claim against any party that the Software infringes or contributes to
+the infringement of any patent, then your patent license to the Software ends
+immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of
+the Software.
+
+If you redistribute any copies, modifications or derivatives of the Software,
+you must include a copy of or a link to these Terms and Conditions and not
+remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
+PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
+SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
+EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of
+the Software, you have no right under these Terms and Conditions to use our
+trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under
+the Apache License, Version 2.0 that is effective on the second anniversary of
+the date we make the Software available. On or after that date, you may use the
+Software under the Apache License, Version 2.0, in which case the following
+will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License.
+
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/apps/worker/Makefile b/apps/worker/Makefile
new file mode 100644
index 0000000000..43d83e72ae
--- /dev/null
+++ b/apps/worker/Makefile
@@ -0,0 +1,255 @@
+sha ?= $(shell git rev-parse --short=7 HEAD)
+full_sha ?= $(shell git rev-parse HEAD)
+release_version = `cat VERSION`
+_gcr := ${CODECOV_WORKER_GCR_REPO_BASE}
+merge_sha ?= $(shell git merge-base HEAD^ origin/main)
+build_date ?= $(shell git show -s --date=iso8601-strict --pretty=format:%cd $$sha)
+name ?= worker
+branch ?= $(shell git branch | grep \* | cut -f2 -d' ')
+gh_access_token := $(shell echo ${GH_ACCESS_TOKEN})
+epoch ?= $(shell date +"%s")
+
+AR_REPO ?= codecov/worker
+DOCKERHUB_REPO ?= codecov/self-hosted-worker
+VERSION ?= release-${sha}
+CODECOV_UPLOAD_TOKEN ?= "notset"
+CODECOV_STATIC_TOKEN ?= "notset"
+CODECOV_URL ?= "https://api.codecov.io"
+
+DEFAULT_REQS_TAG := requirements-v1-$(shell sha1sum uv.lock | cut -d ' ' -f 1)-$(shell sha1sum docker/Dockerfile.requirements | cut -d ' ' -f 1)
+REQUIREMENTS_TAG ?= ${DEFAULT_REQS_TAG}
+
+# We allow this to be overridden so that we can run `pytest` from this directory
+# but have the junit file use paths relative to a parent directory. This will
+# help us move to a monorepo.
+PYTEST_ROOTDIR ?= "."
+
+export DOCKER_BUILDKIT=1
+export WORKER_DOCKER_REPO=${AR_REPO}
+export WORKER_DOCKER_VERSION=${VERSION}
+export CODECOV_TOKEN=${CODECOV_UPLOAD_TOKEN}
+
+# Codecov CLI version to use
+CODECOV_CLI_VERSION := 0.5.1
+
+build:
+ $(MAKE) build.requirements
+ $(MAKE) build.local
+
+
+# for portable builds to dockerhub, for use with local development and
+# acceptance testing.
+build.portable:
+ docker build -f dockerscripts/Dockerfile . -t codecov/$(name)-portable \
+ --label "org.label-schema.build-date"="$(build_date)" \
+ --label "org.label-schema.name"="$(name)" \
+ --label "org.label-schema.vcs-ref"="$(sha)" \
+ --label "org.label-schema.vendor"="Codecov" \
+ --label "org.label-schema.version"="${release_version}-${sha}" \
+ --label "org.vcs-branch"="$(branch)" \
+ --build-arg GH_ACCESS_TOKEN=${gh_access_token} \
+ --build-arg COMMIT_SHA="${sha}" \
+ --build-arg RELEASE_VERSION="${release_version}"
+
+test:
+ COVERAGE_CORE=sysmon pytest --cov=./ --junitxml=junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+test.unit:
+ COVERAGE_CORE=sysmon pytest --cov=./ -m "not integration" --cov-report=xml:unit.coverage.xml --junitxml=unit.junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+test.integration:
+ COVERAGE_CORE=sysmon pytest --cov=./ -m "integration" --cov-report=xml:integration.coverage.xml --junitxml=integration.junit.xml -o junit_family=legacy -c pytest.ini --rootdir=${PYTEST_ROOTDIR}
+
+lint:
+ make lint.install
+ make lint.run
+
+# used for CI
+lint.install:
+ echo "Installing..."
+ pip install -Iv ruff
+
+lint.local:
+ make lint.install.local
+ make lint.run
+
+lint.install.local:
+ echo "Installing..."
+ uv add --dev ruff
+
+# The preferred method (for now) w.r.t. fixable rules is to manually update the makefile
+# with --fix and re-run 'make lint.' Since ruff is constantly adding rules this is a slight
+# amount of "needed" friction imo.
+lint.run:
+ ruff check
+ ruff format
+
+lint.check:
+ echo "Linting..."
+ ruff check
+ echo "Formatting..."
+ ruff format --check
+
+build.requirements:
+ # If make was given a different requirements tag, we assume a suitable image
+ # was already built (e.g. by umbrella) and don't want to build this one.
+ ifneq (${REQUIREMENTS_TAG},${DEFAULT_REQS_TAG})
+ echo "Error: building worker reqs image despite another being provided"
+ exit 1
+ endif
+ # if docker pull succeeds, we have already build this version of
+ # requirements.txt. Otherwise, build and push a version tagged
+ # with the hash of this requirements.txt
+ docker pull ${AR_REPO}:${REQUIREMENTS_TAG} || docker build \
+ -f docker/Dockerfile.requirements . \
+ -t ${AR_REPO}:${REQUIREMENTS_TAG} \
+ -t codecov/worker-ci-requirements:${REQUIREMENTS_TAG}
+
+build.local:
+ docker build -f docker/Dockerfile . \
+ -t ${AR_REPO}:latest \
+ -t ${AR_REPO}:${VERSION} \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg BUILD_ENV=local
+
+build.app:
+ docker build -f docker/Dockerfile . \
+ -t ${AR_REPO}:latest \
+ -t ${AR_REPO}:${VERSION} \
+ --label "org.label-schema.vendor"="Codecov" \
+ --label "org.label-schema.version"="${release_version}-${sha}" \
+ --label "org.opencontainers.image.revision"="$(full_sha)" \
+ --label "org.opencontainers.image.source"="github.com/codecov/worker" \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=cloud
+
+build.self-hosted:
+ make build.self-hosted-base
+ make build.self-hosted-runtime
+
+build.self-hosted-base:
+ docker build -f docker/Dockerfile . \
+ -t ${DOCKERHUB_REPO}:latest-no-dependencies \
+ -t ${DOCKERHUB_REPO}:${VERSION}-no-dependencies \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=self-hosted
+
+build.self-hosted-runtime:
+ docker build -f docker/Dockerfile . \
+ -t ${DOCKERHUB_REPO}:latest \
+ -t ${DOCKERHUB_REPO}:${VERSION} \
+ --label "org.label-schema.vendor"="Codecov" \
+ --label "org.label-schema.version"="${release_version}-${sha}" \
+ --build-arg REQUIREMENTS_IMAGE=${AR_REPO}:${REQUIREMENTS_TAG} \
+ --build-arg RELEASE_VERSION=${VERSION} \
+ --build-arg BUILD_ENV=self-hosted-runtime
+
+tag.latest:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:latest
+
+tag.staging:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:staging-${VERSION}
+
+tag.production:
+ docker tag ${AR_REPO}:${VERSION} ${AR_REPO}:production-${VERSION}
+
+tag.self-hosted-rolling:
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:rolling_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:rolling
+
+tag.self-hosted-release:
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:${release_version}_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_calver_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION}-no-dependencies ${DOCKERHUB_REPO}:latest_stable_no_dependencies
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:${release_version}
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-stable
+ docker tag ${DOCKERHUB_REPO}:${VERSION} ${DOCKERHUB_REPO}:latest-calver
+
+load.requirements:
+ docker load --input requirements.tar
+ docker tag codecov/worker-ci-requirements:${REQUIREMENTS_TAG} ${AR_REPO}:${REQUIREMENTS_TAG}
+
+load.self-hosted:
+ docker load --input self-hosted-runtime.tar
+ docker load --input self-hosted.tar
+
+save.app:
+ docker save -o app.tar ${AR_REPO}:${VERSION}
+
+save.requirements:
+ docker tag ${AR_REPO}:${REQUIREMENTS_TAG} codecov/worker-ci-requirements:${REQUIREMENTS_TAG}
+ docker save -o requirements.tar codecov/worker-ci-requirements:${REQUIREMENTS_TAG}
+
+save.self-hosted:
+ make save.self-hosted-base
+ make save.self-hosted-runtime
+
+save.self-hosted-base:
+ docker save -o self-hosted.tar ${DOCKERHUB_REPO}:${VERSION}-no-dependencies
+
+save.self-hosted-runtime:
+ docker save -o self-hosted-runtime.tar ${DOCKERHUB_REPO}:${VERSION}
+
+push.latest:
+ docker push ${AR_REPO}:latest
+
+push.staging:
+ docker push ${AR_REPO}:staging-${VERSION}
+
+push.production:
+ docker push ${AR_REPO}:production-${VERSION}
+
+push.requirements:
+ docker push ${AR_REPO}:${REQUIREMENTS_TAG}
+
+push.self-hosted-release:
+ docker push ${DOCKERHUB_REPO}:${release_version}_no_dependencies
+ docker push ${DOCKERHUB_REPO}:latest_calver_no_dependencies
+ docker push ${DOCKERHUB_REPO}:latest_stable_no_dependencies
+ docker push ${DOCKERHUB_REPO}:${release_version}
+ docker push ${DOCKERHUB_REPO}:latest-stable
+ docker push ${DOCKERHUB_REPO}:latest-calver
+
+push.self-hosted-rolling:
+ docker push ${DOCKERHUB_REPO}:rolling_no_dependencies
+ docker push ${DOCKERHUB_REPO}:rolling
+
+shell:
+ docker-compose exec worker bash
+
+test_env.up:
+ env | grep GITHUB > .testenv; true
+ docker-compose up -d
+
+test_env.prepare:
+ docker-compose exec worker make test_env.container_prepare
+
+test_env.check_db:
+ docker-compose exec worker make test_env.container_check_db
+
+test_env.install_cli:
+ pip install --no-cache-dir codecov-cli==$(CODECOV_CLI_VERSION)
+
+test_env.container_prepare:
+ apt-get update
+ apt-get install -y git build-essential netcat-traditional
+ git config --global --add safe.directory /apps/app/worker || true
+
+test_env.container_check_db:
+ while ! nc -vz postgres 5432; do sleep 1; echo "waiting for postgres"; done
+ while ! nc -vz timescale 5432; do sleep 1; echo "waiting for timescale"; done
+
+test_env.run_unit:
+ docker-compose exec worker make test.unit PYTEST_ROOTDIR=${PYTEST_ROOTDIR}
+
+test_env.run_integration:
+ docker-compose exec worker make test.integration PYTEST_ROOTDIR=${PYTEST_ROOTDIR}
+
+test_env:
+ make test_env.up
+ make test_env.prepare
+ make test_env.check_db
+ make test_env.run_unit
+ make test_env.run_integration
diff --git a/apps/worker/README.md b/apps/worker/README.md
new file mode 100644
index 0000000000..2fb023af75
--- /dev/null
+++ b/apps/worker/README.md
@@ -0,0 +1,119 @@
+# worker
+
+
+[](https://codecov.io/github/codecov/worker)
+
+> We believe that everyone should have access to quality software (like Sentry), that’s why we have always offered Codecov for free to open source maintainers.
+>
+> By making our code public, we’re not only joining the community that’s supported us from the start — but also want to make sure that every developer can contribute to and build on the Codecov experience.
+
+Code for Background Workers of Codecov. This is built on top of the `celery` async framework
+
+## Quickstart
+
+### Setting Virtual Env
+
+This repo is set up with `direnv`, which will automatically
+create a virtualenv in `.venv` and activate the environment
+when you `cd` into the directory in your console.
+
+Please take a look at [direnv](https://github.com/direnv/direnv/blob/master/docs/installation.md)
+for more installation information.
+
+### Installing dependencies
+
+Make sure to:
+
+- Install rust. See https://www.rust-lang.org/tools/install
+- Have access to any private codecov repos listed in the requirements.txt file. See [here](https://codecovio.atlassian.net/wiki/spaces/ENG/pages/1270743045/Setup) for help on getting that set up.
+
+To install the dependencies, run
+
+```
+uv sync
+```
+
+### Environment variables
+
+In order to successfully run `make push`, you'll need to define the `CODECOV_WORKER_GCR_REPO_BASE` variable. See its use in [`Makefile`](Makefile) to understand what it's used for. An example is `gcr.io/your-project-here/codecov`. Codecov internal users, see [the env setup documentation](https://www.notion.so/sentry/Environment-variables-for-building-pushing-Docker-images-locally-3159e90c5e6f4db4bfbde8800cdad2c0?pvs=4) for our canonical defaults.
+
+### Running Tests Locally
+
+Simply:
+
+```
+make test_env
+```
+
+What this does is start a few dependent databases with `docker-compose`,
+then it runs the tests in the `worker` docker container.
+
+### Linting and Import Sorts
+
+Install/run `black` and `isort` using
+
+```
+make lint
+```
+
+### Getting into docker
+
+To build this into a docker image:
+
+```
+make build.base
+make build
+```
+
+To run this as part of the whole infrasctructure, you will be better served by getting the main codebase and running `docker-compose up` from there
+
+### Getting into enterprise mode
+
+To generate an enterprise build, do
+
+```
+make build.enterprise
+```
+
+## Versioning
+
+The source of truth on which version we use is in the file `VERSION`. Every script that tags things with versions will consult that file to see what version it is.
+
+That file is manually updated. We use semantic versioning.
+
+If you are unsure whether you need to change that version at a given moment, the answer is that you probaby don't then. We have multiple deploys on the same version, and only change it when we want to cut a version to enterprise.
+
+## Upgrading Dependencies
+
+This repository uses `uv` to manage dependencies.
+Make your dependency updates to `pyproject.toml` then run:
+
+```
+uv sync
+```
+
+to update the `uv.lock` file.
+
+### After deploying
+
+If you are deploying or helping with a deploy, make sure to:
+
+1. Watch logs (on datadog and sentry)
+2. Monitor error rates and timing graphs on the dashboards we have set up
+
+As the deployer, it is your responsibility to make sure the system is working as expected post-deploy. If not, you might need to do a rollback.
+
+## Code Structure
+
+Before getting into changing the code, try to use the following structure (feel free to suggest changes.Some bits of it are based on our experience)
+
+- `helpers` - Those are the "low" level pieces of code, that don't depend on database models or any other heavy business logic. Those shouldn't depend on anything else on the codebase, preferrably
+- `database` - Those contain database models. They can use logic from `helpers` and other models, but nothing else. Try to avoid any heavy logic in this code.
+- `services` - Those are heavier pieces of logic, that don't talk to the external world. They can use `helpers` and `database` logic, and among themselves. But make sure that if a service _bravo_ depends on service _alpha_, then _alpha_ should not depend on any part of _bravo_
+- `tasks` - Those are the parts of the code that talk to the external world: it has the tasks that are triggered by external containers. They can depend on `helpers`, `models` and `services`, but NEVER depend on another task (except to schedule them). If some code is common to two tasks, try to put it in a `service` or somewhere else.
+
+You will also notice some usage of the package https://github.com/codecov/shared for various things. The logic that is there is used by both here and `codecov/api` codebase. So feel free to make changes there, but dont do anything that will break compatibility too hard.
+
+## Contributing
+
+This repository, like all of Codecov's repositories, strives to follow our general [Contributing guidlines](https://github.com/codecov/contributing). If you're considering making a contribution to this repository, we encourage review of our Contributing guidelines first.
diff --git a/apps/worker/VERSION b/apps/worker/VERSION
new file mode 100644
index 0000000000..86a8f0262a
--- /dev/null
+++ b/apps/worker/VERSION
@@ -0,0 +1 @@
+25.4.1
\ No newline at end of file
diff --git a/apps/worker/__init__.py b/apps/worker/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/app.py b/apps/worker/app.py
new file mode 100644
index 0000000000..2dccd4a0a0
--- /dev/null
+++ b/apps/worker/app.py
@@ -0,0 +1,22 @@
+import logging
+import logging.config
+
+from celery import Celery, signals
+
+from helpers.logging_config import get_logging_config_dict
+from helpers.sentry import initialize_sentry, is_sentry_enabled
+
+log = logging.getLogger(__name__)
+
+_config_dict = get_logging_config_dict()
+logging.config.dictConfig(_config_dict)
+
+
+celery_app = Celery("tasks")
+celery_app.config_from_object("celery_config:CeleryWorkerConfig")
+
+
+@signals.celeryd_init.connect
+def init_sentry(**_kwargs):
+ if is_sentry_enabled():
+ initialize_sentry()
diff --git a/apps/worker/celery_config.py b/apps/worker/celery_config.py
new file mode 100644
index 0000000000..6814334bd8
--- /dev/null
+++ b/apps/worker/celery_config.py
@@ -0,0 +1,146 @@
+# http://docs.celeryq.org/en/latest/configuration.html#configuration
+import gc
+import logging
+import logging.config
+from datetime import timedelta
+
+from celery import signals
+from celery.beat import BeatLazyFunc
+from celery.schedules import crontab
+from shared.celery_config import (
+ BaseCeleryConfig,
+ brolly_stats_rollup_task_name,
+ # flare_cleanup_task_name,
+ gh_app_webhook_check_task_name,
+ health_check_task_name,
+)
+from shared.config import get_config
+from shared.helpers.cache import RedisBackend, cache
+from shared.helpers.redis import get_redis_connection
+
+from celery_task_router import route_task
+from helpers.clock import get_utc_now_as_iso_format
+from helpers.health_check import get_health_check_interval_seconds
+
+log = logging.getLogger(__name__)
+
+
+@signals.worker_before_create_process.connect
+def prefork_gc_freeze(**kwargs) -> None:
+ # This comes from https://github.com/getsentry/sentry/pull/63001
+ # More info https://www.youtube.com/watch?v=Hgw_RlCaIds
+ # The idea is to save memory in the worker subprocesses
+ # By freezing all the stuff we can just read from
+ gc.freeze()
+
+
+@signals.setup_logging.connect
+def initialize_logging(loglevel=logging.INFO, **kwargs):
+ celery_logger = logging.getLogger("celery")
+ celery_logger.setLevel(loglevel)
+ log.info("Initialized celery logging")
+ return celery_logger
+
+
+@signals.worker_process_init.connect
+def initialize_cache(**kwargs):
+ log.info("Initialized cache")
+ redis_cache_backend = RedisBackend(get_redis_connection())
+ cache.configure(redis_cache_backend)
+
+
+hourly_check_task_name = "app.cron.hourly_check.HourlyCheckTask"
+daily_plan_manager_task_name = "app.cron.daily.PlanManagerTask"
+
+notify_error_task_name = "app.tasks.notify.NotifyErrorTask"
+
+# Backfill GH Apps
+backfill_existing_gh_app_installations_name = "app.tasks.backfill_existing_gh_app_installations.BackfillExistingGHAppInstallationsTask"
+backfill_existing_individual_gh_app_installation_name = "app.tasks.backfill_existing_individual_gh_app_installation.BackfillExistingIndividualGHAppInstallationTask"
+backfill_owners_without_gh_app_installations_name = "app.tasks.backfill_owners_without_gh_app_installations.BackfillOwnersWithoutGHAppInstallationsTask"
+backfill_owners_without_gh_app_installation_individual_name = "app.tasks.backfill_owners_without_gh_app_installation_individual.BackfillOwnersWithoutGHAppInstallationIndividualTask"
+
+trial_expiration_task_name = "app.tasks.plan.TrialExpirationTask"
+trial_expiration_cron_task_name = "app.cron.plan.TrialExpirationCronTask"
+
+update_branches_task_name = "app.cron.branches.UpdateBranchesTask"
+update_branches_task_name = "app.cron.test_instances.BackfillTestInstancesTask"
+
+cache_rollup_cron_task_name = "app.cron.cache_rollup.CacheRollupTask"
+
+regular_cleanup_cron_task_name = "app.cron.cleanup.RegularCleanup"
+
+
+def _beat_schedule():
+ beat_schedule = {
+ "hourly_check": {
+ "task": hourly_check_task_name,
+ "schedule": crontab(minute="0"),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ },
+ "github_app_webhooks_task": {
+ "task": gh_app_webhook_check_task_name,
+ "schedule": crontab(minute="0", hour="0,6,12,18"),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ },
+ "trial_expiration_cron": {
+ "task": trial_expiration_cron_task_name,
+ "schedule": crontab(minute="0", hour="4"), # 4 UTC is 12am EDT
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ },
+ "regular_cleanup": {
+ "task": regular_cleanup_cron_task_name,
+ "schedule": crontab(minute="0", hour="4"),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ },
+ # "flare_cleanup": {
+ # "task": flare_cleanup_task_name,
+ # "schedule": crontab(minute="0", hour="5"), # every day, 5am UTC (10pm PDT)
+ # "kwargs": {
+ # "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ # },
+ # },
+ }
+
+ if get_config("setup", "health_check", "enabled", default=False):
+ beat_schedule["health_check_task"] = {
+ "task": health_check_task_name,
+ "schedule": timedelta(seconds=get_health_check_interval_seconds()),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ }
+
+ if get_config("setup", "telemetry", "enabled", default=True):
+ beat_schedule["brolly_stats_rollup"] = {
+ "task": brolly_stats_rollup_task_name,
+ "schedule": crontab(minute="0", hour="2"),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ }
+
+ if get_config("setup", "cache_rollup", "enabled", default=False):
+ beat_schedule["cache_rollup"] = {
+ "task": cache_rollup_cron_task_name,
+ "schedule": crontab(minute="0", hour="2"),
+ "kwargs": {
+ "cron_task_generation_time_iso": BeatLazyFunc(get_utc_now_as_iso_format)
+ },
+ }
+
+ return beat_schedule
+
+
+class CeleryWorkerConfig(BaseCeleryConfig):
+ beat_schedule = _beat_schedule()
+
+ task_routes = route_task
diff --git a/apps/worker/celery_task_router.py b/apps/worker/celery_task_router.py
new file mode 100644
index 0000000000..1d33b59584
--- /dev/null
+++ b/apps/worker/celery_task_router.py
@@ -0,0 +1,119 @@
+import shared.celery_config as shared_celery_config
+from shared.celery_router import route_tasks_based_on_user_plan
+from shared.plan.constants import DEFAULT_FREE_PLAN
+
+from database.engine import get_db_session
+from database.models.core import Commit, CompareCommit, Owner, Repository
+from database.models.labelanalysis import LabelAnalysisRequest
+from database.models.staticanalysis import StaticAnalysisSuite
+
+
+def _get_user_plan_from_ownerid(db_session, ownerid, *args, **kwargs) -> str:
+ result = db_session.query(Owner.plan).filter(Owner.ownerid == ownerid).first()
+ if result:
+ return result.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_repoid(db_session, repoid, *args, **kwargs) -> str:
+ result = (
+ db_session.query(Owner.plan)
+ .join(Repository.owner)
+ .filter(Repository.repoid == repoid)
+ .first()
+ )
+ if result:
+ return result.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_org_ownerid(dbsession, org_ownerid, *args, **kwargs) -> str:
+ return _get_user_plan_from_ownerid(dbsession, ownerid=org_ownerid)
+
+
+def _get_user_plan_from_comparison_id(dbsession, comparison_id, *args, **kwargs) -> str:
+ result = (
+ dbsession.query(Owner.plan)
+ .join(CompareCommit.compare_commit)
+ .join(Commit.repository)
+ .join(Repository.owner)
+ .filter(CompareCommit.id_ == comparison_id)
+ .first()
+ )
+ if result:
+ return result.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_label_request_id(dbsession, request_id, *args, **kwargs) -> str:
+ result = (
+ dbsession.query(Owner.plan)
+ .join(LabelAnalysisRequest.head_commit)
+ .join(Commit.repository)
+ .join(Repository.owner)
+ .filter(LabelAnalysisRequest.id_ == request_id)
+ .first()
+ )
+ if result:
+ return result.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_suite_id(dbsession, suite_id, *args, **kwargs) -> str:
+ result = (
+ dbsession.query(Owner.plan)
+ .join(StaticAnalysisSuite.commit)
+ .join(Commit.repository)
+ .join(Repository.owner)
+ .filter(StaticAnalysisSuite.id_ == suite_id)
+ .first()
+ )
+ if result:
+ return result.plan
+ return DEFAULT_FREE_PLAN
+
+
+def _get_user_plan_from_task(dbsession, task_name: str, task_kwargs: dict) -> str:
+ owner_plan_lookup_funcs = {
+ # from ownerid
+ shared_celery_config.delete_owner_task_name: _get_user_plan_from_ownerid,
+ shared_celery_config.send_email_task_name: _get_user_plan_from_ownerid,
+ shared_celery_config.sync_repos_task_name: _get_user_plan_from_ownerid,
+ shared_celery_config.sync_teams_task_name: _get_user_plan_from_ownerid,
+ # from org_ownerid
+ shared_celery_config.new_user_activated_task_name: _get_user_plan_from_org_ownerid,
+ # from repoid
+ shared_celery_config.pre_process_upload_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.upload_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.upload_processor_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.notify_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.commit_update_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.flush_repo_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.status_set_error_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.status_set_pending_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.pulls_task_name: _get_user_plan_from_repoid,
+ shared_celery_config.upload_finisher_task_name: _get_user_plan_from_repoid, # didn't want to directly import the task module
+ shared_celery_config.manual_upload_completion_trigger_task_name: _get_user_plan_from_repoid,
+ # from comparison_id
+ shared_celery_config.compute_comparison_task_name: _get_user_plan_from_comparison_id,
+ # from label_request_id
+ shared_celery_config.label_analysis_task_name: _get_user_plan_from_label_request_id,
+ # from suite_id
+ shared_celery_config.static_analysis_task_name: _get_user_plan_from_suite_id,
+ }
+ func_to_use = owner_plan_lookup_funcs.get(
+ task_name, lambda *args, **kwargs: DEFAULT_FREE_PLAN
+ )
+ return func_to_use(dbsession, **task_kwargs)
+
+
+def route_task(name, args, kwargs, options, task=None, **kw):
+ """Function to dynamically route tasks to the proper queue.
+ Docs: https://docs.celeryq.dev/en/stable/userguide/routing.html#routers
+ """
+
+ user_plan = options.get("user_plan")
+ if user_plan is None:
+ db_session = get_db_session()
+ user_plan = _get_user_plan_from_task(db_session, name, kwargs)
+ return route_tasks_based_on_user_plan(name, user_plan)
diff --git a/apps/worker/codecov.yml b/apps/worker/codecov.yml
new file mode 100644
index 0000000000..41ac1a3c18
--- /dev/null
+++ b/apps/worker/codecov.yml
@@ -0,0 +1,27 @@
+codecov:
+ require_ci_to_pass: false
+ notify:
+ wait_for_ci: false
+
+component_management:
+ default_rules:
+ statuses:
+ - type: project
+ target: auto
+ individual_components:
+ - component_id: actual_code
+ name: NonTestCode
+ paths:
+ - "!conftest.py"
+ - "!**/conftest.py"
+ - "!**tests**/test_*.py"
+ - "!database/tests/factories/**"
+ - component_id: no_tasks
+ name: OutsideTasks
+ paths:
+ - "!tasks/**"
+ flag_regexes:
+ - "unit"
+
+test_analytics:
+ flake_detection: true
diff --git a/apps/worker/conftest.py b/apps/worker/conftest.py
new file mode 100644
index 0000000000..eb4b358e8e
--- /dev/null
+++ b/apps/worker/conftest.py
@@ -0,0 +1,424 @@
+import logging
+import os
+from pathlib import Path
+
+import mock
+import pytest
+import vcr
+from shared.config import ConfigHelper
+from shared.storage.memory import MemoryStorageService
+from shared.torngit import Github as GithubHandler
+from sqlalchemy import event
+from sqlalchemy.orm import Session
+from sqlalchemy_utils import database_exists
+
+from celery_config import initialize_logging
+from database.base import Base
+from database.engine import json_dumps
+from helpers.environment import _get_cached_current_env
+
+
+# @pytest.hookimpl(tryfirst=True)
+def pytest_configure(config):
+ """
+ Allows plugins and conftest files to perform initial configuration.
+ This hook is called for every plugin and initial conftest
+ file after command line options have been parsed.
+ """
+ os.environ["CURRENT_ENVIRONMENT"] = "local"
+ os.environ["RUN_ENV"] = "DEV"
+ _get_cached_current_env.cache_clear()
+ initialize_logging()
+
+
+def pytest_itemcollected(item):
+ """logic that runs on the test collection step"""
+ if "codecov_vcr" in item.fixturenames:
+ # Tests with codecov_vcr fixtures are automatically 'integration'
+ item.add_marker("integration")
+
+
+@pytest.fixture(scope="session")
+def engine(request, sqlalchemy_db, sqlalchemy_connect_url, app_config):
+ """Engine configuration.
+ See http://docs.sqlalchemy.org/en/latest/core/engines.html
+ for more details.
+ :sqlalchemy_connect_url: Connection URL to the database. E.g
+ postgresql://scott:tiger@localhost:5432/mydatabase
+ :app_config: Path to a ini config file containing the sqlalchemy.url
+ config variable in the DEFAULT section.
+ :returns: Engine instance
+ """
+ if app_config:
+ from sqlalchemy import engine_from_config
+
+ engine = engine_from_config(app_config)
+ elif sqlalchemy_connect_url:
+ from sqlalchemy.engine import create_engine
+
+ engine = create_engine(sqlalchemy_connect_url, json_serializer=json_dumps)
+ else:
+ raise RuntimeError("Can not establish a connection to the database")
+
+ # Put a suffix like _gw0, _gw1 etc on xdist processes
+ xdist_suffix = getattr(request.config, "slaveinput", {}).get("slaveid")
+ if engine.url.database != ":memory:" and xdist_suffix is not None:
+ engine.url.database = "{}_{}".format(engine.url.database, xdist_suffix)
+ engine = create_engine(engine.url) # override engine
+
+ # Check that the DB exist and migrate the unmigrated SQLALchemy models as a stop-gap
+ database_url = sqlalchemy_connect_url
+ if not database_exists(database_url):
+ raise RuntimeError(f"SQLAlchemy cannot connect to DB at {database_url}")
+
+ Base.metadata.tables["timeseries_measurement"].create(bind=engine, checkfirst=True)
+ Base.metadata.tables["timeseries_dataset"].create(bind=engine, checkfirst=True)
+
+ Base.metadata.tables["compare_commitcomparison"].create(
+ bind=engine, checkfirst=True
+ )
+ Base.metadata.tables["compare_flagcomparison"].create(bind=engine, checkfirst=True)
+ Base.metadata.tables["compare_componentcomparison"].create(
+ bind=engine, checkfirst=True
+ )
+
+ Base.metadata.tables["labelanalysis_labelanalysisrequest"].create(
+ bind=engine, checkfirst=True
+ )
+ Base.metadata.tables["labelanalysis_labelanalysisprocessingerror"].create(
+ bind=engine, checkfirst=True
+ )
+
+ Base.metadata.tables["staticanalysis_staticanalysissuite"].create(
+ bind=engine, checkfirst=True
+ )
+ Base.metadata.tables["staticanalysis_staticanalysissinglefilesnapshot"].create(
+ bind=engine, checkfirst=True
+ )
+ Base.metadata.tables["staticanalysis_staticanalysissuitefilepath"].create(
+ bind=engine, checkfirst=True
+ )
+
+ yield engine
+
+ print("Disposing engine") # noqa: T201
+ engine.dispose()
+
+
+@pytest.fixture(scope="session")
+def sqlalchemy_db(request: pytest.FixtureRequest, django_db_blocker, django_db_setup):
+ # Bootstrap the DB by running the Django bootstrap version.
+ from django.conf import settings
+ from django.db import connections
+ from django.test.utils import setup_databases, teardown_databases
+
+ keepdb = request.config.getvalue("reuse_db", False) and not request.config.getvalue(
+ "create_db", False
+ )
+
+ with django_db_blocker.unblock():
+ # Temporarily reset the database to the SQLAlchemy DBs to run the migrations.
+ original_db_name = settings.DATABASES["default"]["NAME"]
+ original_test_name = settings.DATABASES["default"]["TEST"]["NAME"]
+ settings.DATABASES["default"]["NAME"] = "sqlalchemy"
+ settings.DATABASES["default"]["TEST"]["NAME"] = "test_postgres_sqlalchemy"
+ for connection in connections:
+ if "timeseries" in connection:
+ with connections[connection].cursor() as cursor:
+ cursor.execute(
+ "SELECT _timescaledb_internal.stop_background_workers();"
+ )
+ db_cfg = setup_databases(
+ verbosity=request.config.option.verbose,
+ interactive=False,
+ keepdb=keepdb,
+ )
+ for connection in connections:
+ if "timeseries" in connection:
+ with connections[connection].cursor() as cursor:
+ cursor.execute(
+ "SELECT _timescaledb_internal.start_background_workers();"
+ )
+ settings.DATABASES["default"]["NAME"] = original_db_name
+ settings.DATABASES["default"]["TEST"]["NAME"] = original_test_name
+
+ # Hack to get the default connection for the test database to _actually_ be the
+ # Django database that the django_db should actually use. It was set to the SQLAlchemy database,
+ # but this makes sure that the default Django DB connection goes to the Django database.
+ # Since the database was already created and migrated in the django_db_setup fixture,
+ # we set keepdb=True to avoid recreating the database and rerunning the migrations.
+ connections.configure_settings(settings.DATABASES)
+ connections["default"].creation.create_test_db(
+ verbosity=request.config.option.verbose,
+ autoclobber=True,
+ keepdb=True,
+ )
+
+ yield
+
+ if not keepdb:
+ try:
+ with django_db_blocker.unblock():
+ # Need to set `test_postgres_sqlalchemy` as the main db name to tear down properly.
+ settings.DATABASES["default"]["NAME"] = "test_postgres_sqlalchemy"
+ teardown_databases(db_cfg, verbosity=request.config.option.verbose)
+ settings.DATABASES["default"]["NAME"] = original_db_name
+ except Exception as exc: # noqa: BLE001
+ request.node.warn(
+ pytest.PytestWarning(
+ f"Error when trying to teardown test databases: {exc!r}"
+ )
+ )
+
+
+@pytest.fixture
+def dbsession(sqlalchemy_db, engine):
+ """Sets up the SQLAlchemy dbsession."""
+ connection = engine.connect()
+
+ connection_transaction = connection.begin()
+
+ # bind an individual Session to the connection
+ session = Session(bind=connection)
+
+ # start the session in a SAVEPOINT...
+ session.begin_nested()
+
+ # then each time that SAVEPOINT ends, reopen it
+ @event.listens_for(session, "after_transaction_end")
+ def restart_savepoint(session, transaction):
+ if transaction.nested and not transaction._parent.nested:
+ # ensure that state is expired the way
+ # session.commit() at the top level normally does
+ # (optional step)
+ session.expire_all()
+
+ session.begin_nested()
+
+ yield session
+
+ session.close()
+ connection_transaction.rollback()
+ connection.close()
+
+
+@pytest.fixture
+def mock_configuration(mocker):
+ m = mocker.patch("shared.config._get_config_instance")
+ mock_config = ConfigHelper()
+ m.return_value = mock_config
+ our_config = {
+ "bitbucket": {"bot": {"username": "codecov-io"}},
+ "services": {
+ "minio": {
+ "access_key_id": "codecov-default-key",
+ "bucket": "archive",
+ "hash_key": "88f572f4726e4971827415efa8867978",
+ "secret_access_key": "codecov-default-secret",
+ "verify_ssl": False,
+ "host": "minio",
+ "port": 9000,
+ },
+ "smtp": {
+ "host": "mailhog",
+ "port": 1025,
+ "username": "username",
+ "password": "password",
+ },
+ },
+ "setup": {
+ "codecov_url": "https://codecov.io",
+ "encryption_secret": "zp^P9*i8aR3",
+ "telemetry": {
+ "endpoint_override": "abcde",
+ },
+ },
+ }
+ mock_config.set_params(our_config)
+ return mock_config
+
+
+@pytest.fixture
+def empty_configuration(mocker):
+ m = mocker.patch("shared.config._get_config_instance")
+ mock_config = ConfigHelper()
+ m.return_value = mock_config
+ return mock_config
+
+
+@pytest.fixture
+def codecov_vcr(request):
+ vcr_log = logging.getLogger("vcr")
+ vcr_log.setLevel(logging.ERROR)
+
+ current_path = Path(request.node.fspath)
+ current_path_name = current_path.name.replace(".py", "")
+ cassete_path = current_path.parent / "cassetes" / current_path_name
+ if request.node.cls:
+ cls_name = request.node.cls.__name__
+ cassete_path = cassete_path / cls_name
+ current_name = request.node.name
+ casset_file_path = str(cassete_path / f"{current_name}.yaml")
+ with vcr.use_cassette(
+ casset_file_path,
+ record_mode="once",
+ filter_headers=["authorization"],
+ match_on=["method", "scheme", "host", "port", "path"],
+ ) as cassete_maker:
+ yield cassete_maker
+
+
+@pytest.fixture
+def mock_redis(mocker):
+ m = mocker.patch("shared.helpers.redis._get_redis_instance_from_url")
+ redis_server = mocker.MagicMock()
+ m.return_value = redis_server
+ yield redis_server
+
+
+@pytest.fixture
+def mock_storage(mocker):
+ m = mocker.patch("shared.storage.get_appropriate_storage_service")
+ storage_server = MemoryStorageService({})
+ m.return_value = storage_server
+ return storage_server
+
+
+@pytest.fixture
+def mock_archive_storage(mocker):
+ mocker.patch(
+ "shared.django_apps.core.models.should_write_data_to_storage_config_check",
+ return_value=True,
+ )
+ storage_server = MemoryStorageService({})
+ mocker.patch(
+ "shared.storage.get_appropriate_storage_service", return_value=storage_server
+ )
+ return storage_server
+
+
+@pytest.fixture
+def mock_smtp(mocker):
+ m = mocker.patch("services.smtp.SMTPService")
+ smtp_server = mocker.MagicMock()
+ m.return_value = smtp_server
+ yield smtp_server
+
+
+@pytest.fixture
+def mock_repo_provider(mocker):
+ m = mocker.patch("services.repository._get_repo_provider_service_instance")
+ provider_instance = mocker.MagicMock(
+ GithubHandler,
+ data={},
+ get_commit_diff=mock.AsyncMock(return_value={}),
+ get_distance_in_commits=mock.AsyncMock(
+ return_value={"behind_by": 0, "behind_by_commit": None}
+ ),
+ )
+ m.return_value = provider_instance
+ yield provider_instance
+
+
+@pytest.fixture
+def mock_owner_provider(mocker):
+ provider_instance = mocker.MagicMock(GithubHandler)
+
+ def side_effect(*args, **kwargs):
+ provider_instance.data = {**kwargs}
+ return provider_instance
+
+ m = mocker.patch("services.owner._get_owner_provider_service_instance")
+ m.side_effect = side_effect
+ yield provider_instance
+
+
+@pytest.fixture
+def with_sql_functions(dbsession):
+ dbsession.execute(
+ """CREATE OR REPLACE FUNCTION array_append_unique(anyarray, anyelement) RETURNS anyarray
+ LANGUAGE sql IMMUTABLE
+ AS $_$
+ select case when $2 is null
+ then $1
+ else array_remove($1, $2) || array[$2]
+ end;
+ $_$;"""
+ )
+ dbsession.execute(
+ """create or replace function try_to_auto_activate(int, int) returns boolean as $$
+ update owners
+ set plan_activated_users = (
+ case when coalesce(array_length(plan_activated_users, 1), 0) < plan_user_count -- we have credits
+ then array_append_unique(plan_activated_users, $2) -- add user
+ else plan_activated_users
+ end)
+ where ownerid=$1
+ returning (plan_activated_users @> array[$2]);
+ $$ language sql volatile strict;"""
+ )
+ dbsession.execute(
+ """create or replace function get_gitlab_root_group(int) returns jsonb as $$
+ /* get root group by following parent_service_id to highest level */
+ with recursive tree as (
+ select o.service_id,
+ o.parent_service_id,
+ o.ownerid,
+ 1 as depth
+ from owners o
+ where o.ownerid = $1
+ and o.service = 'gitlab'
+ and o.parent_service_id is not null
+
+ union all
+
+ select o.service_id,
+ o.parent_service_id,
+ o.ownerid,
+ depth + 1 as depth
+ from tree t
+ join owners o
+ on o.service_id = t.parent_service_id
+ /* avoid infinite loop in case of cycling (2 > 5 > 3 > 2 > 5...) up to Gitlab max subgroup depth of 20 */
+ where depth <= 20
+ ), data as (
+ select t.ownerid,
+ t.service_id
+ from tree t
+ where t.parent_service_id is null
+ )
+ select to_jsonb(data) from data limit 1;
+ $$ language sql stable strict;"""
+ )
+ dbsession.flush()
+
+
+# We don't want any tests submitting checkpoint logs to Sentry for real
+@pytest.fixture(autouse=True)
+def mock_checkpoint_submit(mocker, request):
+ # We mock sentry differently in the tests for CheckpointLogger
+ if request.node.get_closest_marker("real_checkpoint_logger"):
+ return
+
+ def mock_submit_fn(metric, start, end, data={}):
+ pass
+
+ mock_submit = mocker.Mock()
+ mock_submit.side_effect = mock_submit_fn
+
+ return mocker.patch(
+ "helpers.checkpoint_logger.BaseFlow.submit_subflow", mock_submit
+ )
+
+
+@pytest.fixture(autouse=True)
+def mock_feature(mocker, request):
+ if request.node.get_closest_marker("real_feature"):
+ return
+
+ from shared.rollouts import Feature
+
+ def check_value(self, identifier, default=False):
+ return default
+
+ return mocker.patch.object(Feature, "check_value", check_value)
diff --git a/apps/worker/database/__init__.py b/apps/worker/database/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/database/base.py b/apps/worker/database/base.py
new file mode 100644
index 0000000000..a9aa736bb5
--- /dev/null
+++ b/apps/worker/database/base.py
@@ -0,0 +1,44 @@
+import uuid
+
+from sqlalchemy import Column, types
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import Session
+
+from helpers.clock import get_utc_now
+
+Base = declarative_base()
+
+
+class CodecovBaseModel(Base):
+ __abstract__ = True
+
+ def get_db_session(self):
+ return Session.object_session(self)
+
+
+class MixinBaseClass(object):
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ external_id = Column(
+ UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False
+ )
+ created_at = Column(types.DateTime(timezone=True), default=get_utc_now)
+ updated_at = Column(
+ types.DateTime(timezone=True), onupdate=get_utc_now, default=get_utc_now
+ )
+
+ @property
+ def id(self):
+ return self.id_
+
+
+class MixinBaseClassNoExternalID(object):
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ created_at = Column(types.DateTime(timezone=True), default=get_utc_now)
+ updated_at = Column(
+ types.DateTime(timezone=True), onupdate=get_utc_now, default=get_utc_now
+ )
+
+ @property
+ def id(self):
+ return self.id_
diff --git a/apps/worker/database/engine.py b/apps/worker/database/engine.py
new file mode 100644
index 0000000000..69a6cb8400
--- /dev/null
+++ b/apps/worker/database/engine.py
@@ -0,0 +1,92 @@
+import dataclasses
+import json
+from decimal import Decimal
+
+from shared.config import get_config
+from shared.timeseries.helpers import is_timeseries_enabled
+from shared.utils.ReportEncoder import ReportEncoder
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, scoped_session, sessionmaker
+
+import database.events # noqa: F401
+from database.models.timeseries import TimeseriesBaseModel
+
+from .base import Base
+
+
+def create_all(engine):
+ Base.metadata.create_all(engine)
+
+
+class DatabaseEncoder(ReportEncoder):
+ def default(self, obj):
+ if dataclasses.is_dataclass(obj):
+ return dataclasses.astuple(obj)
+ if isinstance(obj, Decimal):
+ return str(obj)
+ return super().default(obj)
+
+
+def json_dumps(d):
+ return json.dumps(d, cls=DatabaseEncoder)
+
+
+class SessionFactory:
+ def __init__(self, database_url, timeseries_database_url=None):
+ self.database_url = database_url
+ self.timeseries_database_url = timeseries_database_url
+ self.main_engine = None
+ self.timeseries_engine = None
+
+ def create_session(self):
+ self.main_engine = create_engine(
+ self.database_url,
+ json_serializer=json_dumps,
+ )
+
+ if is_timeseries_enabled():
+ self.timeseries_engine = create_engine(
+ self.timeseries_database_url,
+ json_serializer=json_dumps,
+ )
+
+ main_engine = self.main_engine
+ timeseries_engine = self.timeseries_engine
+
+ class RoutingSession(Session):
+ def get_bind(self, mapper=None, clause=None):
+ if mapper is not None and issubclass(
+ mapper.class_, TimeseriesBaseModel
+ ):
+ return timeseries_engine
+ if (
+ clause is not None
+ and hasattr(clause, "table")
+ and clause.table.name.startswith("timeseries_")
+ ):
+ return timeseries_engine
+ return main_engine
+
+ session_factory = sessionmaker(class_=RoutingSession)
+ else:
+ session_factory = sessionmaker(bind=self.main_engine)
+
+ return scoped_session(session_factory)
+
+
+session_factory = SessionFactory(
+ database_url=get_config(
+ "services",
+ "database_url",
+ default="postgres://postgres:@postgres:5432/postgres",
+ ),
+ timeseries_database_url=get_config(
+ "services",
+ "timeseries_database_url",
+ default="postgres://postgres:@timescale:5432/postgres",
+ ),
+)
+
+session = session_factory.create_session()
+
+get_db_session = session
diff --git a/apps/worker/database/enums.py b/apps/worker/database/enums.py
new file mode 100644
index 0000000000..42b0b92601
--- /dev/null
+++ b/apps/worker/database/enums.py
@@ -0,0 +1,80 @@
+from enum import Enum
+
+
+class Decoration(Enum):
+ standard = "standard"
+ upgrade = "upgrade"
+ upload_limit = "upload_limit"
+ passing_empty_upload = "passing_empty_upload"
+ failing_empty_upload = "failing_empty_upload"
+ processing_upload = "processing_upload"
+
+
+class Notification(Enum):
+ comment = "comment"
+ status_changes = "status_changes"
+ status_patch = "status_patch"
+ status_project = "status_project"
+ checks_changes = "checks_changes"
+ checks_patch = "checks_patch"
+ checks_project = "checks_project"
+ slack = "slack"
+ webhook = "webhook"
+ gitter = "gitter"
+ irc = "irc"
+ hipchat = "hipchat"
+ codecov_slack_app = "codecov_slack_app"
+
+
+notification_type_status_or_checks = {
+ Notification.status_changes,
+ Notification.status_patch,
+ Notification.status_project,
+ Notification.checks_changes,
+ Notification.checks_patch,
+ Notification.checks_project,
+}
+
+
+class NotificationState(Enum):
+ pending = "pending"
+ success = "success"
+ error = "error"
+
+
+class CompareCommitState(Enum):
+ pending = "pending"
+ processed = "processed"
+ error = "error"
+
+
+class CompareCommitError(Enum):
+ missing_base_report = "missing_base_report"
+ missing_head_report = "missing_head_report"
+ provider_client_error = "provider_client_error"
+
+
+class CommitErrorTypes(Enum):
+ INVALID_YAML = "invalid_yaml"
+ YAML_CLIENT_ERROR = "yaml_client_error"
+ YAML_UNKNOWN_ERROR = "yaml_unknown_error"
+ REPO_BOT_INVALID = "repo_bot_invalid"
+
+
+class TrialStatus(Enum):
+ NOT_STARTED = "not_started"
+ ONGOING = "ongoing"
+ EXPIRED = "expired"
+ CANNOT_TRIAL = "cannot_trial"
+
+
+class ReportType(Enum):
+ COVERAGE = "coverage"
+ TEST_RESULTS = "test_results"
+ BUNDLE_ANALYSIS = "bundle_analysis"
+
+
+class FlakeSymptomType(Enum):
+ FAILED_IN_DEFAULT_BRANCH = "failed_in_default_branch"
+ CONSECUTIVE_DIFF_OUTCOMES = "consecutive_diff_outcomes"
+ UNRELATED_MATCHING_FAILURES = "unrelated_matching_failures"
diff --git a/apps/worker/database/events.py b/apps/worker/database/events.py
new file mode 100644
index 0000000000..80ce322031
--- /dev/null
+++ b/apps/worker/database/events.py
@@ -0,0 +1,85 @@
+import json
+import logging
+
+from google.cloud import pubsub_v1
+from shared.config import get_config
+from sqlalchemy import event, inspect
+
+from database.models.core import Repository
+from helpers.environment import is_enterprise
+
+_pubsub_publisher = None
+
+log = logging.getLogger(__name__)
+
+
+def _is_shelter_enabled():
+ return get_config(
+ "setup", "shelter", "enabled", default=False if is_enterprise() else True
+ )
+
+
+def _get_pubsub_publisher():
+ global _pubsub_publisher
+ if not _pubsub_publisher:
+ _pubsub_publisher = pubsub_v1.PublisherClient()
+ return _pubsub_publisher
+
+
+def _sync_repo(repository: Repository):
+ log.info(f"Signal triggered for repository {repository.repoid}")
+ try:
+ pubsub_project_id = get_config("setup", "shelter", "pubsub_project_id")
+ pubsub_topic_id = get_config("setup", "shelter", "sync_repo_topic_id")
+
+ if pubsub_project_id and pubsub_topic_id:
+ publisher = _get_pubsub_publisher()
+ topic_path = publisher.topic_path(pubsub_project_id, pubsub_topic_id)
+ publisher.publish(
+ topic_path,
+ json.dumps(
+ {
+ "type": "repo",
+ "sync": "one",
+ "id": repository.repoid,
+ }
+ ).encode("utf-8"),
+ )
+ log.info(f"Message published for repository {repository.repoid}")
+ except Exception as e:
+ log.warning(f"Failed to publish message for repo {repository.repoid}: {e}")
+
+
+@event.listens_for(Repository, "after_insert")
+def after_insert_repo(mapper, connection, target: Repository):
+ if not _is_shelter_enabled():
+ log.debug("Shelter is not enabled, skipping after_insert signal")
+ return
+
+ # Send to shelter service
+ log.info("After insert signal", extra=dict(repoid=target.repoid))
+ _sync_repo(target)
+
+
+@event.listens_for(Repository, "after_update")
+def after_update_repo(mapper, connection, target: Repository):
+ if not _is_shelter_enabled():
+ log.debug("Shelter is not enabled, skipping after_update signal")
+ return
+
+ # Send to shelter service
+ state = inspect(target)
+
+ for attr in state.attrs:
+ if attr.key in ["name", "upload_token", "ownerid", "private"]:
+ history = attr.history
+ # Detects if there are changes and if said changes are different.
+ # has_changes() is True when you update the an entry with the same value,
+ # so we must ensure those values are different to trigger the signal
+ if history.has_changes() and history.deleted and history.added:
+ old_value = history.deleted[0]
+ new_value = history.added[0]
+ if old_value != new_value:
+ log.info("After update signal", extra=dict(repoid=target.repoid))
+ _sync_repo(target)
+ break
diff --git a/apps/worker/database/models/__init__.py b/apps/worker/database/models/__init__.py
new file mode 100644
index 0000000000..ea11f1a3d3
--- /dev/null
+++ b/apps/worker/database/models/__init__.py
@@ -0,0 +1,5 @@
+from database.models.core import *
+from database.models.labelanalysis import *
+from database.models.reports import *
+from database.models.staticanalysis import *
+from database.models.timeseries import *
diff --git a/apps/worker/database/models/core.py b/apps/worker/database/models/core.py
new file mode 100644
index 0000000000..4c86664b6e
--- /dev/null
+++ b/apps/worker/database/models/core.py
@@ -0,0 +1,673 @@
+import random
+import string
+import uuid
+from datetime import datetime
+from functools import cached_property
+from typing import Optional
+
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from sqlalchemy import Column, ForeignKey, Index, UniqueConstraint, types
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.orm import Session, backref, relationship, validates
+from sqlalchemy.schema import FetchedValue
+
+import database.models
+from database.base import CodecovBaseModel, MixinBaseClass, MixinBaseClassNoExternalID
+from database.enums import Decoration, Notification, NotificationState, ReportType
+from database.utils import ArchiveField
+from helpers.config import should_write_data_to_storage_config_check
+
+
+class AccountsUsers(CodecovBaseModel, MixinBaseClassNoExternalID):
+ __tablename__ = "codecov_auth_accountsusers"
+ user_id = Column("user_id", ForeignKey("users.id"), primary_key=True)
+ account_id = Column(
+ "account_id", ForeignKey("codecov_auth_account.id"), primary_key=True
+ )
+
+
+class User(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "users"
+ id_ = Column("id", types.BigInteger, primary_key=True)
+
+ # This field is case-insensitive but we don't have a way to represent that
+ # here. Options to address:
+ # - Upgrade sqlalchemy and use `postgresql.CITEXT(100)` as the field type
+ # - Add a case-insensitive collation to postgres[1] and use it here + in `codecov-api`
+ #
+ # [1] https://www.postgresql.org/docs/current/collation.html
+ email = Column(types.String(100), nullable=True)
+
+ name = Column(types.String(100), nullable=True)
+ is_staff = Column(types.Boolean, default=False)
+ is_superuser = Column(types.Boolean, default=False)
+ external_id = Column(postgresql.UUID(as_uuid=True), unique=True, default=uuid.uuid4)
+ email_opt_in = Column(types.Boolean, default=False)
+
+ accounts = relationship(
+ "Account", secondary="codecov_auth_accountsusers", back_populates="users"
+ )
+
+ @validates("external_id")
+ def validate_external_id(self, key, value):
+ if self.external_id:
+ raise ValueError("`external_id` cannot be modified")
+ return value
+
+
+class Account(CodecovBaseModel, MixinBaseClassNoExternalID):
+ __tablename__ = "codecov_auth_account"
+ name = Column(types.String(100), nullable=False, unique=True)
+ is_active = Column(types.Boolean, nullable=False, default=True)
+ plan = Column(
+ types.String(50),
+ nullable=False,
+ default=DEFAULT_FREE_PLAN,
+ )
+ plan_seat_count = Column(types.SmallInteger, nullable=False, default=1)
+ free_seat_count = Column(types.SmallInteger, nullable=False, default=0)
+ plan_auto_activate = Column(types.Boolean, nullable=False, default=True)
+ is_delinquent = Column(types.Boolean, nullable=False, default=False)
+
+ users = relationship(
+ "User", secondary="codecov_auth_accountsusers", back_populates="accounts"
+ )
+ organizations = relationship("Owner", back_populates="account")
+
+
+class Owner(CodecovBaseModel):
+ __tablename__ = "owners"
+ ownerid = Column(types.Integer, primary_key=True)
+ service = Column(types.String(100), nullable=False, server_default=FetchedValue())
+ service_id = Column(types.Text, nullable=False, server_default=FetchedValue())
+
+ name = Column(types.String(100), server_default=FetchedValue())
+ email = Column(types.String(300), server_default=FetchedValue())
+ username = Column(types.String(100), server_default=FetchedValue())
+ plan_activated_users = Column(
+ postgresql.ARRAY(types.Integer), server_default=FetchedValue()
+ )
+
+ createstamp = Column(types.DateTime, server_default=FetchedValue())
+ admins = Column(postgresql.ARRAY(types.Integer), server_default=FetchedValue())
+ permission = Column(postgresql.ARRAY(types.Integer), server_default=FetchedValue())
+ organizations = Column(
+ postgresql.ARRAY(types.Integer), server_default=FetchedValue()
+ )
+ free = Column(
+ types.Integer, nullable=False, default=0, server_default=FetchedValue()
+ )
+
+ account_id = Column(types.BigInteger, ForeignKey("codecov_auth_account.id"))
+ account = relationship("Account", back_populates="organizations")
+
+ # DEPRECATED - Prefer GithubAppInstallation
+ integration_id = Column(types.Integer, server_default=FetchedValue())
+ yaml = Column(postgresql.JSON, server_default=FetchedValue())
+ oauth_token = Column(types.Text, server_default=FetchedValue())
+ avatar_url = Column(types.Text, server_default=FetchedValue())
+ updatestamp = Column(types.DateTime, server_default=FetchedValue())
+ parent_service_id = Column(types.Text, server_default=FetchedValue())
+ root_parent_service_id = Column(types.Text, nullable=True)
+ plan_provider = Column(types.Text, server_default=FetchedValue())
+ trial_start_date = Column(types.DateTime, server_default=FetchedValue())
+ trial_end_date = Column(types.DateTime, server_default=FetchedValue())
+ trial_status = Column(types.Text, server_default=FetchedValue())
+ trial_fired_by = Column(types.Integer, server_default=FetchedValue())
+ plan = Column(types.Text, default=DEFAULT_FREE_PLAN)
+ plan_user_count = Column(types.SmallInteger, server_default=FetchedValue())
+ pretrial_users_count = Column(types.SmallInteger, server_default=FetchedValue())
+ plan_auto_activate = Column(types.Boolean, server_default=FetchedValue())
+ stripe_customer_id = Column(types.Text, server_default=FetchedValue())
+ stripe_subscription_id = Column(types.Text, server_default=FetchedValue())
+ onboarding_completed = Column(types.Boolean, default=False)
+ upload_token_required_for_public_repos = Column(
+ types.Boolean, default=False, nullable=False
+ )
+
+ bot_id = Column(
+ "bot",
+ types.Integer,
+ ForeignKey("owners.ownerid"),
+ server_default=FetchedValue(),
+ )
+
+ bot = relationship("Owner", remote_side=[ownerid])
+ repositories = relationship(
+ "Repository",
+ back_populates="owner",
+ foreign_keys="Repository.ownerid",
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+ github_app_installations = relationship(
+ "GithubAppInstallation",
+ back_populates="owner",
+ foreign_keys="GithubAppInstallation.ownerid",
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+
+ # TODO: Create association between `User` and `Owner` mirroring `codecov-api`
+ # https://github.com/codecov/codecov-api/blob/204f7fd7e37896efe0259e4bc91aad20601087e0/codecov_auth/models.py#L196-L202
+
+ __table_args__ = (
+ Index("owner_service_ids", "service", "service_id", unique=True),
+ Index("owner_service_username", "service", "username", unique=True),
+ )
+
+ @property
+ def slug(self: "Owner") -> str:
+ return self.username
+
+ @property
+ def root_organization(self: "Owner") -> Optional["Owner"]:
+ """
+ Find the root organization of Gitlab OwnerOrg, by using the root_parent_service_id
+ if it exists, otherwise iterating through the parents and cache it in root_parent_service_id
+ """
+ db_session = self.get_db_session()
+ if self.root_parent_service_id:
+ return self._get_owner_by_service_id(
+ db_session, self.root_parent_service_id
+ )
+
+ root = None
+ if self.service == "gitlab" and self.parent_service_id:
+ root = self
+ while root.parent_service_id is not None:
+ root = self._get_owner_by_service_id(db_session, root.parent_service_id)
+ self.root_parent_service_id = root.service_id
+ db_session.commit()
+ return root
+
+ def _get_owner_by_service_id(
+ self: "Owner", db_session: Session, service_id: str
+ ) -> "Owner":
+ """
+ Helper method to fetch an Owner by service_id.
+ """
+ return (
+ db_session.query(Owner)
+ .filter_by(service_id=service_id, service=self.service)
+ .one()
+ )
+
+ def __repr__(self: "Owner") -> str:
+ return f"Owner<{self.ownerid}@service<{self.service}>>"
+
+
+class Repository(CodecovBaseModel):
+ __tablename__ = "repos"
+
+ repoid = Column(types.Integer, primary_key=True)
+ ownerid = Column(types.Integer, ForeignKey("owners.ownerid"))
+ bot_id = Column("bot", types.Integer, ForeignKey("owners.ownerid"))
+ service_id = Column(types.Text)
+ name = Column(types.Text)
+ private = Column(types.Boolean)
+ updatestamp = Column(types.DateTime)
+ yaml = Column(postgresql.JSON)
+ deleted = Column(types.Boolean, nullable=False, default=False)
+ branch = Column(types.Text, default="main")
+ image_token = Column(
+ types.Text,
+ default=lambda: "".join(
+ random.choice(string.ascii_uppercase + string.digits) for _ in range(10)
+ ),
+ )
+ language = Column(types.Text)
+ languages = Column(postgresql.ARRAY(types.String), nullable=True, default=[])
+ languages_last_updated = Column(types.DateTime, server_default=FetchedValue())
+ hookid = Column(types.Text)
+ webhook_secret = Column(types.Text)
+ activated = Column(types.Boolean, default=False)
+ bundle_analysis_enabled = Column(types.Boolean, default=False)
+ test_analytics_enabled = Column(types.Boolean, default=False)
+ upload_token = Column(postgresql.UUID, server_default=FetchedValue())
+
+ # DEPRECATED - prefer GithubAppInstallation.is_repo_covered_by_integration
+ using_integration = Column(types.Boolean)
+
+ owner = relationship(Owner, foreign_keys=[ownerid], back_populates="repositories")
+ bot = relationship(Owner, foreign_keys=[bot_id])
+
+ __table_args__ = (
+ Index("repos_slug", "ownerid", "name", unique=True),
+ Index("repos_service_ids", "ownerid", "service_id", unique=True),
+ )
+
+ @property
+ def slug(self):
+ return f"{self.owner.slug}/{self.name}"
+
+ @property
+ def service(self):
+ return self.owner.service
+
+ def __repr__(self):
+ return f"Repo<{self.repoid}>"
+
+
+GITHUB_APP_INSTALLATION_DEFAULT_NAME = "codecov_app_installation"
+
+
+class GithubAppInstallation(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "codecov_auth_githubappinstallation"
+
+ # replacement for owner.integration_id
+ # installation id GitHub sends us in the installation-related webhook events
+ installation_id = Column(types.Integer, server_default=FetchedValue())
+ name = Column(types.Text, default=GITHUB_APP_INSTALLATION_DEFAULT_NAME)
+ # if null, all repos are covered by this installation
+ # otherwise, it's a list of repo.id values
+ repository_service_ids = Column(
+ postgresql.ARRAY(types.Text), server_default=FetchedValue()
+ )
+ # Data required to get a token from gh
+ app_id = Column(types.Text, server_default=FetchedValue())
+ pem_path = Column(types.Text, server_default=FetchedValue())
+
+ ownerid = Column("owner_id", types.Integer, ForeignKey("owners.ownerid"))
+ owner = relationship(
+ Owner, foreign_keys=[ownerid], back_populates="github_app_installations"
+ )
+
+ is_suspended = Column(types.Boolean, default=False)
+
+ def repository_queryset(self, dbsession: Session):
+ """Returns a query set of repositories covered by this installation"""
+ if self.repository_service_ids is None:
+ # All repos covered
+ return dbsession.query(Repository).filter(
+ Repository.ownerid == self.ownerid
+ )
+ # Some repos covered
+ return dbsession.query(Repository).filter(
+ Repository.service_id.in_(self.repository_service_ids),
+ Repository.ownerid == self.ownerid,
+ )
+
+ def covers_all_repos(self) -> bool:
+ return self.repository_service_ids is None
+
+ def is_repo_covered_by_integration(self, repo: Repository) -> bool:
+ if self.covers_all_repos():
+ return repo.ownerid == self.ownerid
+ return repo.service_id in self.repository_service_ids
+
+ def is_configured(self) -> bool:
+ """Returns whether this installation is properly configured and can be used"""
+ if self.name == GITHUB_APP_INSTALLATION_DEFAULT_NAME:
+ # The default app is configured in the installation YAML
+ return True
+ return self.app_id is not None and self.pem_path is not None
+
+
+class OwnerInstallationNameToUseForTask(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "codecov_auth_ownerinstallationnametousefortask"
+
+ ownerid = Column("owner_id", types.Integer, ForeignKey("owners.ownerid"))
+ owner = relationship(
+ Owner,
+ foreign_keys=[ownerid],
+ )
+ installation_name = Column(types.Text, server_default=FetchedValue())
+ task_name = Column(types.Text, server_default=FetchedValue())
+
+ __table_args__ = (
+ UniqueConstraint(
+ "owner_id",
+ "task_name",
+ name="single_task_name_per_owner",
+ ),
+ )
+
+
+class Commit(CodecovBaseModel):
+ __tablename__ = "commits"
+
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ author_id = Column("author", types.Integer, ForeignKey("owners.ownerid"))
+ branch = Column(types.Text)
+ ci_passed = Column(types.Boolean)
+ commitid = Column(types.Text)
+ deleted = Column(types.Boolean)
+ message = Column(types.Text)
+ notified = Column(types.Boolean)
+ merged = Column(types.Boolean)
+ parent_commit_id = Column("parent", types.Text)
+ pullid = Column(types.Integer)
+ repoid = Column(types.Integer, ForeignKey("repos.repoid"))
+ state = Column(types.String(256))
+ timestamp = Column(types.DateTime, nullable=False)
+ updatestamp = Column(types.DateTime, nullable=True)
+ totals = Column(postgresql.JSON)
+
+ author = relationship(Owner)
+ repository = relationship(Repository, backref=backref("commits", cascade="delete"))
+ notifications = relationship(
+ "CommitNotification",
+ backref=backref("commits"),
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+ reports_list = relationship(
+ "CommitReport",
+ back_populates="commit",
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+
+ def __repr__(self):
+ return f"Commit<{self.commitid}@repo<{self.repoid}>>"
+
+ def get_parent_commit(self):
+ db_session = self.get_db_session()
+ return (
+ db_session.query(Commit)
+ .filter_by(repoid=self.repoid, commitid=self.parent_commit_id)
+ .first()
+ )
+
+ @property
+ def report(self):
+ db_session = self.get_db_session()
+ CommitReport = database.models.reports.CommitReport
+ return (
+ db_session.query(CommitReport)
+ .filter(
+ (CommitReport.commit_id == self.id_)
+ & (CommitReport.code == None) # noqa: E711
+ & (
+ (CommitReport.report_type == None) # noqa: E711
+ | (CommitReport.report_type == ReportType.COVERAGE.value)
+ )
+ )
+ .first()
+ )
+
+ def commit_report(self, report_type: ReportType):
+ db_session = self.get_db_session()
+ CommitReport = database.models.reports.CommitReport
+ if report_type == ReportType.COVERAGE:
+ return self.report
+ else:
+ return (
+ db_session.query(CommitReport)
+ .filter(
+ (CommitReport.commit_id == self.id_)
+ & (CommitReport.code == None) # noqa: E711
+ & (CommitReport.report_type == report_type.value)
+ )
+ .first()
+ )
+
+ @property
+ def id(self):
+ return self.id_
+
+ @property
+ def external_id(self):
+ return self.commitid
+
+ def get_repository(self):
+ return self.repository
+
+ def get_commitid(self):
+ return self.commitid
+
+ def should_write_to_storage(self: object) -> bool:
+ if self.repository is None or self.repository.owner is None:
+ return False
+ is_codecov_repo = self.repository.owner.username == "codecov"
+ return should_write_data_to_storage_config_check(
+ "commit_report", is_codecov_repo, self.repository.repoid
+ )
+
+ # Use custom JSON to properly serialize custom data classes on reports
+ _report_json = Column("report", postgresql.JSON)
+ _report_json_storage_path = Column("report_storage_path", types.Text, nullable=True)
+ report_json = ArchiveField(
+ should_write_to_storage_fn=should_write_to_storage,
+ default_value_class=dict,
+ )
+
+
+class Branch(CodecovBaseModel):
+ __tablename__ = "branches"
+
+ repoid = Column(types.Integer, ForeignKey("repos.repoid"), primary_key=True)
+ updatestamp = Column(types.DateTime, default=datetime.now)
+ branch = Column(types.Text, nullable=False, primary_key=True)
+ base = Column(types.Text)
+ head = Column(types.Text, nullable=False)
+ authors = Column(postgresql.ARRAY(types.Integer))
+
+ repository = relationship(Repository, backref=backref("branches", cascade="delete"))
+
+ __table_args__ = (Index("branches_repoid_branch", "repoid", "branch", unique=True),)
+
+ def __repr__(self):
+ return f"Branch<{self.branch}@repo<{self.repoid}>>"
+
+
+class LoginSession(CodecovBaseModel):
+ __tablename__ = "sessions"
+
+ sessionid = Column(types.Integer, primary_key=True)
+ token = Column(postgresql.UUID(as_uuid=True))
+ name = Column(types.Text)
+ ownerid = Column(types.Integer, ForeignKey("owners.ownerid"))
+ session_type = Column("type", types.Text)
+ lastseen = Column(types.DateTime(timezone=True))
+ useragent = Column(types.Text)
+ ip = Column(types.Text)
+
+
+class Pull(CodecovBaseModel):
+ __tablename__ = "pulls"
+
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ repoid = Column(types.Integer, ForeignKey("repos.repoid"))
+ pullid = Column(types.Integer, nullable=False)
+ issueid = Column(types.Integer)
+ updatestamp = Column(
+ types.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
+ )
+ state = Column(types.Text, nullable=False, default="open")
+ title = Column(types.Text)
+ base = Column(types.Text)
+ user_provided_base_sha = Column(types.Text)
+ compared_to = Column(types.Text)
+ head = Column(types.Text)
+ commentid = Column(types.Text)
+ bundle_analysis_commentid = Column(types.Text)
+ diff = Column(postgresql.JSON)
+ author_id = Column("author", types.Integer, ForeignKey("owners.ownerid"))
+ behind_by = Column(types.Integer)
+ behind_by_commit = Column(types.Text)
+
+ author = relationship(Owner)
+ repository = relationship(
+ Repository, backref=backref("pulls", cascade="delete", lazy="dynamic")
+ )
+
+ def should_write_to_storage(self: object) -> bool:
+ if self.repository is None or self.repository.owner is None:
+ return False
+ is_codecov_repo = self.repository.owner.username == "codecov"
+ return should_write_data_to_storage_config_check(
+ master_switch_key="pull_flare",
+ is_codecov_repo=is_codecov_repo,
+ repoid=self.repository.repoid,
+ )
+
+ _flare = Column("flare", postgresql.JSON)
+ _flare_storage_path = Column("flare_storage_path", types.Text, nullable=True)
+ flare = ArchiveField(
+ should_write_to_storage_fn=should_write_to_storage, default_value_class=dict
+ )
+
+ __table_args__ = (Index("pulls_repoid_pullid", "repoid", "pullid", unique=True),)
+
+ def __repr__(self):
+ return f"Pull<{self.pullid}@repo<{self.repoid}>>"
+
+ def get_head_commit(self):
+ return (
+ self.get_db_session()
+ .query(Commit)
+ .filter_by(repoid=self.repoid, commitid=self.head)
+ .first()
+ )
+
+ def get_comparedto_commit(self):
+ return (
+ self.get_db_session()
+ .query(Commit)
+ .filter_by(repoid=self.repoid, commitid=self.compared_to)
+ .first()
+ )
+
+ def get_head_commit_notifications(self):
+ head_commit = self.get_head_commit()
+ if head_commit:
+ return (
+ self.get_db_session()
+ .query(CommitNotification)
+ .filter_by(commit_id=head_commit.id_)
+ .all()
+ )
+ return []
+
+ def get_repository(self):
+ return self.repository
+
+ def get_commitid(self):
+ return None
+
+ @property
+ def external_id(self):
+ return self.pullid
+
+ @property
+ def id(self):
+ return self.id_
+
+ @cached_property
+ def is_first_coverage_pull(self):
+ """
+ This method determines if this is the first PR that has coverage.
+
+ It fetches the first record that has a commentid, which implies having a coverage comment.
+ If it doesn't exist, this is the first record ever, hence returning true. Else, we evaluate if
+ the record found is the same as the current one.
+ """
+
+ first_pull_with_coverage = (
+ self.repository.pulls.with_entities(
+ Pull.id_, Pull.commentid, Pull.bundle_analysis_commentid
+ )
+ .filter(Pull.commentid != None) # noqa: E711
+ .order_by(Pull.id_)
+ .first()
+ )
+
+ if first_pull_with_coverage:
+ return first_pull_with_coverage.id_ == self.id_
+ return True
+
+
+class CommitNotification(CodecovBaseModel):
+ __tablename__ = "commit_notifications"
+
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ gh_app_id = Column(
+ types.BigInteger,
+ ForeignKey("codecov_auth_githubappinstallation.id"),
+ nullable=True,
+ )
+ notification_type = Column(
+ postgresql.ENUM(Notification, values_callable=lambda x: [e.value for e in x]),
+ nullable=False,
+ )
+ decoration_type = Column(
+ postgresql.ENUM(Decoration, values_callable=lambda x: [e.value for e in x])
+ )
+ created_at = Column(types.DateTime, default=datetime.now())
+ updated_at = Column(types.DateTime, default=datetime.now(), onupdate=datetime.now())
+ state = Column(
+ postgresql.ENUM(
+ NotificationState, values_callable=lambda x: [e.value for e in x]
+ )
+ )
+
+ commit = relationship(Commit, foreign_keys=[commit_id])
+
+ __table_args__ = (
+ Index("notifications_commit_id", "commit_id"),
+ UniqueConstraint(
+ "commit_id",
+ "notification_type",
+ name="commit_notifications_commit_id_notification_type",
+ ),
+ )
+
+ def __repr__(self):
+ return f"Notification<{self.notification_type}@commit<{self.commit_id}>>"
+
+
+class CompareCommit(MixinBaseClass, CodecovBaseModel):
+ __tablename__ = "compare_commitcomparison"
+
+ base_commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ base_commit = relationship(Commit, foreign_keys=[base_commit_id])
+ compare_commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ compare_commit = relationship(Commit, foreign_keys=[compare_commit_id])
+ report_storage_path = Column(types.String(150))
+ patch_totals = Column(postgresql.JSON)
+ state = Column(types.Text)
+ error = Column(types.Text)
+
+ __table_args__ = (
+ Index("compare_commitcomparison_base_commit_id_cf53c1d9", "base_commit_id"),
+ Index(
+ "compare_commitcomparison_compare_commit_id_3ea19610", "compare_commit_id"
+ ),
+ UniqueConstraint(
+ "base_commit_id",
+ "compare_commit_id",
+ name="unique_comparison_between_commit",
+ ),
+ )
+
+ def __repr__(self):
+ return f"CompareCommit<{self.base_commit_id}...{self.compare_commit_id}>"
+
+
+class CommitError(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "core_commiterror"
+
+ commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ commit = relationship(Commit, foreign_keys=[commit_id], backref="errors")
+ error_code = Column(types.String(100), nullable=True)
+ error_params = Column(postgresql.JSON, default=dict)
+
+
+class OrganizationLevelToken(MixinBaseClass, CodecovBaseModel):
+ __tablename__ = "codecov_auth_organizationleveltoken"
+
+ ownerid = Column(types.Integer, ForeignKey("owners.ownerid"))
+ owner = relationship(Owner, foreign_keys=[ownerid])
+ token = Column(postgresql.UUID)
+ valid_until = Column(types.DateTime)
+ token_type = Column(types.String)
+
+
+class Constants(CodecovBaseModel):
+ __tablename__ = "constants"
+
+ key = Column(types.String, primary_key=True)
+ value = Column(types.String)
diff --git a/apps/worker/database/models/labelanalysis.py b/apps/worker/database/models/labelanalysis.py
new file mode 100644
index 0000000000..d0382028f1
--- /dev/null
+++ b/apps/worker/database/models/labelanalysis.py
@@ -0,0 +1,40 @@
+from enum import Enum
+
+from sqlalchemy import Column, ForeignKey, types
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.orm import relationship
+
+from database.base import CodecovBaseModel, MixinBaseClass
+
+
+class LabelAnalysisProcessingErrorCode(Enum):
+ NOT_FOUND = "not found"
+ FAILED = "failed"
+ MISSING_DATA = "missing data"
+
+
+class LabelAnalysisRequest(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "labelanalysis_labelanalysisrequest"
+ base_commit_id = Column(types.BigInteger, ForeignKey("commits.id"), nullable=False)
+ head_commit_id = Column(types.BigInteger, ForeignKey("commits.id"), nullable=False)
+ requested_labels = Column(postgresql.ARRAY(types.Text), nullable=True)
+ state_id = Column(types.Integer, nullable=False)
+ result = Column(postgresql.JSON, nullable=True)
+ # relationships
+ base_commit = relationship("Commit", foreign_keys=[base_commit_id])
+ head_commit = relationship("Commit", foreign_keys=[head_commit_id])
+
+
+class LabelAnalysisProcessingError(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "labelanalysis_labelanalysisprocessingerror"
+ label_analysis_request = relationship(LabelAnalysisRequest, backref="errors")
+ label_analysis_request_id = Column(
+ "label_analysis_request_id",
+ types.BigInteger,
+ ForeignKey("labelanalysis_labelanalysisrequest.id"),
+ )
+ error_code = Column(types.String(100), nullable=False)
+ error_params = Column(postgresql.JSON, nullable=True)
+
+ def to_representation(self):
+ return dict(error_code=self.error_code, error_params=self.error_params)
diff --git a/apps/worker/database/models/reports.py b/apps/worker/database/models/reports.py
new file mode 100644
index 0000000000..0a13a2efb7
--- /dev/null
+++ b/apps/worker/database/models/reports.py
@@ -0,0 +1,353 @@
+import logging
+import uuid
+from decimal import Decimal
+from functools import cached_property
+
+from sqlalchemy import Column, ForeignKey, Table, UniqueConstraint, types
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import backref, relationship
+
+from database.base import CodecovBaseModel, MixinBaseClass, MixinBaseClassNoExternalID
+from database.models.core import Commit, CompareCommit, Repository
+from helpers.clock import get_utc_now
+from helpers.number import precise_round
+
+log = logging.getLogger(__name__)
+
+
+class RepositoryFlag(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_repositoryflag"
+ repository_id = Column(types.Integer, ForeignKey("repos.repoid"))
+ repository = relationship(Repository, backref=backref("flags"))
+ flag_name = Column(types.String(1024), nullable=False)
+ deleted = Column(types.Boolean, nullable=True)
+
+
+class CommitReport(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_commitreport"
+ commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ code = Column(types.String(100), nullable=True)
+ report_type = Column(types.String(100), nullable=True)
+ commit: Commit = relationship(
+ "Commit",
+ foreign_keys=[commit_id],
+ back_populates="reports_list",
+ cascade="all, delete",
+ )
+ totals = relationship(
+ "ReportLevelTotals",
+ back_populates="report",
+ uselist=False,
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+ uploads = relationship(
+ "Upload", back_populates="report", cascade="all, delete", passive_deletes=True
+ )
+ patch_results = relationship(
+ "ReportResults",
+ uselist=False,
+ back_populates="report",
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+ test_result_totals = relationship(
+ "TestResultReportTotals",
+ back_populates="report",
+ uselist=False,
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+
+
+uploadflagmembership = Table(
+ "reports_uploadflagmembership",
+ CodecovBaseModel.metadata,
+ Column("upload_id", types.BigInteger, ForeignKey("reports_upload.id")),
+ Column("flag_id", types.BigInteger, ForeignKey("reports_repositoryflag.id")),
+)
+
+
+class ReportResults(MixinBaseClass, CodecovBaseModel):
+ __tablename__ = "reports_reportresults"
+ state = Column(types.Text)
+ completed_at = Column(types.DateTime(timezone=True), nullable=True)
+ result = Column(postgresql.JSON)
+ report_id = Column(types.BigInteger, ForeignKey("reports_commitreport.id"))
+ report = relationship("CommitReport", foreign_keys=[report_id])
+
+
+class Upload(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_upload"
+ build_code = Column(types.Text)
+ build_url = Column(types.Text)
+ env = Column(postgresql.JSON)
+ job_code = Column(types.Text)
+ name = Column(types.String(100))
+ provider = Column(types.String(50))
+ report_id = Column(types.BigInteger, ForeignKey("reports_commitreport.id"))
+ report: CommitReport = relationship(
+ "CommitReport", foreign_keys=[report_id], back_populates="uploads"
+ )
+ state = Column(types.String(100), nullable=False)
+ storage_path = Column(types.Text, nullable=False)
+ order_number = Column(types.Integer)
+ flags = relationship(RepositoryFlag, secondary=uploadflagmembership)
+ totals = relationship(
+ "UploadLevelTotals",
+ back_populates="upload",
+ uselist=False,
+ cascade="all, delete",
+ passive_deletes=True,
+ )
+ upload_extras = Column(postgresql.JSON, nullable=False)
+ upload_type = Column(types.String(100), nullable=False)
+ state_id = Column(types.Integer)
+ upload_type_id = Column(types.Integer)
+
+ @cached_property
+ def flag_names(self) -> list[str]:
+ return [f.flag_name for f in self.flags]
+
+
+class UploadError(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_uploaderror"
+ report_upload = relationship(Upload, backref="errors")
+ upload_id = Column("upload_id", types.BigInteger, ForeignKey("reports_upload.id"))
+ error_code = Column(types.String(100), nullable=False)
+ error_params = Column(postgresql.JSON, default=dict)
+
+
+class AbstractTotals(MixinBaseClass):
+ branches = Column(types.Integer)
+ coverage = Column(types.Numeric(precision=8, scale=5))
+ hits = Column(types.Integer)
+ lines = Column(types.Integer)
+ methods = Column(types.Integer)
+ misses = Column(types.Integer)
+ partials = Column(types.Integer)
+ files = Column(types.Integer)
+
+ def update_from_totals(self, totals, precision=2, rounding="down"):
+ self.branches = totals.branches
+ if totals.coverage is not None:
+ coverage: Decimal = Decimal(totals.coverage)
+ self.coverage = precise_round(
+ coverage, precision=precision, rounding=rounding
+ )
+ # Temporary until the table starts accepting NULLs
+ else:
+ self.coverage = 0
+ self.hits = totals.hits
+ self.lines = totals.lines
+ self.methods = totals.methods
+ self.misses = totals.misses
+ self.partials = totals.partials
+ self.files = totals.files
+
+ class Meta:
+ abstract = True
+
+
+class ReportLevelTotals(CodecovBaseModel, AbstractTotals):
+ __tablename__ = "reports_reportleveltotals"
+ report_id = Column(types.BigInteger, ForeignKey("reports_commitreport.id"))
+ report = relationship("CommitReport", foreign_keys=[report_id])
+
+
+class UploadLevelTotals(CodecovBaseModel, AbstractTotals):
+ __tablename__ = "reports_uploadleveltotals"
+ upload_id = Column("upload_id", types.BigInteger, ForeignKey("reports_upload.id"))
+ upload = relationship("Upload", foreign_keys=[upload_id])
+
+
+class CompareFlag(MixinBaseClass, CodecovBaseModel):
+ __tablename__ = "compare_flagcomparison"
+
+ commit_comparison_id = Column(
+ types.BigInteger, ForeignKey("compare_commitcomparison.id")
+ )
+ repositoryflag_id = Column(
+ types.BigInteger, ForeignKey("reports_repositoryflag.id")
+ )
+ head_totals = Column(postgresql.JSON)
+ base_totals = Column(postgresql.JSON)
+ patch_totals = Column(postgresql.JSON)
+
+ commit_comparison = relationship(CompareCommit, foreign_keys=[commit_comparison_id])
+ repositoryflag = relationship(RepositoryFlag, foreign_keys=[repositoryflag_id])
+
+
+class CompareComponent(MixinBaseClass, CodecovBaseModel):
+ __tablename__ = "compare_componentcomparison"
+
+ commit_comparison_id = Column(
+ types.BigInteger, ForeignKey("compare_commitcomparison.id")
+ )
+ component_id = Column(types.String(100), nullable=False)
+ head_totals = Column(postgresql.JSON)
+ base_totals = Column(postgresql.JSON)
+ patch_totals = Column(postgresql.JSON)
+
+ commit_comparison = relationship(CompareCommit, foreign_keys=[commit_comparison_id])
+
+
+class Test(CodecovBaseModel):
+ __tablename__ = "reports_test"
+ # the reason we aren't using the regular primary key
+ # in this case is because we want to be able to compute/predict
+ # the primary key of a Test object ourselves in the processor
+ # so we can easily do concurrent writes to the database
+ # this is a hash of the repoid, name, testsuite and env
+ id_ = Column("id", types.Text, primary_key=True)
+ external_id = Column(
+ UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False
+ )
+ created_at = Column(types.DateTime(timezone=True), default=get_utc_now)
+ updated_at = Column(
+ types.DateTime(timezone=True), onupdate=get_utc_now, default=get_utc_now
+ )
+
+ @property
+ def id(self):
+ return self.id_
+
+ repoid = Column(types.Integer, ForeignKey("repos.repoid"))
+ repository = relationship("Repository", backref=backref("tests"))
+ name = Column(types.String(256), nullable=False)
+ testsuite = Column(types.String(256), nullable=False)
+ # this is a hash of the flags associated with this test
+ # users will use flags to distinguish the same test being run
+ # in a different environment
+ # for example: the same test being run on windows vs. mac
+ flags_hash = Column(types.String(256), nullable=False)
+
+ framework = Column(types.String(100), nullable=True)
+
+ computed_name = Column(types.Text, nullable=True)
+ filename = Column(types.Text, nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint(
+ "repoid",
+ "name",
+ "testsuite",
+ "flags_hash",
+ name="reports_test_repoid_name_testsuite_flags_hash",
+ ),
+ )
+
+
+class TestInstance(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_testinstance"
+ test_id = Column(types.Text, ForeignKey("reports_test.id"))
+ test = relationship(Test, backref=backref("testinstances"))
+ duration_seconds = Column(types.Float, nullable=False)
+ outcome = Column(types.String(100), nullable=False)
+ upload_id = Column(types.BigInteger, ForeignKey("reports_upload.id"))
+ upload = relationship("Upload", backref=backref("testinstances"))
+ failure_message = Column(types.Text)
+ branch = Column(types.Text, nullable=True)
+ commitid = Column(types.Text, nullable=True)
+ repoid = Column(types.Integer, nullable=True)
+
+ reduced_error_id = Column(
+ types.BigInteger, ForeignKey("reports_reducederror.id"), nullable=True
+ )
+ reduced_error = relationship("ReducedError", backref=backref("testinstances"))
+
+
+class TestResultReportTotals(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "reports_testresultreporttotals"
+ report_id = Column(types.BigInteger, ForeignKey("reports_commitreport.id"))
+ report = relationship("CommitReport", foreign_keys=[report_id])
+ passed = Column(types.Integer)
+ skipped = Column(types.Integer)
+ failed = Column(types.Integer)
+
+ # this field is no longer used in the new ta_finisher task
+ # TODO: thus, it will be removed in the future
+ error = Column(types.String(100), nullable=True)
+
+
+class ReducedError(CodecovBaseModel):
+ __tablename__ = "reports_reducederror"
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ message = Column(types.Text)
+ created_at = Column(types.DateTime(timezone=True), default=get_utc_now)
+ updated_at = Column(
+ types.DateTime(timezone=True), onupdate=get_utc_now, default=get_utc_now
+ )
+
+ @property
+ def id(self):
+ return self.id_
+
+
+class Flake(CodecovBaseModel):
+ __tablename__ = "reports_flake"
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ repoid = Column(types.Integer, ForeignKey("repos.repoid"))
+ repository = relationship("Repository", backref=backref("flakes"))
+
+ testid = Column(types.Text, ForeignKey("reports_test.id"))
+ test = relationship(Test, backref=backref("flakes"))
+
+ reduced_error_id = Column(
+ types.BigInteger, ForeignKey("reports_reducederror.id"), nullable=True
+ )
+ reduced_error = relationship(ReducedError, backref=backref("flakes"))
+
+ recent_passes_count = Column(types.Integer)
+ count = Column(types.Integer)
+ fail_count = Column(types.Integer)
+ start_date = Column(types.DateTime)
+ end_date = Column(types.DateTime, nullable=True)
+
+ @property
+ def id(self):
+ return self.id_
+
+
+class DailyTestRollup(CodecovBaseModel, MixinBaseClassNoExternalID):
+ __tablename__ = "reports_dailytestrollups"
+
+ test_id = Column(types.Text, ForeignKey("reports_test.id"))
+ test = relationship(Test, backref=backref("dailytestrollups"))
+ date = Column(types.Date)
+ repoid = Column(types.Integer)
+ branch = Column(types.Text)
+
+ fail_count = Column(types.Integer)
+ flaky_fail_count = Column(types.Integer)
+ skip_count = Column(types.Integer)
+ pass_count = Column(types.Integer)
+ last_duration_seconds = Column(types.Float)
+ avg_duration_seconds = Column(types.Float)
+ latest_run = Column(types.DateTime)
+ commits_where_fail = Column(types.ARRAY(types.Text))
+
+ __table_args__ = (
+ UniqueConstraint(
+ "repoid",
+ "date",
+ "branch",
+ "test_id",
+ name="reports_dailytestrollups_repoid_date_branch_test",
+ ),
+ )
+
+
+class TestFlagBridge(CodecovBaseModel):
+ __tablename__ = "reports_test_results_flag_bridge"
+
+ id_ = Column("id", types.BigInteger, primary_key=True)
+
+ test_id = Column(types.Text, ForeignKey("reports_test.id"))
+ test = relationship(Test, backref=backref("test_flag_bridges"))
+
+ flag_id = Column(
+ "flag_id", types.BigInteger, ForeignKey("reports_repositoryflag.id")
+ )
+ flag = relationship("RepositoryFlag", backref=backref("test_flag_bridges"))
diff --git a/apps/worker/database/models/staticanalysis.py b/apps/worker/database/models/staticanalysis.py
new file mode 100644
index 0000000000..22f3bd5110
--- /dev/null
+++ b/apps/worker/database/models/staticanalysis.py
@@ -0,0 +1,37 @@
+from sqlalchemy import Column, ForeignKey, types
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import relationship
+
+from database.base import CodecovBaseModel, MixinBaseClass
+
+
+class StaticAnalysisSuite(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "staticanalysis_staticanalysissuite"
+ commit_id = Column(types.BigInteger, ForeignKey("commits.id"))
+ # relationships
+ commit = relationship("Commit")
+
+
+class StaticAnalysisSingleFileSnapshot(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "staticanalysis_staticanalysissinglefilesnapshot"
+ repository_id = Column(types.Integer, ForeignKey("repos.repoid"))
+ file_hash = Column(UUID, nullable=False)
+ content_location = Column(types.Text, nullable=False)
+ state_id = Column(types.Integer, nullable=False)
+ # relationships
+ repository = relationship("Repository")
+
+
+class StaticAnalysisSuiteFilepath(CodecovBaseModel, MixinBaseClass):
+ __tablename__ = "staticanalysis_staticanalysissuitefilepath"
+ analysis_suite_id = Column(
+ types.BigInteger, ForeignKey("staticanalysis_staticanalysissuite.id")
+ )
+ file_snapshot_id = Column(
+ types.BigInteger,
+ ForeignKey("staticanalysis_staticanalysissinglefilesnapshot.id"),
+ )
+ filepath = Column(types.Text, nullable=False)
+ # relationships
+ file_snapshot = relationship(StaticAnalysisSingleFileSnapshot)
+ analysis_suite = relationship(StaticAnalysisSuite)
diff --git a/apps/worker/database/models/timeseries.py b/apps/worker/database/models/timeseries.py
new file mode 100644
index 0000000000..f96b0a8a4c
--- /dev/null
+++ b/apps/worker/database/models/timeseries.py
@@ -0,0 +1,90 @@
+from enum import Enum
+
+from sqlalchemy import Column, types
+from sqlalchemy.schema import Index
+
+from database.base import CodecovBaseModel
+
+
+class TimeseriesBaseModel(CodecovBaseModel):
+ __abstract__ = True
+
+
+class MeasurementName(Enum):
+ coverage = "coverage"
+ flag_coverage = "flag_coverage"
+ component_coverage = "component_coverage"
+ # For tracking the entire size of a bundle report by its name
+ bundle_analysis_report_size = "bundle_analysis_report_size"
+ # For tracking the size of a category of assets of a bundle report by its name
+ bundle_analysis_javascript_size = "bundle_analysis_javascript_size"
+ bundle_analysis_stylesheet_size = "bundle_analysis_stylesheet_size"
+ bundle_analysis_font_size = "bundle_analysis_font_size"
+ bundle_analysis_image_size = "bundle_analysis_image_size"
+ # For tracking individual asset size via its UUID
+ bundle_analysis_asset_size = "bundle_analysis_asset_size"
+
+
+class Measurement(TimeseriesBaseModel):
+ """
+ This model is defined here in order to describe the available columns and
+ indexes on the table. The table does not have a primary key and so you'll
+ likely run into issues if you try to use the model in an ORM-style of loading
+ and saving single records based on the primary key. The primary key is defined
+ below to appease SQLAlchemy only and is not intended to be used.
+
+ See `services/timeseries.py` for an example of inserting/updating measurements.
+ """
+
+ __tablename__ = "timeseries_measurement"
+
+ timestamp = Column(types.DateTime(timezone=True), nullable=False)
+ owner_id = Column(types.BigInteger, nullable=False)
+ repo_id = Column(types.BigInteger, nullable=False)
+ measurable_id = Column(types.String(256))
+ branch = Column(types.String(256))
+ commit_sha = Column(types.String(256), nullable=False)
+ name = Column(types.String(256), nullable=False)
+ value = Column(types.Float, nullable=False)
+
+ __table_args__ = (
+ Index(
+ "timeseries_measurement_measurable_unique",
+ timestamp,
+ owner_id,
+ repo_id,
+ measurable_id,
+ commit_sha,
+ name,
+ unique=True,
+ ),
+ )
+
+ __mapper_args__ = {
+ "primary_key": [
+ timestamp,
+ owner_id,
+ repo_id,
+ measurable_id,
+ commit_sha,
+ name,
+ ]
+ }
+
+
+class Dataset(TimeseriesBaseModel):
+ __tablename__ = "timeseries_dataset"
+
+ id_ = Column("id", types.BigInteger, primary_key=True)
+ name = Column(types.String(256), nullable=False)
+ repository_id = Column(types.BigInteger, nullable=False)
+ backfilled = Column(types.Boolean, nullable=False, default=False)
+
+ __table_args__ = (
+ Index(
+ "timeseries_dataset_name_repo_unique",
+ name,
+ repository_id,
+ unique=True,
+ ),
+ )
diff --git a/apps/worker/database/tests/__init__.py b/apps/worker/database/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/database/tests/factories/__init__.py b/apps/worker/database/tests/factories/__init__.py
new file mode 100644
index 0000000000..2cd2c564a1
--- /dev/null
+++ b/apps/worker/database/tests/factories/__init__.py
@@ -0,0 +1 @@
+from database.tests.factories.core import *
diff --git a/apps/worker/database/tests/factories/core.py b/apps/worker/database/tests/factories/core.py
new file mode 100644
index 0000000000..92ac521bd1
--- /dev/null
+++ b/apps/worker/database/tests/factories/core.py
@@ -0,0 +1,331 @@
+from datetime import datetime, timezone
+from hashlib import sha1
+from uuid import uuid4
+
+import factory
+from factory import Factory
+from shared.django_apps.codecov_auth.models import Plan, Tier
+from shared.plan.constants import DEFAULT_FREE_PLAN, TierName
+
+from database import enums, models
+from services.encryption import encryptor
+
+
+def encrypt_oauth_token(val):
+ if val is None:
+ return None
+ return encryptor.encode(val)
+
+
+class UserFactory(Factory):
+ class Meta:
+ model = models.User
+
+ id_ = factory.Sequence(lambda n: n)
+
+ name = factory.Faker("name")
+ email = factory.Faker("email")
+ is_staff = False
+ is_superuser = False
+ external_id = factory.LazyFunction(lambda: uuid4())
+
+
+class OwnerFactory(Factory):
+ class Meta:
+ model = models.Owner
+ exclude = ("unencrypted_oauth_token",)
+
+ name = factory.Faker("name")
+ email = factory.Faker("email")
+ username = factory.Faker("user_name")
+ plan_activated_users = []
+ service_id = factory.Sequence(lambda n: "user%d" % n)
+ admins = []
+ permission = []
+ organizations = []
+ service = factory.Iterator(["gitlab", "github", "bitbucket"])
+ free = 0
+ unencrypted_oauth_token = factory.LazyFunction(lambda: uuid4().hex)
+ trial_start_date = datetime.now()
+ trial_end_date = datetime.now()
+ trial_status = enums.TrialStatus.NOT_STARTED.value
+ trial_fired_by = None
+ upload_token_required_for_public_repos = False
+ plan = DEFAULT_FREE_PLAN
+
+ oauth_token = factory.LazyAttribute(
+ lambda o: encrypt_oauth_token(o.unencrypted_oauth_token)
+ )
+
+ @classmethod
+ def create_from_test_request(cls, request, *args, **kwargs):
+ if "username" not in kwargs:
+ kwargs["username"] = request.node.name[-100:]
+ return cls(*args, **kwargs)
+
+
+class RepositoryFactory(Factory):
+ class Meta:
+ model = models.Repository
+
+ private = True
+ name = factory.Faker("slug")
+ using_integration = False
+ service_id = factory.Sequence(lambda n: "id_%d" % n)
+
+ owner = factory.SubFactory(OwnerFactory)
+ bot = None
+ updatestamp = factory.LazyAttribute(lambda o: datetime.now(tz=timezone.utc))
+ languages = []
+ languages_last_updated = factory.LazyAttribute(
+ lambda o: datetime.now(tz=timezone.utc)
+ )
+ bundle_analysis_enabled = False
+ test_analytics_enabled = True
+
+
+class BranchFactory(Factory):
+ class Meta:
+ model = models.Branch
+
+ branch = factory.Faker("slug")
+ head = factory.LazyAttribute(lambda o: sha1(o.branch.encode("utf-8")).hexdigest())
+ authors = []
+
+ repository = factory.SubFactory(RepositoryFactory)
+
+
+class PullFactory(Factory):
+ class Meta:
+ model = models.Pull
+
+ pullid = factory.Sequence(lambda n: 10 + (7 * n) % 90)
+ state = "open"
+
+ repository = factory.SubFactory(RepositoryFactory)
+ author = factory.SubFactory(OwnerFactory)
+
+
+class CommitFactory(Factory):
+ class Meta:
+ model = models.Commit
+
+ message = factory.Faker("sentence")
+
+ id_ = factory.Sequence(lambda n: n)
+ commitid = factory.LazyAttribute(
+ lambda o: sha1(
+ (o.message if o.message is not None else "nomessage").encode("utf-8")
+ ).hexdigest()
+ )
+ ci_passed = True
+ pullid = None
+ timestamp = datetime(2019, 2, 1, 17, 59, 47, tzinfo=timezone.utc)
+ author = factory.SubFactory(OwnerFactory)
+ repository = factory.SubFactory(RepositoryFactory)
+ totals = factory.LazyFunction(
+ lambda: {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "85.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ }
+ )
+ _report_json_storage_path = None
+ _report_json = factory.LazyFunction(
+ lambda: {
+ "files": {
+ "awesome/__init__.py": [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ],
+ "tests/__init__.py": [
+ 0,
+ [0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 3, 2, 1, 0, "66.66667", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "tests/test_sample.py": [
+ 1,
+ [0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 7, 7, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ },
+ "sessions": {
+ "0": {
+ "N": None,
+ "a": "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ "c": None,
+ "d": 1547084427,
+ "e": None,
+ "f": ["unit"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "t": [3, 20, 17, 3, 0, "85.00000", 0, 0, 0, 0, 0, 0, 0],
+ "": None,
+ }
+ },
+ }
+ )
+ parent_commit_id = factory.LazyAttribute(
+ lambda o: sha1(
+ (o.message if o.message is not None else "nomessage" + "parent").encode(
+ "utf-8"
+ )
+ ).hexdigest()
+ )
+ state = "complete"
+
+
+class ReportFactory(Factory):
+ class Meta:
+ model = models.CommitReport
+
+ commit = factory.SubFactory(CommitFactory)
+
+
+class ReportLevelTotalsFactory(Factory):
+ class Meta:
+ model = models.ReportLevelTotals
+
+ report = factory.SubFactory(ReportFactory)
+ branches = 0
+ coverage = 0.00
+ hits = 0
+ lines = 0
+ methods = 0
+ misses = 0
+ partials = 0
+ files = 0
+
+
+class ReportResultsFactory(Factory):
+ class Meta:
+ model = models.ReportResults
+
+ report = factory.SubFactory(ReportFactory)
+ state = "success"
+ result = {"state": "success", "message": "somemessage"}
+
+
+class UploadFactory(Factory):
+ class Meta:
+ model = models.Upload
+
+ report = factory.SubFactory(ReportFactory)
+ state = "complete"
+ upload_extras = {}
+ upload_type = "uploaded"
+ storage_path = "storage/path.txt"
+ created_at = datetime.now()
+
+
+class UploadLevelTotalsFactory(Factory):
+ class Meta:
+ model = models.UploadLevelTotals
+
+ upload = factory.SubFactory(UploadFactory)
+ branches = 0
+ coverage = 0.00
+ hits = 0
+ lines = 0
+ methods = 0
+ misses = 0
+ partials = 0
+ files = 0
+
+
+class RepositoryFlagFactory(Factory):
+ class Meta:
+ model = models.RepositoryFlag
+
+ repository = factory.SubFactory(RepositoryFactory)
+ flag_name = "test_flag"
+
+
+class CommitNotificationFactory(Factory):
+ class Meta:
+ model = models.CommitNotification
+
+ notification_type = enums.Notification.comment
+ decoration_type = enums.Decoration.standard
+ state = enums.NotificationState.pending
+
+ commit = factory.SubFactory(CommitFactory)
+
+
+class CompareCommitFactory(Factory):
+ class Meta:
+ model = models.CompareCommit
+
+ state = enums.CompareCommitState.pending.value
+ base_commit = factory.SubFactory(CommitFactory)
+ compare_commit = factory.SubFactory(CommitFactory)
+
+
+class OrgLevelTokenFactory(Factory):
+ class Meta:
+ model = models.OrganizationLevelToken
+
+ token = factory.LazyFunction(lambda: uuid4().hex)
+ token_type = "upload"
+ owner = factory.SubFactory(OwnerFactory)
+
+
+class ConstantsFactory(Factory):
+ class Meta:
+ model = models.Constants
+
+ key = ""
+ value = ""
+
+
+class TierFactory(Factory):
+ class Meta:
+ model = Tier
+
+ tier_name = TierName.BASIC.value
+ bundle_analysis = False
+ test_analytics = False
+ flaky_test_detection = False
+ project_coverage = False
+ private_repo_support = False
+
+
+class PlanFactory(Factory):
+ class Meta:
+ model = Plan
+
+ tier = factory.SubFactory(TierFactory)
+ base_unit_price = 0
+ benefits = factory.LazyFunction(lambda: ["Benefit 1", "Benefit 2", "Benefit 3"])
+ billing_rate = None
+ is_active = True
+ marketing_name = factory.Faker("catch_phrase")
+ max_seats = 1
+ monthly_uploads_limit = None
+ name = DEFAULT_FREE_PLAN
+ paid_plan = False
+ stripe_id = None
+
+
+class UploadErrorFactory(Factory):
+ class Meta:
+ model = models.UploadError
+
+ report_upload = factory.SubFactory(UploadFactory)
+ error_code = "error"
+ error_params = {"error_message": "error message"}
diff --git a/apps/worker/database/tests/factories/labelanalysis.py b/apps/worker/database/tests/factories/labelanalysis.py
new file mode 100644
index 0000000000..08e4ee031c
--- /dev/null
+++ b/apps/worker/database/tests/factories/labelanalysis.py
@@ -0,0 +1,13 @@
+import factory
+
+from database.models import LabelAnalysisRequest
+from database.tests.factories.core import CommitFactory
+
+
+class LabelAnalysisRequestFactory(factory.Factory):
+ class Meta:
+ model = LabelAnalysisRequest
+
+ base_commit = factory.SubFactory(CommitFactory)
+ head_commit = factory.SubFactory(CommitFactory)
+ state_id = 1
diff --git a/apps/worker/database/tests/factories/reports.py b/apps/worker/database/tests/factories/reports.py
new file mode 100644
index 0000000000..b5859dd0f6
--- /dev/null
+++ b/apps/worker/database/tests/factories/reports.py
@@ -0,0 +1,69 @@
+import datetime as dt
+
+import factory
+
+from database.models.reports import (
+ CompareFlag,
+ Flake,
+ RepositoryFlag,
+ Test,
+ TestResultReportTotals,
+)
+from database.tests.factories.core import (
+ CompareCommitFactory,
+ ReportFactory,
+ RepositoryFactory,
+)
+
+
+class RepositoryFlagFactory(factory.Factory):
+ repository = factory.SubFactory(RepositoryFactory)
+ flag_name = factory.Sequence(lambda n: f"flag{n}")
+
+ class Meta:
+ model = RepositoryFlag
+
+
+class CompareFlagFactory(factory.Factory):
+ class Meta:
+ model = CompareFlag
+
+ commit_comparison = factory.SubFactory(CompareCommitFactory)
+ repositoryflag = factory.SubFactory(RepositoryFlagFactory)
+
+
+class TestFactory(factory.Factory):
+ class Meta:
+ model = Test
+
+ name = factory.Sequence(lambda n: f"test_{n}")
+ testsuite = "testsuite"
+ flags_hash = "flags_hash"
+ id_ = factory.Sequence(lambda n: f"id_{n}")
+ repository = factory.SubFactory(RepositoryFactory)
+
+
+class FlakeFactory(factory.Factory):
+ class Meta:
+ model = Flake
+
+ test = factory.SubFactory(TestFactory)
+ repository = factory.SelfAttribute("test.repository")
+ reduced_error = None
+
+ count = 0
+ fail_count = 0
+ recent_passes_count = 0
+
+ start_date = dt.datetime.now()
+ end_date = None
+
+
+class TestResultReportTotalsFactory(factory.Factory):
+ class Meta:
+ model = TestResultReportTotals
+
+ report = factory.SubFactory(ReportFactory)
+ passed = 0
+ skipped = 0
+ failed = 0
diff --git a/apps/worker/database/tests/factories/staticanalysis.py b/apps/worker/database/tests/factories/staticanalysis.py
new file mode 100644
index 0000000000..4b74fd8be4
--- /dev/null
+++ b/apps/worker/database/tests/factories/staticanalysis.py
@@ -0,0 +1,37 @@
+from uuid import uuid4
+
+import factory
+from shared.labelanalysis import LabelAnalysisRequestState
+
+from database.models.staticanalysis import (
+ StaticAnalysisSingleFileSnapshot,
+ StaticAnalysisSuite,
+ StaticAnalysisSuiteFilepath,
+)
+from database.tests.factories.core import CommitFactory, RepositoryFactory
+
+
+class StaticAnalysisSuiteFactory(factory.Factory):
+ class Meta:
+ model = StaticAnalysisSuite
+
+ commit = factory.SubFactory(CommitFactory)
+
+
+class StaticAnalysisSingleFileSnapshotFactory(factory.Factory):
+ class Meta:
+ model = StaticAnalysisSingleFileSnapshot
+
+ repository = factory.SubFactory(RepositoryFactory)
+ file_hash = factory.LazyFunction(lambda: uuid4().hex)
+ content_location = factory.Faker("file_path", depth=3)
+ state_id = LabelAnalysisRequestState.CREATED.db_id
+
+
+class StaticAnalysisSuiteFilepathFactory(factory.Factory):
+ class Meta:
+ model = StaticAnalysisSuiteFilepath
+
+ filepath = factory.Faker("file_name")
+ file_snapshot = factory.SubFactory(StaticAnalysisSingleFileSnapshotFactory)
+ analysis_suite = factory.SubFactory(StaticAnalysisSuiteFactory)
diff --git a/apps/worker/database/tests/factories/timeseries.py b/apps/worker/database/tests/factories/timeseries.py
new file mode 100644
index 0000000000..12b0df3c13
--- /dev/null
+++ b/apps/worker/database/tests/factories/timeseries.py
@@ -0,0 +1,27 @@
+import random
+from datetime import datetime
+
+import factory
+
+from database.models.timeseries import Dataset, Measurement
+
+
+class MeasurementFactory(factory.Factory):
+ owner_id = 1
+ repo_id = 1
+ name = "testing"
+ branch = "master"
+ value = factory.LazyAttribute(lambda: random.random() * 1000)
+ timestamp = factory.LazyAttribute(lambda: datetime.now())
+
+ class Meta:
+ model = Measurement
+
+
+class DatasetFactory(factory.Factory):
+ repository_id = 1
+ name = "testing"
+ backfilled = False
+
+ class Meta:
+ model = Dataset
diff --git a/apps/worker/database/tests/unit/test_engine.py b/apps/worker/database/tests/unit/test_engine.py
new file mode 100644
index 0000000000..50760a1b40
--- /dev/null
+++ b/apps/worker/database/tests/unit/test_engine.py
@@ -0,0 +1,58 @@
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy_utils import get_mapper
+
+from database.engine import SessionFactory
+from database.models import Commit
+from database.models.timeseries import Measurement
+
+
+class TestDatabaseEngine:
+ def test_session_get_bind_timeseries_disabled(self, sqlalchemy_connect_url, mocker):
+ mocker.patch("database.engine.is_timeseries_enabled", return_value=False)
+
+ session_factory = SessionFactory(
+ database_url=sqlalchemy_connect_url,
+ timeseries_database_url=sqlalchemy_connect_url,
+ )
+ session = session_factory.create_session()
+ assert session_factory.main_engine is not None
+ assert session_factory.timeseries_engine is None
+
+ engine = session.get_bind(mapper=get_mapper(Commit))
+ assert engine == session_factory.main_engine
+
+ clause = insert(Commit.__table__)
+ engine = session.get_bind(clause=clause)
+ assert engine == session_factory.main_engine
+
+ engine = session.get_bind(mapper=get_mapper(Measurement))
+ assert engine == session_factory.main_engine
+
+ clause = insert(Measurement.__table__)
+ engine = session.get_bind(clause=clause)
+ assert engine == session_factory.main_engine
+
+ def test_session_get_bind_timeseries_enabled(self, sqlalchemy_connect_url, mocker):
+ mocker.patch("database.engine.is_timeseries_enabled", return_value=True)
+
+ session_factory = SessionFactory(
+ database_url=sqlalchemy_connect_url,
+ timeseries_database_url=sqlalchemy_connect_url,
+ )
+ session = session_factory.create_session()
+ assert session_factory.main_engine is not None
+ assert session_factory.timeseries_engine is not None
+
+ engine = session.get_bind(mapper=get_mapper(Commit))
+ assert engine == session_factory.main_engine
+
+ clause = insert(Commit.__table__)
+ engine = session.get_bind(clause=clause)
+ assert engine == session_factory.main_engine
+
+ engine = session.get_bind(mapper=get_mapper(Measurement))
+ assert engine == session_factory.timeseries_engine
+
+ clause = insert(Measurement.__table__)
+ engine = session.get_bind(clause=clause)
+ assert engine == session_factory.timeseries_engine
diff --git a/apps/worker/database/tests/unit/test_events.py b/apps/worker/database/tests/unit/test_events.py
new file mode 100644
index 0000000000..36994afd10
--- /dev/null
+++ b/apps/worker/database/tests/unit/test_events.py
@@ -0,0 +1,89 @@
+import os
+
+import database.events # noqa: F401
+from database.tests.factories import OwnerFactory, RepositoryFactory
+
+
+def test_shelter_repo_sync(dbsession, mock_configuration, mocker):
+ # this prevents the pubsub SDK from trying to load credentials
+ os.environ["PUBSUB_EMULATOR_HOST"] = "localhost"
+ publish = mocker.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+
+ mock_configuration.set_params(
+ {
+ "setup": {
+ "shelter": {
+ "pubsub_project_id": "test-project-id",
+ "sync_repo_topic_id": "test-topic-id",
+ "enabled": True,
+ }
+ }
+ }
+ )
+
+ # this triggers the publish via SQLAlchemy events (after_insert)
+ repo = RepositoryFactory(
+ repoid=91728376, name="test-123", owner=OwnerFactory(ownerid=123), private=False
+ )
+ dbsession.add(repo)
+ dbsession.commit()
+
+ publish.assert_called_once_with(
+ "projects/test-project-id/topics/test-topic-id",
+ b'{"type": "repo", "sync": "one", "id": 91728376}',
+ )
+ publish_calls = publish.call_args_list
+
+ # Synchronize object flush for history.deleted to be perceived by sqlalchemy
+ dbsession.refresh(repo)
+
+ # this triggers the publish via SQLAlchemy events (after_update)
+ repo.name = "test-456"
+ dbsession.commit()
+ dbsession.refresh(repo)
+ assert len(publish_calls) == 2
+
+ # Does not trigger another publish with untracked field
+ repo.message = "foo"
+ dbsession.commit()
+ dbsession.refresh(repo)
+ assert len(publish_calls) == 2
+
+ # Triggers call when owner is changed
+ repo.owner = OwnerFactory(ownerid=456)
+ dbsession.commit()
+ dbsession.refresh(repo)
+ assert len(publish_calls) == 3
+
+ # Triggers call when private is changed
+ repo.private = True
+ dbsession.commit()
+ dbsession.refresh(repo)
+ assert len(publish_calls) == 4
+
+
+def test_repo_sync_when_shelter_disabled(dbsession, mock_configuration, mocker):
+ # this prevents the pubsub SDK from trying to load credentials
+ os.environ["PUBSUB_EMULATOR_HOST"] = "localhost"
+
+ mock_configuration.set_params(
+ {
+ "setup": {
+ "shelter": {
+ "pubsub_project_id": "test-project-id",
+ "sync_repo_topic_id": "test-topic-id",
+ "enabled": False,
+ }
+ }
+ }
+ )
+
+ publish = mocker.patch("google.cloud.pubsub_v1.PublisherClient.publish")
+
+ # Create new repo with shelter disabled
+ repo = RepositoryFactory(repoid=91728377, name="test-789")
+ dbsession.add(repo)
+ dbsession.commit()
+
+ # Verify no publish was called when shelter is disabled
+ publish.assert_not_called()
diff --git a/apps/worker/database/tests/unit/test_model_utils.py b/apps/worker/database/tests/unit/test_model_utils.py
new file mode 100644
index 0000000000..9105ef10bf
--- /dev/null
+++ b/apps/worker/database/tests/unit/test_model_utils.py
@@ -0,0 +1,139 @@
+import json
+from unittest.mock import PropertyMock
+
+from shared.storage.exceptions import FileNotInStorageError
+
+from database.models.core import Commit
+from database.tests.factories.core import CommitFactory
+from database.utils import ArchiveField, ArchiveFieldInterface
+
+
+class TestArchiveField(object):
+ class ClassWithArchiveField(object):
+ commit: Commit
+ id = 1
+ external_id = "external_id"
+ __tablename__ = "test_table"
+
+ _archive_field = "db_field"
+ _archive_field_storage_path = "archive_field_path"
+
+ def should_write_to_storage(self):
+ return self.should_write_to_gcs
+
+ def get_repository(self):
+ return self.commit.repository
+
+ def get_commitid(self):
+ return self.commit.commitid
+
+ def __init__(
+ self, commit, db_value, archive_value, should_write_to_gcs=False
+ ) -> None:
+ self.commit = commit
+ self._archive_field = db_value
+ self._archive_field_storage_path = archive_value
+ self.should_write_to_gcs = should_write_to_gcs
+
+ archive_field = ArchiveField(
+ should_write_to_storage_fn=should_write_to_storage,
+ read_timeout=0.1,
+ )
+
+ class ClassWithArchiveFieldMissingMethods:
+ commit: Commit
+ id = 1
+ external_id = "external_id"
+
+ def test_subclass_validation(self, mocker):
+ assert issubclass(
+ self.ClassWithArchiveField(
+ mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()
+ ),
+ ArchiveFieldInterface,
+ )
+ assert not issubclass(
+ self.ClassWithArchiveFieldMissingMethods, ArchiveFieldInterface
+ )
+
+ def test_archive_getter_db_field_set(self, sqlalchemy_db):
+ commit = CommitFactory()
+ test_class = self.ClassWithArchiveField(commit, "db_value", "gcs_path")
+ assert test_class._archive_field == "db_value"
+ assert test_class._archive_field_storage_path == "gcs_path"
+ assert test_class.archive_field == "db_value"
+
+ def test_archive_getter_archive_field_set(self, sqlalchemy_db, mocker):
+ some_json = {"some": "data"}
+ mock_read_file = mocker.MagicMock(return_value=json.dumps(some_json))
+ mock_archive_service = mocker.patch("database.utils.ArchiveService")
+ mock_archive_service.return_value.read_file = mock_read_file
+ commit = CommitFactory()
+ test_class = self.ClassWithArchiveField(commit, None, "gcs_path")
+
+ assert test_class._archive_field is None
+ assert test_class._archive_field_storage_path == "gcs_path"
+ assert test_class.archive_field == some_json
+ mock_read_file.assert_called_with("gcs_path")
+ mock_archive_service.assert_called_with(repository=commit.repository)
+ assert mock_read_file.call_count == 1
+ # Test that caching also works
+ assert test_class.archive_field == some_json
+ assert mock_read_file.call_count == 1
+
+ def test_archive_getter_file_not_in_storage(self, sqlalchemy_db, mocker):
+ mocker.patch(
+ "database.utils.ArchiveField.read_timeout",
+ new_callable=PropertyMock,
+ return_value=0.1,
+ )
+ mock_read_file = mocker.MagicMock(side_effect=FileNotInStorageError())
+ mock_archive_service = mocker.patch("database.utils.ArchiveService")
+ mock_archive_service.return_value.read_file = mock_read_file
+ commit = CommitFactory()
+ test_class = self.ClassWithArchiveField(commit, None, "gcs_path")
+
+ assert test_class._archive_field is None
+ assert test_class._archive_field_storage_path == "gcs_path"
+ assert test_class.archive_field is None
+ mock_read_file.assert_called_with("gcs_path")
+ mock_archive_service.assert_called_with(repository=commit.repository)
+
+ def test_archive_setter_db_field(self, sqlalchemy_db, mocker):
+ commit = CommitFactory()
+ test_class = self.ClassWithArchiveField(commit, "db_value", "gcs_path", False)
+ assert test_class._archive_field == "db_value"
+ assert test_class._archive_field_storage_path == "gcs_path"
+ assert test_class.archive_field == "db_value"
+ mock_archive_service = mocker.patch("database.utils.ArchiveService")
+ test_class.archive_field = "batata frita"
+ mock_archive_service.assert_not_called()
+ assert test_class._archive_field == "batata frita"
+ assert test_class.archive_field == "batata frita"
+
+ def test_archive_setter_archive_field(self, sqlalchemy_db, mocker):
+ commit = CommitFactory()
+ test_class = self.ClassWithArchiveField(commit, "db_value", None, True)
+ some_json = {"some": "data"}
+ mock_read_file = mocker.MagicMock(return_value=json.dumps(some_json))
+ mock_write_file = mocker.MagicMock(return_value="path/to/written/object")
+ mock_archive_service = mocker.patch("database.utils.ArchiveService")
+ mock_archive_service.return_value.read_file = mock_read_file
+ mock_archive_service.return_value.write_json_data_to_storage = mock_write_file
+
+ assert test_class._archive_field == "db_value"
+ assert test_class._archive_field_storage_path is None
+ assert test_class.archive_field == "db_value"
+ assert mock_read_file.call_count == 0
+
+ # Pretend there was something in the path.
+ # This should happen, but it will help us test the deletion of old data saved
+ test_class._archive_field_storage_path = "path/to/old/data"
+
+ # Now we write to the property
+ test_class.archive_field = some_json
+ assert test_class._archive_field is None
+ assert test_class._archive_field_storage_path == "path/to/written/object"
+ assert test_class.archive_field == some_json
+ # Cache is updated on write
+ assert mock_read_file.call_count == 0
diff --git a/apps/worker/database/tests/unit/test_models.py b/apps/worker/database/tests/unit/test_models.py
new file mode 100644
index 0000000000..735706d92b
--- /dev/null
+++ b/apps/worker/database/tests/unit/test_models.py
@@ -0,0 +1,492 @@
+import json
+from unittest.mock import PropertyMock
+
+from mock import MagicMock, patch
+from shared.plan.constants import DEFAULT_FREE_PLAN
+from shared.storage.exceptions import FileNotInStorageError
+from sqlalchemy.orm import Session
+
+from database.models import (
+ Account,
+ Branch,
+ Commit,
+ CommitNotification,
+ Owner,
+ Pull,
+ Repository,
+)
+from database.models.core import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ AccountsUsers,
+ GithubAppInstallation,
+)
+from database.tests.factories import (
+ BranchFactory,
+ CommitFactory,
+ CommitNotificationFactory,
+ CompareCommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from database.tests.factories.core import UserFactory
+
+
+class TestReprModels(object):
+ def test_owner_repr(self, dbsession):
+ simple_owner = Owner()
+ assert "Owner>" == repr(simple_owner)
+ factoried_owner = OwnerFactory.create(service="github")
+ assert "Owner>" == repr(factoried_owner)
+ dbsession.add(factoried_owner)
+ dbsession.flush()
+ dbsession.refresh(factoried_owner)
+ assert f"Owner<{factoried_owner.ownerid}@service>" == repr(
+ factoried_owner
+ )
+
+ def test_repo_repr(self, dbsession):
+ simple_repo = Repository()
+ assert "Repo" == repr(simple_repo)
+ factoried_repo = RepositoryFactory.create()
+ assert "Repo" == repr(factoried_repo)
+ dbsession.add(factoried_repo)
+ dbsession.flush()
+ dbsession.refresh(factoried_repo)
+ assert f"Repo<{factoried_repo.repoid}>" == repr(factoried_repo)
+
+ def test_commit_repr(self, dbsession):
+ simple_commit = Commit()
+ assert "Commit>" == repr(simple_commit)
+ factoried_commit = CommitFactory.create(
+ commitid="327993f5d81eda4bac19ea6090fe68c8eb313066"
+ )
+ assert "Commit<327993f5d81eda4bac19ea6090fe68c8eb313066@repo>" == repr(
+ factoried_commit
+ )
+ dbsession.add(factoried_commit)
+ dbsession.flush()
+ dbsession.refresh(factoried_commit)
+ assert (
+ f"Commit<327993f5d81eda4bac19ea6090fe68c8eb313066@repo<{factoried_commit.repoid}>>"
+ == repr(factoried_commit)
+ )
+
+ def test_branch_repr(self, dbsession):
+ simple_branch = Branch()
+ assert "Branch>" == repr(simple_branch)
+ factoried_branch = BranchFactory.create(branch="thisoakbranch")
+ assert "Branch>" == repr(factoried_branch)
+ dbsession.add(factoried_branch)
+ dbsession.flush()
+ dbsession.refresh(factoried_branch)
+ assert f"Branch>" == repr(
+ factoried_branch
+ )
+
+ def test_pull_repr(self, dbsession):
+ simple_pull = Pull()
+ assert "Pull>" == repr(simple_pull)
+ factoried_pull = PullFactory.create()
+ assert f"Pull<{factoried_pull.pullid}@repo>" == repr(factoried_pull)
+ dbsession.add(factoried_pull)
+ dbsession.flush()
+ dbsession.refresh(factoried_pull)
+ assert f"Pull<{factoried_pull.pullid}@repo<{factoried_pull.repoid}>>" == repr(
+ factoried_pull
+ )
+
+ def test_notification_repr(self, dbsession):
+ simple_notification = CommitNotification()
+ assert "Notification>" == repr(simple_notification)
+ factoried_notification = CommitNotificationFactory.create()
+ assert (
+ f"Notification<{factoried_notification.notification_type}@commit<{factoried_notification.commit_id}>>"
+ == repr(factoried_notification)
+ )
+ dbsession.add(factoried_notification)
+ dbsession.flush()
+ dbsession.refresh(factoried_notification)
+ assert (
+ f"Notification<{factoried_notification.notification_type}@commit<{factoried_notification.commit_id}>>"
+ == repr(factoried_notification)
+ )
+
+ def test_commit_compare_repr(self, dbsession):
+ compare_commit = CompareCommitFactory()
+ assert "CompareCommit" == repr(compare_commit)
+
+ def test_commit_notified(self, dbsession):
+ commit = CommitFactory.create()
+ dbsession.add(commit)
+ dbsession.flush()
+ assert commit.notified is None
+ commit.notified = True
+ dbsession.flush()
+ dbsession.refresh(commit)
+ assert commit.notified is True
+
+
+class TestPullModel(object):
+ def test_updatestamp_update(self, dbsession):
+ factoried_pull = PullFactory.create(updatestamp=None)
+ assert factoried_pull.updatestamp is None
+ dbsession.add(factoried_pull)
+ dbsession.flush()
+ assert factoried_pull.updatestamp is not None
+ val = factoried_pull.updatestamp
+ factoried_pull.title = "Super Mario Bros"
+ dbsession.flush()
+ assert factoried_pull.updatestamp is not None
+ assert factoried_pull.updatestamp > val
+
+
+class TestOwnerModel(object):
+ def test_upload_token_required_for_public_repos(self, dbsession):
+ # Create an owner with upload_token_required_for_public_repos specified
+ tokens_required_owner = Owner(
+ name="Token Owner",
+ email="token_owner@example.com",
+ username="tokenuser",
+ upload_token_required_for_public_repos=True,
+ service="github",
+ service_id="abc",
+ )
+ dbsession.add(tokens_required_owner)
+ dbsession.commit()
+
+ # Refresh from the database to verify persistence
+ dbsession.refresh(tokens_required_owner)
+ assert tokens_required_owner.upload_token_required_for_public_repos is True
+
+ # Update other field, upload_token_required_for_public_repos is unchanged
+ assert tokens_required_owner.onboarding_completed is False
+ tokens_required_owner.onboarding_completed = True
+ dbsession.commit()
+ dbsession.refresh(tokens_required_owner)
+ assert tokens_required_owner.onboarding_completed is True
+ assert tokens_required_owner.upload_token_required_for_public_repos is True
+
+ # Create an owner without upload_token_required_for_public_repos specified
+ tokens_not_required_owner = Owner(
+ name="Tokenless Owner",
+ email="tokenless_owner@example.com",
+ username="tokenlessuser",
+ service="github",
+ service_id="defg",
+ )
+ dbsession.add(tokens_not_required_owner)
+ dbsession.commit()
+ dbsession.refresh(tokens_not_required_owner)
+ assert tokens_not_required_owner.upload_token_required_for_public_repos is False
+
+ # Update other field, upload_token_required_for_public_repos is unchanged
+ assert tokens_not_required_owner.onboarding_completed is False
+ tokens_not_required_owner.onboarding_completed = True
+ dbsession.commit()
+ dbsession.refresh(tokens_not_required_owner)
+ assert tokens_not_required_owner.onboarding_completed is True
+ assert tokens_not_required_owner.upload_token_required_for_public_repos is False
+
+ def test_root_organization(self, dbsession):
+ gitlab_root_group = OwnerFactory.create(
+ username="root_group",
+ service="gitlab",
+ plan="users-pr-inappm",
+ )
+ dbsession.add(gitlab_root_group)
+ gitlab_middle_group = OwnerFactory.create(
+ username="mid_group",
+ service="gitlab",
+ parent_service_id=gitlab_root_group.service_id,
+ root_parent_service_id=None,
+ )
+ dbsession.add(gitlab_middle_group)
+ gitlab_subgroup = OwnerFactory.create(
+ username="subgroup",
+ service="gitlab",
+ parent_service_id=gitlab_middle_group.service_id,
+ root_parent_service_id=None,
+ )
+ dbsession.add(gitlab_subgroup)
+ github_org = OwnerFactory.create(
+ username="gh",
+ service="github",
+ )
+ dbsession.add(github_org)
+ dbsession.flush()
+
+ assert gitlab_root_group.root_organization is None
+ assert gitlab_root_group.root_parent_service_id is None
+
+ assert gitlab_middle_group.root_organization == gitlab_root_group
+ assert (
+ gitlab_middle_group.root_parent_service_id == gitlab_root_group.service_id
+ )
+
+ assert gitlab_subgroup.root_organization == gitlab_root_group
+ assert gitlab_subgroup.root_parent_service_id == gitlab_root_group.service_id
+
+ assert github_org.root_organization is None
+ assert github_org.root_parent_service_id is None
+
+
+class TestAccountModels(object):
+ def test_create_account(self, dbsession):
+ account = Account(
+ name="test_name",
+ )
+ dbsession.add(account)
+ dbsession.commit()
+ dbsession.refresh(account)
+ assert account.name == "test_name"
+ assert account.is_active is True
+ assert account.plan == DEFAULT_FREE_PLAN
+ assert account.plan_seat_count == 1
+ assert account.free_seat_count == 0
+ assert account.plan_auto_activate is True
+ assert account.is_delinquent is False
+ assert account.users == []
+ assert account.organizations == []
+
+ def test_account_fks(self, dbsession):
+ user = UserFactory()
+ owner_person = OwnerFactory()
+ owner_org = OwnerFactory()
+ account = Account(
+ name="test_name",
+ )
+ dbsession.add_all([user, owner_person, owner_org, account])
+ dbsession.commit()
+
+ # this is the evaluation from shared that was breaking
+ assert owner_org.account is None
+ has_account = owner_org.account is not None
+ assert has_account is False
+
+ owner_person.user = user
+ account.users.append(user)
+ account.organizations.append(owner_org)
+ dbsession.add_all([owner_person, account])
+ dbsession.commit()
+
+ dbsession.refresh(user)
+ dbsession.refresh(owner_person)
+ dbsession.refresh(owner_org)
+ dbsession.refresh(account)
+
+ assert user.accounts == [account]
+ assert owner_person.account is None
+ assert owner_org.account == account
+ assert account.users == [user]
+ assert account.organizations == [owner_org]
+ # this is the evaluation from shared that was breaking
+ has_account = owner_org.account is not None
+ assert has_account is True
+
+ through_table_obj = dbsession.query(AccountsUsers).first()
+ assert through_table_obj.user_id == user.id
+ assert through_table_obj.account_id == account.id
+
+
+class TestCommitModel(object):
+ sample_report = {
+ "files": {
+ "different/test_file.py": [
+ 2,
+ [0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0],
+ [[0, 10, 8, 2, 0, "80.00000", 0, 0, 0, 0, 0, 0, 0]],
+ [0, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ ],
+ },
+ "sessions": {
+ "0": {
+ "N": None,
+ "a": "v4/raw/2019-01-10/4434BC2A2EC4FCA57F77B473D83F928C/abf6d4df662c47e32460020ab14abf9303581429/9ccc55a1-8b41-4bb1-a946-ee7a33a7fb56.txt",
+ "c": None,
+ "d": 1547084427,
+ "e": None,
+ "f": ["unittests"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "t": [3, 20, 17, 3, 0, "85.00000", 0, 0, 0, 0, 0, 0, 0],
+ "": None,
+ }
+ },
+ }
+
+ @patch("database.utils.ArchiveService")
+ def test_get_report_from_db(self, mock_archive, dbsession):
+ commit = CommitFactory()
+ mock_read_file = MagicMock()
+ mock_archive.return_value.read_file = mock_read_file
+ commit._report_json = self.sample_report
+ dbsession.add(commit)
+ dbsession.flush()
+
+ fetched = dbsession.query(Commit).get(commit.id_)
+ assert fetched.report_json == self.sample_report
+ mock_archive.assert_not_called()
+ mock_read_file.assert_not_called()
+
+ @patch("database.utils.ArchiveService")
+ def test_get_report_from_storage(self, mock_archive, dbsession):
+ commit = CommitFactory()
+ storage_path = "https://storage/path/report.json"
+ mock_read_file = MagicMock(return_value=json.dumps(self.sample_report))
+ mock_archive.return_value.read_file = mock_read_file
+ commit._report_json = None
+ commit._report_json_storage_path = storage_path
+ dbsession.add(commit)
+ dbsession.flush()
+
+ fetched = dbsession.query(Commit).get(commit.id_)
+ assert fetched.report_json == self.sample_report
+ mock_archive.assert_called()
+ mock_read_file.assert_called_with(storage_path)
+ # Calls it again to test caching
+ assert fetched.report_json == self.sample_report
+ assert mock_archive.call_count == 1
+ assert mock_read_file.call_count == 1
+ # This one to help us understand caching across different instances
+ # different instances if they are the same
+ assert commit.report_json == self.sample_report
+ assert mock_archive.call_count == 1
+ assert mock_read_file.call_count == 1
+ # Let's see for objects with different IDs
+ diff_commit = CommitFactory()
+ storage_path = "https://storage/path/files_array.json"
+ diff_commit._report_json = None
+ diff_commit._report_json_storage_path = storage_path
+ dbsession.add(diff_commit)
+ dbsession.flush()
+ assert diff_commit.report_json == self.sample_report
+ assert mock_archive.call_count == 2
+ assert mock_read_file.call_count == 2
+
+ @patch("database.utils.ArchiveService")
+ def test_get_report_from_storage_file_not_found(
+ self, mock_archive, dbsession, mocker
+ ):
+ mocker.patch(
+ "database.utils.ArchiveField.read_timeout",
+ new_callable=PropertyMock,
+ return_value=0.1,
+ )
+ commit = CommitFactory()
+ storage_path = "https://storage/path/files_array.json"
+
+ def side_effect(*args, **kwargs):
+ raise FileNotInStorageError()
+
+ mock_read_file = MagicMock(side_effect=side_effect)
+ mock_archive.return_value.read_file = mock_read_file
+ commit._report_json = None
+ commit._report_json_storage_path = storage_path
+ dbsession.add(commit)
+ dbsession.flush()
+
+ fetched = dbsession.query(Commit).get(commit.id_)
+ assert fetched._report_json_storage_path == storage_path
+ assert fetched.report_json == {}
+ mock_archive.assert_called()
+ mock_read_file.assert_called_with(storage_path)
+
+
+class TestGithubAppInstallationModel(object):
+ def test_covers_all_repos(self, dbsession: Session):
+ owner = OwnerFactory.create()
+ other_owner = OwnerFactory.create()
+ repo1 = RepositoryFactory.create(owner=owner)
+ repo2 = RepositoryFactory.create(owner=owner)
+ repo3 = RepositoryFactory.create(owner=owner)
+ other_repo_different_owner = RepositoryFactory.create(owner=other_owner)
+ installation_obj = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=None,
+ installation_id=100,
+ # name would be set by the API
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ )
+ dbsession.add_all([owner, other_owner, repo1, repo2, repo3, installation_obj])
+ dbsession.flush()
+ assert installation_obj.covers_all_repos() == True
+ assert installation_obj.is_repo_covered_by_integration(repo1) == True
+ assert other_repo_different_owner.ownerid != repo1.ownerid
+ assert (
+ installation_obj.is_repo_covered_by_integration(other_repo_different_owner)
+ == False
+ )
+ assert owner.github_app_installations == [installation_obj]
+ assert installation_obj.repository_queryset(dbsession).count() == 3
+ assert set(installation_obj.repository_queryset(dbsession).all()) == set(
+ [repo1, repo2, repo3]
+ )
+
+ def test_covers_some_repos(self, dbsession: Session):
+ owner = OwnerFactory()
+ repo = RepositoryFactory(owner=owner)
+ same_owner_other_repo = RepositoryFactory(owner=owner)
+ other_repo_different_owner = RepositoryFactory()
+ installation_obj = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=[repo.service_id],
+ installation_id=100,
+ )
+ dbsession.add_all(
+ [
+ owner,
+ repo,
+ same_owner_other_repo,
+ other_repo_different_owner,
+ installation_obj,
+ ]
+ )
+ dbsession.flush()
+ assert installation_obj.covers_all_repos() == False
+ assert installation_obj.is_repo_covered_by_integration(repo) == True
+ assert (
+ installation_obj.is_repo_covered_by_integration(other_repo_different_owner)
+ == False
+ )
+ assert (
+ installation_obj.is_repo_covered_by_integration(same_owner_other_repo)
+ == False
+ )
+ assert owner.github_app_installations == [installation_obj]
+ assert installation_obj.repository_queryset(dbsession).count() == 1
+ assert list(installation_obj.repository_queryset(dbsession).all()) == [repo]
+
+ def test_is_configured(self, dbsession: Session):
+ owner = OwnerFactory()
+ installation_obj_default = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=None,
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=100,
+ )
+ installation_obj_configured = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=None,
+ name="my_installation",
+ installation_id=100,
+ app_id=10,
+ pem_path="some_path",
+ )
+ installation_obj_not_configured = GithubAppInstallation(
+ owner=owner,
+ repository_service_ids=None,
+ installation_id=100,
+ name="my_installation",
+ )
+ dbsession.add_all(
+ [
+ installation_obj_default,
+ installation_obj_configured,
+ installation_obj_not_configured,
+ ]
+ )
+ assert installation_obj_default.is_configured() == True
+ assert installation_obj_configured.is_configured() == True
+ assert installation_obj_not_configured.is_configured() == False
diff --git a/apps/worker/database/utils.py b/apps/worker/database/utils.py
new file mode 100644
index 0000000000..34bd47dec5
--- /dev/null
+++ b/apps/worker/database/utils.py
@@ -0,0 +1,183 @@
+import logging
+import time
+from typing import Any, Callable, Optional
+
+import orjson
+from shared.storage.exceptions import FileNotInStorageError
+from shared.utils.ReportEncoder import ReportEncoder
+
+from services.archive import ArchiveService
+
+log = logging.getLogger(__name__)
+
+
+class ArchiveFieldInterfaceMeta(type):
+ def __subclasscheck__(cls, subclass):
+ return (
+ hasattr(subclass, "get_repository")
+ and callable(subclass.get_repository)
+ and hasattr(subclass, "get_commitid")
+ and callable(subclass.get_commitid)
+ and hasattr(subclass, "external_id")
+ )
+
+
+class ArchiveFieldInterface(metaclass=ArchiveFieldInterfaceMeta):
+ """Any class that uses ArchiveField must implement this interface"""
+
+ external_id: str
+
+ def get_repository(self):
+ """Returns the repository object associated with self"""
+ raise NotImplementedError()
+
+ def get_commitid(self) -> Optional[str]:
+ """Returns the commitid associated with self.
+ If no commitid is associated return None.
+ """
+ raise NotImplementedError()
+
+
+class ArchiveField:
+ """This is a helper class that transparently handles models' fields that are saved in storage.
+ Classes that use the ArchiveField MUST implement ArchiveFieldInterface. It ill throw an error otherwise.
+ It uses the Descriptor pattern: https://docs.python.org/3/howto/descriptor.html
+
+ Arguments:
+ should_write_to_storage_fn: Callable function that decides if data should be written to storage.
+ It should take 1 argument: the object instance.
+
+ rehydrate_fn: Callable function to allow you to decode your saved data into internal representations.
+ The default value does nothing.
+ Data retrieved both from DB and storage pass through this function to guarantee consistency.
+ It should take 2 arguments: the object instance and the encoded data.
+
+ default_value: Any value that will be returned if we can't save the data for whatever reason
+
+ Example:
+ archive_field = ArchiveField(
+ should_write_to_storage_fn=should_write_data,
+ rehydrate_fn=rehidrate_data,
+ default_value='default'
+ )
+ For a full example check utils/tests/unit/test_model_utils.py
+ """
+
+ def __init__(
+ self,
+ should_write_to_storage_fn: Callable[[object], bool],
+ rehydrate_fn: Callable[[object, object], Any] = lambda self, x: x,
+ json_encoder=ReportEncoder,
+ default_value_class=lambda: None,
+ read_timeout=5,
+ ):
+ self.default_value_class = default_value_class
+ self.rehydrate_fn = rehydrate_fn
+ self.should_write_to_storage_fn = should_write_to_storage_fn
+ self.json_encoder = json_encoder
+ self._read_timeout = read_timeout
+
+ @property
+ def read_timeout(self):
+ return self._read_timeout
+
+ def __set_name__(self, owner, name):
+ # Validate that the owner class has the methods we need
+ assert issubclass(owner, ArchiveFieldInterface), (
+ "Missing some required methods to use AchiveField"
+ )
+ self.public_name = name
+ self.db_field_name = "_" + name
+ self.archive_field_name = "_" + name + "_storage_path"
+ self.cached_value_property_name = f"__{self.public_name}_cached_value"
+
+ def _get_value_from_archive(self, obj):
+ repository = obj.get_repository()
+ archive_service = ArchiveService(repository=repository)
+ archive_field = getattr(obj, self.archive_field_name)
+ if archive_field:
+ start_time = time.time()
+ error = False
+ while time.time() < start_time + self.read_timeout:
+ # we're within the timeout window
+ try:
+ file_str = archive_service.read_file(archive_field)
+ result = self.rehydrate_fn(obj, orjson.loads(file_str))
+ if error:
+ # we previously errored and now it succeeded
+ log.info(
+ "Archive enabled field found in storage after delay",
+ extra=dict(
+ storage_path=archive_field,
+ object_id=obj.id,
+ commit=obj.get_commitid(),
+ delay_seconds=time.time() - start_time,
+ ),
+ )
+ return result
+ except FileNotInStorageError:
+ log.warning(
+ "Archive enabled not found, retrying soon",
+ extra=dict(
+ storage_path=archive_field,
+ object_id=obj.id,
+ commit=obj.get_commitid(),
+ ),
+ )
+ error = True
+ # sleep a little but so we're not hammering the archive service
+ # in a tight loop
+ time.sleep(self.read_timeout / 10)
+
+ log.error(
+ "Archive enabled field not in storage",
+ extra=dict(
+ storage_path=archive_field,
+ object_id=obj.id,
+ commit=obj.get_commitid(),
+ ),
+ )
+ else:
+ log.debug(
+ "Both db_field and archive_field are None",
+ extra=dict(
+ object_id=obj.id,
+ commit=obj.get_commitid(),
+ ),
+ )
+ return self.default_value_class()
+
+ def __get__(self, obj, objtype=None):
+ cached_value = getattr(obj, self.cached_value_property_name, None)
+ if cached_value:
+ return cached_value
+ db_field = getattr(obj, self.db_field_name)
+ if db_field is not None:
+ value = self.rehydrate_fn(obj, db_field)
+ else:
+ value = self._get_value_from_archive(obj)
+ setattr(obj, self.cached_value_property_name, value)
+ return value
+
+ def __set__(self, obj, value):
+ # Set the new value
+ if self.should_write_to_storage_fn(obj):
+ repository = obj.get_repository()
+ archive_service = ArchiveService(repository=repository)
+ old_file_path = getattr(obj, self.archive_field_name)
+ table_name = obj.__tablename__
+ path = archive_service.write_json_data_to_storage(
+ commit_id=obj.get_commitid(),
+ table=table_name,
+ field=self.public_name,
+ external_id=obj.external_id,
+ data=value,
+ encoder=self.json_encoder,
+ )
+ if old_file_path is not None and path != old_file_path:
+ archive_service.delete_file(old_file_path)
+ setattr(obj, self.archive_field_name, path)
+ setattr(obj, self.db_field_name, None)
+ else:
+ setattr(obj, self.db_field_name, value)
+ setattr(obj, self.cached_value_property_name, value)
diff --git a/apps/worker/django_scaffold/__init__.py b/apps/worker/django_scaffold/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/django_scaffold/settings.py b/apps/worker/django_scaffold/settings.py
new file mode 100644
index 0000000000..6f85e6cd07
--- /dev/null
+++ b/apps/worker/django_scaffold/settings.py
@@ -0,0 +1,86 @@
+import os
+from pathlib import Path
+
+from shared.django_apps.db_settings import *
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+ALLOWED_HOSTS = []
+
+IS_DEV = os.getenv("RUN_ENV") == "DEV"
+IS_ENTERPRISE = os.getenv("RUN_ENV") == "ENTERPRISE"
+
+GCS_BUCKET_NAME = get_config("services", "minio", "bucket", default="archive")
+
+# Application definition
+INSTALLED_APPS = [
+ # dependencies
+ "psqlextra",
+ # Needed to install legacy migrations
+ "django.contrib.admin",
+ "django.contrib.contenttypes",
+ "django.contrib.auth",
+ "django.contrib.messages",
+ "django.contrib.sessions",
+ # Shared apps:
+ "shared.django_apps.legacy_migrations",
+ "shared.django_apps.pg_telemetry",
+ "shared.django_apps.rollouts",
+ "shared.django_apps.user_measurements",
+ "shared.django_apps.bundle_analysis",
+ "shared.django_apps.codecov_auth",
+ "shared.django_apps.compare",
+ "shared.django_apps.core",
+ "shared.django_apps.labelanalysis",
+ "shared.django_apps.reports",
+ "shared.django_apps.staticanalysis",
+ "shared.django_apps.ta_timeseries",
+ "shared.django_apps.test_analytics",
+]
+
+TELEMETRY_VANILLA_DB = "default"
+TELEMETRY_TIMESCALE_DB = "timeseries"
+
+SKIP_RISKY_MIGRATION_STEPS = get_config("migrations", "skip_risky_steps", default=False)
+
+MIDDLEWARE = [
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+]
+
+BUNDLE_ANALYSIS_NOTIFY_MESSAGE_TEMPLATES = (
+ BASE_DIR / "services" / "bundle_analysis" / "notify" / "messages" / "templates"
+)
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [BUNDLE_ANALYSIS_NOTIFY_MESSAGE_TEMPLATES],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.contrib.auth.context_processors.auth",
+ "django.template.context_processors.request",
+ "django.contrib.messages.context_processors.messages",
+ ]
+ },
+ }
+]
+
+# Password validation
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
+AUTH_PASSWORD_VALIDATORS = []
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.2/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
diff --git a/apps/worker/django_scaffold/tests_settings.py b/apps/worker/django_scaffold/tests_settings.py
new file mode 100644
index 0000000000..b17ab895ac
--- /dev/null
+++ b/apps/worker/django_scaffold/tests_settings.py
@@ -0,0 +1,27 @@
+from pathlib import Path
+
+from shared.django_apps.dummy_settings import *
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+BUNDLE_ANALYSIS_NOTIFY_MESSAGE_TEMPLATES = (
+ BASE_DIR / "services" / "bundle_analysis" / "notify" / "messages" / "templates"
+)
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [BUNDLE_ANALYSIS_NOTIFY_MESSAGE_TEMPLATES],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.contrib.auth.context_processors.auth",
+ "django.template.context_processors.request",
+ "django.contrib.messages.context_processors.messages",
+ ]
+ },
+ }
+]
+
+DATABASES["default"]["TEST"] = {"NAME": "test_postgres_django"}
+IS_ENTERPRISE = False
diff --git a/apps/worker/docker-compose.yml b/apps/worker/docker-compose.yml
new file mode 100644
index 0000000000..7333514769
--- /dev/null
+++ b/apps/worker/docker-compose.yml
@@ -0,0 +1,62 @@
+version: "3"
+
+services:
+ worker:
+ image: ${WORKER_DOCKER_REPO}:${WORKER_DOCKER_VERSION}
+ depends_on:
+ - postgres
+ - redis
+ - timescale
+ - minio
+ volumes:
+ - ./:/app/apps/worker
+ - ./docker/test_codecov_config.yml:/config/codecov.yml
+ environment:
+ # Improves pytest-cov performance in python 3.12
+ # https://github.com/nedbat/coveragepy/issues/1665#issuecomment-1937075835
+ - COVERAGE_CORE=sysmon
+ env_file:
+ - .testenv
+ command:
+ - sleep
+ - infinity
+
+ postgres:
+ image: postgres:14-alpine
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_HOST_AUTH_METHOD=trust
+ - POSTGRES_PASSWORD=password
+ volumes:
+ - type: tmpfs
+ target: /var/lib/postgresql/data
+ tmpfs:
+ size: 2048M
+
+ timescale:
+ image: timescale/timescaledb-ha:pg14-latest
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_HOST_AUTH_METHOD=trust
+ - POSTGRES_PASSWORD=password
+ volumes:
+ - ./docker/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql
+
+ minio:
+ image: minio/minio:latest
+ command: server /export
+ ports:
+ - "${MINIO_PORT:-9000}:9000"
+ environment:
+ - MINIO_ACCESS_KEY=codecov-default-key
+ - MINIO_SECRET_KEY=codecov-default-secret
+ volumes:
+ - type: tmpfs
+ target: /export
+ tmpfs:
+ size: 256M
+ redis:
+ image: redis:6-alpine
+
+ mailhog:
+ image: mailhog/mailhog:latest
diff --git a/apps/worker/docker/Dockerfile b/apps/worker/docker/Dockerfile
new file mode 100644
index 0000000000..a91ce0ab69
--- /dev/null
+++ b/apps/worker/docker/Dockerfile
@@ -0,0 +1,34 @@
+# syntax=docker/dockerfile:1.4
+ARG REQUIREMENTS_IMAGE
+ARG BUILD_ENV=self-hosted
+ARG BERGLAS_VERSION=2.0.6
+
+FROM us-docker.pkg.dev/berglas/berglas/berglas:$BERGLAS_VERSION as berglas
+
+FROM $REQUIREMENTS_IMAGE as app
+WORKDIR /app/apps/worker
+ADD . /app/apps/worker
+RUN chmod +x worker.sh
+ARG RELEASE_VERSION
+ENV RELEASE_VERSION=$RELEASE_VERSION
+ENTRYPOINT ["./worker.sh"]
+
+FROM app as local
+
+FROM app as cloud
+COPY --chmod=755 --from=berglas /bin/berglas /usr/local/bin/berglas
+
+FROM app as self-hosted
+ENV RUN_ENV="ENTERPRISE"
+
+
+FROM self-hosted as self-hosted-runtime
+USER root
+ARG EXTERNAL_DEPS_FOLDER=./external_deps
+RUN mkdir $EXTERNAL_DEPS_FOLDER
+RUN pip install --target $EXTERNAL_DEPS_FOLDER psycopg2-binary tlslite-ng
+RUN chown codecov:application $EXTERNAL_DEPS_FOLDER
+USER codecov
+
+
+FROM ${BUILD_ENV}
diff --git a/apps/worker/docker/Dockerfile.requirements b/apps/worker/docker/Dockerfile.requirements
new file mode 100644
index 0000000000..c3f710a2a5
--- /dev/null
+++ b/apps/worker/docker/Dockerfile.requirements
@@ -0,0 +1,66 @@
+# syntax=docker/dockerfile:1.4
+ARG PYTHON_IMAGE=ghcr.io/astral-sh/uv:python3.13-bookworm-slim
+# BUILD STAGE
+FROM $PYTHON_IMAGE as build
+
+RUN apt-get update
+RUN apt-get install -y \
+ build-essential \
+ curl \
+ git \
+ libffi-dev \
+ libpq-dev \
+ libxml2-dev \
+ libxslt-dev
+
+# Install Rust
+ARG RUST_VERSION=stable
+ENV RUST_VERSION=${RUST_VERSION}
+
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
+ | bash -s -- -y --profile minimal --default-toolchain $RUST_VERSION
+ENV PATH="/root/.cargo/bin:$PATH"
+
+ENV UV_LINK_MODE=copy \
+ UV_COMPILE_BYTECODE=1 \
+ UV_PYTHON_DOWNLOADS=never \
+ UV_PYTHON=python \
+ UV_PROJECT_ENVIRONMENT=/worker
+
+# Then, add the rest of the project source code and install it
+# Installing separately from its dependencies allows optimal layer caching
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv export --no-hashes --frozen --format requirements-txt > requirements.txt
+
+RUN grep -v '^-e ' requirements.txt > requirements.remote.txt
+
+# build all remote wheels
+RUN pip wheel -w wheels --find-links wheels -r requirements.remote.txt
+
+# build all local packages to wheels
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv build --all-packages --wheel -o wheels
+
+
+# RUNTIME STAGE - Copy packages from build stage and install runtime dependencies
+FROM $PYTHON_IMAGE
+
+RUN apt-get update
+RUN apt-get install -y \
+ libxml2-dev \
+ libxslt-dev \
+ make
+
+COPY --from=build /wheels/ /wheels/
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv pip install --no-deps --no-index --find-links=wheels wheels/* --system
+
+RUN addgroup --system application \
+ && adduser --system codecov --ingroup application --home /home/codecov
diff --git a/apps/worker/docker/init_db.sql b/apps/worker/docker/init_db.sql
new file mode 100644
index 0000000000..92eca2ee49
--- /dev/null
+++ b/apps/worker/docker/init_db.sql
@@ -0,0 +1 @@
+CREATE DATABASE test_analytics;
\ No newline at end of file
diff --git a/apps/worker/docker/test_codecov_config.yml b/apps/worker/docker/test_codecov_config.yml
new file mode 100644
index 0000000000..81487a89ff
--- /dev/null
+++ b/apps/worker/docker/test_codecov_config.yml
@@ -0,0 +1,72 @@
+setup:
+ codecov_url: https://codecov.io
+ debug: no
+ loglvl: INFO
+ encryption_secret: "zp^P9*i8aR3"
+ timeseries:
+ enabled: true
+ ta_timeseries:
+ enabled: true
+
+services:
+ database_url: postgres://postgres:password@postgres:5432/postgres
+ timeseries_database_url: postgres://postgres:password@timescale:5432/postgres
+ redis_url: redis://redis:6379
+ minio:
+ hash_key: testixik8qdauiab1yiffydimvi72ekq # never change this
+ bucket: archive
+ access_key_id: codecov-default-key
+ secret_access_key: codecov-default-secret
+ verify_ssl: false
+ port: 9000
+ host: minio
+ smtp:
+ host: mailhog
+ port: 1025
+
+github:
+ bot:
+ username: codecov-io
+ integration:
+ id: 254
+ pem: src/certs/github.pem
+
+bitbucket:
+ bot:
+ username: codecov-io
+
+gitlab:
+ bot:
+ username: codecov-io
+
+site:
+ codecov:
+ require_ci_to_pass: yes
+
+ coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project: yes
+ patch: yes
+ changes: no
+
+ parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+ javascript:
+ enable_partials: no
+
+ comment:
+ layout: "reach, diff, flags, files, footer"
+ behavior: default
+ require_changes: no
+ require_base: no
+ require_head: yes
diff --git a/apps/worker/enterprise/ldd b/apps/worker/enterprise/ldd
new file mode 100644
index 0000000000..d7d1f21209
--- /dev/null
+++ b/apps/worker/enterprise/ldd
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# From http://wiki.musl-libc.org/wiki/FAQ#Q:_where_is_ldd_.3F
+#
+# Musl's dynlinker comes with ldd functionality built in. just create a
+# symlink from ld-musl-$ARCH.so to /bin/ldd. If the dynlinker was started
+# as "ldd", it will detect that and print the appropriate DSO information.
+#
+# Instead, this string replaced "ldd" with the package so that pyinstaller
+# can find the actual lib.
+exec /usr/bin/ldd "$@" | \
+ sed -r 's/([^[:space:]]+) => ldd/\1 => \/lib\/\1/g' | \
+ sed -r 's/ldd \(.*\)//g'
\ No newline at end of file
diff --git a/apps/worker/enterprise/package.sh b/apps/worker/enterprise/package.sh
new file mode 100644
index 0000000000..18a780e0c7
--- /dev/null
+++ b/apps/worker/enterprise/package.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Simple wrapper around pyinstaller
+
+set -e
+set -x
+
+# Generate a random key for encryption
+random_key=$(pwgen -s 16 1)
+pyinstaller_args="${@/--random-key/--key $random_key}"
+
+# Use the hacked ldd to fix libc.musl-x86_64.so.1 location
+PATH="/pyinstaller:$PATH"
+
+hiddenimport=$(python -c "
+from glob import glob
+import celery
+
+base = celery.__file__.rsplit('/', 1)[0]
+print(
+ ' '.join(
+ [
+ '--hiddenimport celery'
+ + file.replace(base, '').replace('.py', '').replace('/', '.')
+ for file in (glob(base + '/*.py') + glob(base + '/**/*.py'))
+ ]
+ )
+)
+print(' --hiddenimport pkg_resources.py2_warn')
+print(' --hiddenimport kombu.transport.pyamqp')
+print(' --hiddenimport celery.worker.consumer')
+print(' --hiddenimport sqlalchemy.ext.baked')
+print(' --hiddenimport tasks')
+print(' --hiddenimport tornado.curl_httpclient')
+print(' --hiddenimport asyncore')
+print(' --hiddenimport imaplib')
+print(' --hiddenimport poplib')
+print(' --hiddenimport smtplib')
+print(' --hiddenimport xmlrpc.server')
+")
+
+mkdir src
+echo 'true' > src/is_enterprise
+
+# Exclude pycrypto and PyInstaller from built packages
+pyinstaller -F \
+ --exclude-module pycrypto \
+ --exclude-module PyInstaller \
+ --exclude-module psycopg2 \
+ --exclude-module tlslite \
+ --additional-hooks-dir /pyinstaller/hooks \
+ ${hiddenimport} \
+ ${pyinstaller_args} \
+ --paths /worker \
+ /worker/enterprise.py
+
+# cat enterprise.spec
+
+# Clean up
+mv /worker/dist/enterprise /
+cd /
+rm -rf /home/*
+rm -rf /worker
+mv /enterprise /home
+rm -rf /pyinstaller
+rm -rf /usr/local/lib/python3.8/site-packages
diff --git a/apps/worker/generated_proto/testrun/__init__.py b/apps/worker/generated_proto/testrun/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/generated_proto/testrun/ta_testrun_pb2.py b/apps/worker/generated_proto/testrun/ta_testrun_pb2.py
new file mode 100644
index 0000000000..04e4f6247e
--- /dev/null
+++ b/apps/worker/generated_proto/testrun/ta_testrun_pb2.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# NO CHECKED-IN PROTOBUF GENCODE
+# source: ta_testrun.proto
+# Protobuf Python Version: 5.29.2
+"""Generated protocol buffer code."""
+
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import runtime_version as _runtime_version
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import builder as _builder
+
+_runtime_version.ValidateProtobufRuntimeVersion(
+ _runtime_version.Domain.PUBLIC, 5, 29, 2, "", "ta_testrun.proto"
+)
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
+ b'\n\x10ta_testrun.proto"\xa4\x03\n\x07TestRun\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x11\n\tclassname\x18\x03 \x01(\t\x12\x11\n\ttestsuite\x18\x04 \x01(\t\x12\x15\n\rcomputed_name\x18\x05 \x01(\t\x12!\n\x07outcome\x18\x06 \x01(\x0e\x32\x10.TestRun.Outcome\x12\x17\n\x0f\x66\x61ilure_message\x18\x07 \x01(\t\x12\x18\n\x10\x64uration_seconds\x18\x08 \x01(\x02\x12\x0e\n\x06repoid\x18\n \x01(\x03\x12\x12\n\ncommit_sha\x18\x0b \x01(\t\x12\x13\n\x0b\x62ranch_name\x18\x0c \x01(\t\x12\r\n\x05\x66lags\x18\r \x03(\t\x12\x10\n\x08\x66ilename\x18\x0e \x01(\t\x12\x11\n\tframework\x18\x0f \x01(\t\x12\x11\n\tupload_id\x18\x10 \x01(\x03\x12\x12\n\nflags_hash\x18\x11 \x01(\x0c\x12\x0f\n\x07test_id\x18\x12 \x01(\x0c"@\n\x07Outcome\x12\n\n\x06PASSED\x10\x00\x12\n\n\x06\x46\x41ILED\x10\x01\x12\x0b\n\x07SKIPPED\x10\x02\x12\x10\n\x0c\x46LAKY_FAILED\x10\x03'
+)
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "ta_testrun_pb2", _globals)
+if not _descriptor._USE_C_DESCRIPTORS:
+ DESCRIPTOR._loaded_options = None
+ _globals["_TESTRUN"]._serialized_start = 21
+ _globals["_TESTRUN"]._serialized_end = 441
+ _globals["_TESTRUN_OUTCOME"]._serialized_start = 377
+ _globals["_TESTRUN_OUTCOME"]._serialized_end = 441
+# @@protoc_insertion_point(module_scope)
diff --git a/apps/worker/helpers/__init__.py b/apps/worker/helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/helpers/backfills.py b/apps/worker/helpers/backfills.py
new file mode 100644
index 0000000000..34313ee208
--- /dev/null
+++ b/apps/worker/helpers/backfills.py
@@ -0,0 +1,69 @@
+import logging
+
+import shared.torngit as torngit
+from asgiref.sync import async_to_sync
+from sqlalchemy.orm.session import Session
+
+from database.models.core import GithubAppInstallation, Repository
+
+log = logging.getLogger(__name__)
+
+
+# GH App Backfills
+# Looping and adding all repositories in the installation app
+def add_repos_service_ids_from_provider(
+ db_session: Session,
+ ownerid: int,
+ owner_service: torngit.base.TorngitBaseAdapter,
+ gh_app_installation: GithubAppInstallation,
+):
+ # TODO: Convert this to the generator function
+ repos = async_to_sync(owner_service.list_repos_using_installation)()
+
+ if repos:
+ # Fetching all repos service ids we have for that owner in the DB
+ repo_service_ids_in_db = [
+ repo.service_id
+ for repo in db_session.query(Repository.service_id)
+ .filter_by(ownerid=ownerid)
+ .all()
+ ]
+
+ # Add service ids from provider that we have DB records for to a list
+ new_repo_service_ids = []
+ for repo in repos:
+ repo_data = repo["repo"]
+ service_id = repo_data["service_id"]
+ if service_id and service_id in repo_service_ids_in_db:
+ new_repo_service_ids.append(service_id)
+ log.info(
+ "Added the following repo service ids to this gh app installation",
+ extra=dict(
+ ownerid=ownerid,
+ installation_id=gh_app_installation.installation_id,
+ new_repo_service_ids=new_repo_service_ids,
+ ),
+ )
+ gh_app_installation.repository_service_ids = new_repo_service_ids
+ db_session.commit()
+
+
+# Check if gh selection is set to all and act accordingly
+def maybe_set_installation_to_all_repos(
+ db_session: Session,
+ owner_service,
+ gh_app_installation: GithubAppInstallation,
+):
+ remote_gh_app_installation = async_to_sync(owner_service.get_gh_app_installation)(
+ installation_id=gh_app_installation.installation_id
+ )
+ repository_selection = remote_gh_app_installation.get("repository_selection", "")
+ if repository_selection == "all":
+ gh_app_installation.repository_service_ids = None
+ db_session.commit()
+ log.info(
+ "Selection is set to all, no installation is needed",
+ extra=dict(ownerid=gh_app_installation.ownerid),
+ )
+ return True
+ return False
diff --git a/apps/worker/helpers/checkpoint_logger/__init__.py b/apps/worker/helpers/checkpoint_logger/__init__.py
new file mode 100644
index 0000000000..1d426d2fdd
--- /dev/null
+++ b/apps/worker/helpers/checkpoint_logger/__init__.py
@@ -0,0 +1,520 @@
+"""
+`checkpoint_logger` is a module that tracks latencies/reliabilities for higher-level
+"flows" that don't map well to auto-instrumented tracing. It serializes its data
+between tasks allowing you to begin a flow on one host and log its completion on
+another (as long as clock drift is marginal).
+
+See `UploadFlow` for an example of defining a flow. It's recommended that you
+define your flow with the decorators in this file:
+- `@success_events()`, `@failure_events()`: designate some events as terminal
+ success/fail states of your flow.
+- `@subflows()`: pre-define subflows that get submitted automatically; implicitly
+ define a flow from the first event to each success or failure event
+- `@reliability_counters`: increment event, start, finish, success, failure counters
+ for defining reliability metrics for your flow
+
+It is expected that `@subflows()` and `@reliability_counters` be invoked **after**
+`@success_events()` and/or `@failure_events`.
+
+ # Simple usage
+ UploadFlow.log(UploadFlow.BEGIN)
+ ...
+ # each function returns the flow so you can chain `log` calls.
+ UploadFlow
+ .log(UploadFlow.PROCESSING_COMPLETE)
+ .log(UploadFlow.SKIPPING_NOTIFICATION)
+
+
+ # More complicated usage
+ # - Loads data from a previous task that was passed in `kwargs`
+ # - Logs `UploadFlow.BEGIN` directly into `kwargs` to pass to the next task
+ # - Ignores if `UploadFlow.BEGIN` was already logged (i.e. if this is a task retry)
+ from_kwargs([UploadFlow], kwargs)
+ UploadFlow.log(UploadFlow.BEGIN, kwargs=kwargs, ignore_repeat=True)
+ next_task(kwargs)
+ ...
+ # when using `@failure_events()` and `@subflows()`, an auto-created subflow
+ # is automatically submitted because `UploadFlow.TOO_MANY_RETRIES` is an error
+ from_kwargs(UploadFlow, kwargs)
+ .log(UploadFlow.TOO_MANY_RETRIES)
+"""
+
+import functools
+import itertools
+import logging
+import time
+from enum import Enum
+from typing import (
+ Any,
+ Callable,
+ ClassVar,
+ Iterable,
+ Mapping,
+ MutableMapping,
+ Optional,
+ TypeAlias,
+ TypeVar,
+)
+
+import sentry_sdk
+
+from helpers.checkpoint_logger.prometheus import PROMETHEUS_HANDLER
+from helpers.log_context import get_log_context, set_log_context
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T", bound="BaseFlow")
+TSubflows: TypeAlias = Mapping[T, Iterable[tuple[str, T]]]
+
+
+def _error(msg, flow, strict=False):
+ # When a new version of worker rolls out, it will pick up tasks that
+ # may have been enqueued by the old worker and be missing checkpoints
+ # data. At least for that reason, we want to allow failing softly.
+ PROMETHEUS_HANDLER.log_errors(flow=flow.__name__)
+ if strict:
+ raise ValueError(msg)
+ else:
+ logger.warning(msg)
+
+
+class BaseFlow(str, Enum):
+ """
+ Base class for a flow. Defines optional functions which are added by the
+ @success_events, @failure_events, @subflows, and @reliability_counters
+ decorators to (mostly) appease mypy.
+
+ Inherits from `str` so a dictionary of checkpoints data can be serialized
+ between worker tasks. It overrides sort order functions so that it follows
+ enum declaration order instead of lexicographic order.
+ """
+
+ _subflows: Callable[[], TSubflows]
+ _success_events: Callable[[], Iterable[T]]
+ _failure_events: Callable[[], Iterable[T]]
+ is_success: ClassVar[Callable[[T], bool]]
+ is_failure: ClassVar[Callable[[T], bool]]
+ log_counters: ClassVar[Callable[[T], None]]
+
+ def _generate_next_value_(
+ name: str, start: int, count: int, last_values: list[Any]
+ ): # type: ignore[override]
+ """
+ This powers `enum.auto()`. It sets the value of "MyEnum.A" to "A".
+ """
+ return name
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self.__class__._member_names_.index(
+ self.name
+ ) == self.__class__._member_names_.index(other.name)
+ return NotImplemented
+
+ def __gt__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self.__class__._member_names_.index(
+ self.name
+ ) > self.__class__._member_names_.index(other.name)
+ return NotImplemented
+
+ def __ge__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self == other or self > other
+ return NotImplemented
+
+ def __lt__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return (not self == other) and (not self > other)
+ return NotImplemented
+
+ def __le__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self == other or self < other
+ return NotImplemented
+
+ def __hash__(self):
+ return hash(self.name)
+
+ @classmethod
+ def beginning(cls: type[T]) -> T:
+ return next(iter(cls.__members__.values()))
+
+ @classmethod
+ def has_begun(cls: type[T]) -> bool:
+ return cls.beginning() in cls._data_from_log_context()
+
+ @classmethod
+ def has_ended(cls: type[T]) -> bool:
+ return cls.final() in cls._data_from_log_context()
+
+ @classmethod
+ def final(cls: type[T]) -> T:
+ *_, final = iter(cls.__members__.values())
+ return final
+
+ def is_beginning(self):
+ return self == self.beginning()
+
+ @classmethod
+ def _validate_checkpoint(cls: type[T], checkpoint: T) -> None:
+ if checkpoint.__class__ != cls:
+ # This error is not ignored when `strict==False` because it's definitely
+ # a code mistake
+ raise ValueError(
+ f"Checkpoint {checkpoint} not part of flow `{cls.__name__}`"
+ )
+
+ @classmethod
+ def _data_from_log_context(cls: type[T]) -> Mapping[T, int]:
+ return get_log_context().checkpoints_data.get(_kwargs_key(cls), {})
+
+ @classmethod
+ def _save_to_log_context(cls: type[T], data: Mapping[T, int]):
+ log_context = get_log_context()
+ log_context.checkpoints_data[_kwargs_key(cls)] = data
+ set_log_context(log_context)
+
+ @classmethod
+ def save_to_kwargs(cls: type[T], kwargs: dict):
+ data = cls._data_from_log_context()
+ if data:
+ kwargs[_kwargs_key(cls)] = data
+ return kwargs
+
+ @classmethod
+ def log(
+ cls: type[T],
+ checkpoint: T,
+ ignore_repeat: bool = False,
+ kwargs: Optional[MutableMapping[str, Any]] = None,
+ strict: bool = False,
+ ) -> type[T]:
+ cls._validate_checkpoint(checkpoint)
+
+ if not cls.has_begun() and not checkpoint.is_beginning():
+ _error(
+ f"Tried to log checkpoint {checkpoint} before the flow began",
+ cls,
+ strict=strict,
+ )
+ return cls
+
+ is_failure = hasattr(checkpoint, "is_failure") and checkpoint.is_failure()
+ is_success = hasattr(checkpoint, "is_success") and checkpoint.is_success()
+ is_terminal = is_failure or is_success
+ if is_terminal and cls.has_ended():
+ _error(
+ f"Tried to log terminal checkpoint {checkpoint} after the flow ended",
+ cls,
+ strict=strict,
+ )
+ return cls
+
+ data = cls._data_from_log_context()
+ if checkpoint in data:
+ if not ignore_repeat:
+ _error(f"Already recorded checkpoint {checkpoint}", cls, strict=strict)
+ return cls
+
+ timestamp = _get_milli_timestamp()
+ data[checkpoint] = timestamp
+ if is_terminal:
+ data[cls.final()] = timestamp
+ cls._save_to_log_context(data)
+
+ if kwargs is not None:
+ cls.save_to_kwargs(kwargs)
+
+ # `cls._subflows()` comes from the `@subflows` decorator
+ # If the flow has pre-defined subflows, we can automatically submit
+ # any of them that end with the checkpoint we just logged.
+ if hasattr(cls, "_subflows"):
+ for metric, beginning in cls._subflows().get(checkpoint, []): # type: ignore[operator]
+ cls.submit_subflow(metric, beginning, checkpoint, data=data)
+
+ # `checkpoint.log_counters()` comes from the `@reliability_counters`
+ # decorator
+ # Increment event, start, finish, success, failure counters
+ if hasattr(checkpoint, "log_counters"):
+ checkpoint.log_counters()
+
+ return cls
+
+ @classmethod
+ def _subflow_duration(
+ cls: type[T], start: T, end: T, data: Mapping[T, int], strict=False
+ ) -> Optional[int]:
+ cls._validate_checkpoint(start)
+ cls._validate_checkpoint(end)
+ if start not in data:
+ _error(
+ f"Cannot compute duration; missing start checkpoint {start}",
+ cls,
+ strict=strict,
+ )
+ return None
+ elif end not in data:
+ _error(
+ f"Cannot compute duration; missing end checkpoint {end}",
+ cls,
+ strict=strict,
+ )
+ return None
+ elif end <= start:
+ # This error is not ignored when `self.strict==False` because it's definitely
+ # a code mistake
+ raise ValueError(
+ f"Cannot compute duration; end {end} is not after start {start}"
+ )
+
+ return data[end] - data[start]
+
+ @classmethod
+ def submit_subflow(
+ cls: type[T], metric: str, start: T, end: T, data: Mapping[T, int], strict=False
+ ) -> type[T]:
+ duration = cls._subflow_duration(start, end, data, strict)
+ if duration:
+ sentry_sdk.set_measurement(metric, duration, "milliseconds")
+ duration_in_seconds = duration / 1000
+ PROMETHEUS_HANDLER.log_subflow(
+ flow=cls.__name__, subflow=metric, duration=duration_in_seconds
+ )
+
+ return cls
+
+
+TClassDecorator: TypeAlias = Callable[[type[T]], type[T]]
+
+
+def failure_events(*args: str) -> TClassDecorator:
+ """
+ Class decorator that designates some events as terminal failure conditions.
+
+ @failure_events('ERROR')
+ class MyEnum(str, Enum):
+ BEGIN: auto()
+ CHECKPOINT: auto()
+ ERROR: auto()
+ FINISHED: auto()
+ assert MyEnum.ERROR.is_failure()
+ """
+
+ def class_decorator(klass: type[T]) -> type[T]:
+ def _failure_events() -> Iterable[T]:
+ return {v for k, v in klass.__members__.items() if k in args}
+
+ def is_failure(obj: T) -> bool:
+ return obj in _failure_events()
+
+ # `_failure_events` is a cached function rather than a data member so
+ # that it is not processed as if it's a value from the enum.
+ klass._failure_events = functools.lru_cache(maxsize=1)(_failure_events)
+ klass.is_failure = is_failure
+
+ return klass
+
+ return class_decorator
+
+
+def success_events(*args: str) -> TClassDecorator:
+ """
+ Class decorator that designates some events as terminal success conditions.
+
+ @success_events('FINISHED')
+ class MyEnum(str, Enum):
+ BEGIN: auto()
+ CHECKPOINT: auto()
+ ERROR: auto()
+ FINISHED: auto()
+ assert MyEnum.FINISHED.is_success()
+ """
+
+ def class_decorator(klass: type[T]) -> type[T]:
+ def _success_events() -> Iterable[T]:
+ return {v for k, v in klass.__members__.items() if k in args}
+
+ def is_success(obj: T) -> bool:
+ return obj in _success_events()
+
+ # `_success_events` is a cached function rather than a data member so
+ # that it is not processed as if it's a value from the enum.
+ klass._success_events = functools.lru_cache(maxsize=1)(_success_events)
+ klass.is_success = is_success
+
+ return klass
+
+ return class_decorator
+
+
+def subflows(*args: tuple[str, str, str]) -> TClassDecorator:
+ """
+ Class decorator that defines a set of interesting subflows which should be
+ logged as well as the name each should be logged with. It is expected that
+ you invoke this **after** @success_events() and/or @failure_events().
+
+ @success_events('FINISH')
+ @subflows(
+ ('first_subflow', 'BEGIN', 'CHECKPOINT_A'),
+ ('second_subflow', 'CHECKPOINT_A', 'FINISH')
+ )
+ class MyEnum(str, Enum):
+ BEGIN: auto()
+ CHECKPOINT: auto()
+ ERROR: auto()
+ FINISHED: auto()
+
+ A subflow from the first event to each terminal event (success and failure) is
+ created implicitly with names like 'MyEnum_BEGIN_to_FINISHED'. This name can be
+ overridden by defining the subflow explicitly.
+ """
+
+ def class_decorator(klass: type[T]) -> type[T]:
+ def _subflows() -> TSubflows:
+ # We get our subflows in the form: [(metric, begin, end)]
+ # We want them in the form: {end: [(metric, begin)]}
+ # The first step of munging is to group by end
+ def key_on_end(x):
+ return x[2]
+
+ sorted_by_end = sorted(args, key=key_on_end)
+ grouped_by_end = itertools.groupby(args, key=key_on_end)
+
+ enum_vals = klass.__members__
+
+ subflows = {}
+ for end, group in grouped_by_end:
+ # grouped_by_end is not a simple dict so we create our own.
+ # `begin` and `end` are still strings at this point so we also want to convert
+ # them to enum values.
+ subflows[enum_vals[end]] = list(
+ ((metric, enum_vals[begin]) for metric, begin, _ in group)
+ )
+
+ # The first enum value is the beginning of the flow, no matter what
+ # branches it takes. We want to automatically define a subflow from
+ # this beginning to each terminal checkpoint (failures/successes)
+ # unless the user provided one already.
+ flow_begin = next(iter(enum_vals.values()))
+
+ # `klass._failure_events` comes from the `@failure_events` decorator
+ if hasattr(klass, "_failure_events"):
+ # mypy thinks klass._failure_events == klass
+ for end in klass._failure_events(): # type: ignore[operator]
+ flows_ending_here = subflows.setdefault(
+ end, []
+ ) # [(metric, begin)]
+ if not any((x[1] == flow_begin for x in flows_ending_here)):
+ flows_ending_here.append(
+ (
+ f"{klass.__name__}_{flow_begin.name}_to_{end.name}",
+ flow_begin,
+ )
+ )
+
+ # `klass._success_events` comes from the `@success_events` decorator
+ if hasattr(klass, "_success_events"):
+ # mypy thinks klass._success_events == klass
+ for end in klass._success_events(): # type: ignore[operator]
+ flows_ending_here = subflows.setdefault(
+ end, []
+ ) # [(metric, begin)]
+ if not any((x[1] == flow_begin for x in flows_ending_here)):
+ flows_ending_here.append(
+ (
+ f"{klass.__name__}_{flow_begin.name}_to_{end.name}",
+ flow_begin,
+ )
+ )
+
+ return subflows
+
+ klass._subflows = functools.lru_cache(maxsize=1)(_subflows)
+ return klass
+
+ return class_decorator
+
+
+def reliability_counters(klass: type[T]) -> type[T]:
+ """
+ Class decorator that enables computing success/failure rates for a flow. It
+ is expected that you invoke this **after** @success_events and/or
+ @failure_events.
+
+ @success_events('FINISHED')
+ @failure_events('ERROR')
+ @reliability_counters
+ class MyEnum(str, Enum):
+ BEGIN: auto()
+ CHECKPOINT: auto()
+ ERROR: auto()
+ FINISHED: auto()
+ MyEnum.BEGIN.log_counters() # increments "MyEnum.begun" counter
+ MyEnum.ERROR.log_counters() # increments "MyEnum.failed" counter
+ MyEnum.FINISHED.log_counters() # increments "MyEnum.succeeded" counter
+
+ A "MyEnum.ended" counter is incremented for both success and failure events.
+ This counter can be compared to "MyEnum.begun" to detect if any branches
+ aren't instrumented.
+ """
+
+ def log_counters(obj: T) -> None:
+ PROMETHEUS_HANDLER.log_checkpoints(flow=klass.__name__, checkpoint=obj.name)
+
+ # If this is the first checkpoint, increment the number of flows we've begun
+ if obj == next(iter(klass.__members__.values())):
+ PROMETHEUS_HANDLER.log_begun(flow=klass.__name__)
+ return
+
+ is_failure = hasattr(obj, "is_failure") and obj.is_failure()
+ is_success = hasattr(obj, "is_success") and obj.is_success()
+ is_terminal = is_failure or is_success
+
+ if is_failure:
+ PROMETHEUS_HANDLER.log_failure(flow=klass.__name__)
+ elif is_success:
+ PROMETHEUS_HANDLER.log_success(flow=klass.__name__)
+
+ if is_terminal:
+ PROMETHEUS_HANDLER.log_total_ended(flow=klass.__name__)
+
+ klass.log_counters = log_counters
+ return klass
+
+
+def _get_milli_timestamp() -> int:
+ return time.time_ns() // 1000000
+
+
+def _kwargs_key(cls: type[T]) -> str:
+ return f"checkpoints_{cls.__name__}"
+
+
+def from_kwargs(
+ flows: list[type[T]], kwargs: MutableMapping[str, Any], strict: bool = False
+):
+ checkpoints_data = {}
+ for cls in flows:
+ kwargs_key = _kwargs_key(cls)
+ data = kwargs.get(kwargs_key, {})
+
+ # kwargs has been deserialized into a Python dictionary, but our enum values
+ # are deserialized as simple strings. We need to ensure the strings are all
+ # proper enum values as best we can, and then downcast to enum instances.
+ checkpoints_data[kwargs_key] = {}
+ for checkpoint, timestamp in data.items():
+ try:
+ checkpoints_data[kwargs_key][cls(checkpoint)] = timestamp
+ except ValueError:
+ _error(
+ f"Checkpoint {checkpoint} not part of flow `{cls.__name__}`",
+ cls,
+ strict,
+ )
+ checkpoints_data[kwargs_key] = {}
+ break
+
+ log_context = get_log_context()
+ log_context.checkpoints_data = checkpoints_data
+ set_log_context(log_context)
diff --git a/apps/worker/helpers/checkpoint_logger/flows.py b/apps/worker/helpers/checkpoint_logger/flows.py
new file mode 100644
index 0000000000..9d4b78ec7a
--- /dev/null
+++ b/apps/worker/helpers/checkpoint_logger/flows.py
@@ -0,0 +1,103 @@
+from enum import auto
+
+from helpers.checkpoint_logger import (
+ BaseFlow,
+ failure_events,
+ reliability_counters,
+ subflows,
+ success_events,
+)
+
+
+@failure_events(
+ "TOO_MANY_RETRIES",
+ "FINISHER_LOCK_ERROR",
+ "NOTIF_LOCK_ERROR",
+ "NOTIF_NO_VALID_INTEGRATION",
+ "NOTIF_GIT_CLIENT_ERROR",
+ "NOTIF_GIT_SERVICE_ERROR",
+ "NOTIF_TOO_MANY_RETRIES",
+ "NOTIF_NO_APP_INSTALLATION",
+ "NOTIF_ERROR_NO_REPORT",
+ "NOTIFIED_ERROR",
+ "ERROR_NOTIFYING_ERROR",
+ "UNCAUGHT_RETRY_EXCEPTION",
+ "CELERY_FAILURE",
+ "CELERY_TIMEOUT",
+)
+@success_events(
+ "SKIPPING_NOTIFICATION",
+ "NOTIFIED",
+ "NO_PENDING_JOBS",
+ "NOTIF_STALE_HEAD",
+ "NO_REPORTS_FOUND",
+)
+@subflows(
+ ("time_before_processing", "UPLOAD_TASK_BEGIN", "PROCESSING_BEGIN"),
+ ("initial_processing_duration", "PROCESSING_BEGIN", "INITIAL_PROCESSING_COMPLETE"),
+ (
+ "batch_processing_duration",
+ "INITIAL_PROCESSING_COMPLETE",
+ "BATCH_PROCESSING_COMPLETE",
+ ),
+ ("total_processing_duration", "PROCESSING_BEGIN", "PROCESSING_COMPLETE"),
+ ("notification_latency", "UPLOAD_TASK_BEGIN", "NOTIFIED"),
+ ("error_notification_latency", "UPLOAD_TASK_BEGIN", "NOTIFIED_ERROR"),
+)
+@reliability_counters
+class UploadFlow(BaseFlow):
+ UPLOAD_TASK_BEGIN = auto()
+ NO_PENDING_JOBS = auto()
+ NO_REPORTS_FOUND = auto()
+ TOO_MANY_RETRIES = auto()
+ PROCESSING_BEGIN = auto()
+ INITIAL_PROCESSING_COMPLETE = auto()
+ BATCH_PROCESSING_COMPLETE = auto()
+ PROCESSING_COMPLETE = auto()
+ SKIPPING_NOTIFICATION = auto()
+ NOTIFIED = auto()
+ NOTIFIED_ERROR = auto()
+ ERROR_NOTIFYING_ERROR = auto()
+ FINISHER_LOCK_ERROR = auto()
+ NOTIF_LOCK_ERROR = auto()
+ NOTIF_NO_VALID_INTEGRATION = auto()
+ NOTIF_GIT_CLIENT_ERROR = auto()
+ NOTIF_GIT_SERVICE_ERROR = auto()
+ NOTIF_TOO_MANY_RETRIES = auto()
+ NOTIF_STALE_HEAD = auto()
+ NOTIF_NO_APP_INSTALLATION = auto()
+ NOTIF_ERROR_NO_REPORT = auto()
+ UNCAUGHT_RETRY_EXCEPTION = auto()
+ CELERY_FAILURE = auto()
+ CELERY_TIMEOUT = auto()
+
+ # Sentinel checkpoint - not directly used
+ FINAL = auto()
+
+
+@failure_events(
+ "TEST_RESULTS_ERROR", "UNCAUGHT_RETRY_EXCEPTION", "CELERY_FAILURE", "CELERY_TIMEOUT"
+)
+@success_events("TEST_RESULTS_NOTIFY")
+@subflows(
+ ("test_results_notification_latency", "TEST_RESULTS_BEGIN", "TEST_RESULTS_NOTIFY"),
+ ("flake_notification_latency", "TEST_RESULTS_BEGIN", "FLAKE_DETECTION_NOTIFY"),
+ (
+ "test_results_processing_time",
+ "TEST_RESULTS_BEGIN",
+ "TEST_RESULTS_FINISHER_BEGIN",
+ ),
+)
+@reliability_counters
+class TestResultsFlow(BaseFlow):
+ TEST_RESULTS_BEGIN = auto()
+ TEST_RESULTS_NOTIFY = auto()
+ FLAKE_DETECTION_NOTIFY = auto()
+ TEST_RESULTS_ERROR = auto()
+ TEST_RESULTS_FINISHER_BEGIN = auto()
+ UNCAUGHT_RETRY_EXCEPTION = auto()
+ CELERY_FAILURE = auto()
+ CELERY_TIMEOUT = auto()
+
+ # Sentinel checkpoint - not directly used
+ FINAL = auto()
diff --git a/apps/worker/helpers/checkpoint_logger/prometheus.py b/apps/worker/helpers/checkpoint_logger/prometheus.py
new file mode 100644
index 0000000000..15bf5b8ca9
--- /dev/null
+++ b/apps/worker/helpers/checkpoint_logger/prometheus.py
@@ -0,0 +1,174 @@
+from shared.metrics import Counter, Histogram
+
+from helpers.log_context import get_log_context
+from rollouts import CHECKPOINT_ENABLED_REPOSITORIES
+
+_subflow_buckets = [
+ 0.05,
+ 0.1,
+ 0.5,
+ 1,
+ 2,
+ 5,
+ 10,
+ 30,
+ 60,
+ 120,
+ 180,
+ 300,
+ 600,
+ 900,
+ 1200,
+ 1800,
+ 2400,
+ 3600,
+]
+
+# Main Counter
+CHECKPOINTS_TOTAL_BEGUN = Counter(
+ "worker_checkpoints_begun",
+ "Total number of times a flow's first checkpoint was logged.",
+ ["flow"],
+)
+CHECKPOINTS_TOTAL_SUCCEEDED = Counter(
+ "worker_checkpoints_succeeded",
+ "Total number of times one of a flow's success checkpoints was logged.",
+ ["flow"],
+)
+CHECKPOINTS_TOTAL_FAILED = Counter(
+ "worker_checkpoints_failed",
+ "Total number of times one of a flow's failure checkpoints was logged.",
+ ["flow"],
+)
+CHECKPOINTS_TOTAL_ENDED = Counter(
+ "worker_checkpoints_ended",
+ "Total number of times one of a flow's terminal checkpoints (success or failure) was logged.",
+ ["flow"],
+)
+CHECKPOINTS_ERRORS = Counter(
+ "worker_checkpoints_errors",
+ "Total number of errors while trying to log checkpoints",
+ ["flow"],
+)
+CHECKPOINTS_EVENTS = Counter(
+ "worker_checkpoints_events",
+ "Total number of checkpoints logged.",
+ ["flow", "checkpoint"],
+)
+CHECKPOINTS_SUBFLOW_DURATION = Histogram(
+ "worker_checkpoints_subflow_duration_seconds",
+ "Duration of subflows in seconds.",
+ ["flow", "subflow"],
+ buckets=_subflow_buckets,
+)
+
+# Repo Counters
+REPO_CHECKPOINTS_TOTAL_BEGUN = Counter(
+ "worker_repo_checkpoints_begun",
+ "Total number of times a flow's first checkpoint was logged. Labeled with a repo id, but only used for select repos.",
+ ["flow", "repoid"],
+)
+REPO_CHECKPOINTS_TOTAL_SUCCEEDED = Counter(
+ "worker_repo_checkpoints_succeeded",
+ "Total number of times one of a flow's success checkpoints was logged. Labeled with a repo id, but only used for select repos.",
+ ["flow", "repoid"],
+)
+REPO_CHECKPOINTS_TOTAL_FAILED = Counter(
+ "worker_repo_checkpoints_failed",
+ "Total number of times one of a flow's failure checkpoints was logged. Labeled with a repo id, but only used for select repos.",
+ ["flow", "repoid"],
+)
+REPO_CHECKPOINTS_TOTAL_ENDED = Counter(
+ "worker_repo_checkpoints_ended",
+ "Total number of times one of a flow's terminal checkpoints (success or failure) was logged. Labeled with a repo id, but only used for select repos.",
+ ["flow", "repoid"],
+)
+REPO_CHECKPOINTS_ERRORS = Counter(
+ "worker_repo_checkpoints_errors",
+ "Total number of errors while trying to log checkpoints. Labeled with a repo id, but only used for select repos.",
+ ["flow", "repoid"],
+)
+REPO_CHECKPOINTS_EVENTS = Counter(
+ "worker_repo_checkpoints_events",
+ "Total number of checkpoints logged. Labeled with a repo id, but only used for select repos.",
+ ["flow", "checkpoint", "repoid"],
+)
+REPO_CHECKPOINTS_SUBFLOW_DURATION = Histogram(
+ "worker_repo_checkpoints_subflow_duration_seconds",
+ "Duration of subflows in seconds. Labeled with a repo id, but only used for select repos.",
+ ["flow", "subflow", "repoid"],
+ buckets=_subflow_buckets,
+)
+
+
+class PrometheusCheckpointLoggerHandler:
+ """
+ PrometheusCheckpointLoggerHandler is a class that is responsible for all
+ Prometheus related logs. This checkpoint logic is responsible for logging
+ metrics to any checkpoints we define. This class is made with the intent
+ of extending different checkpoints for metrics for different needs. The
+ methods in this class are mainly used by the CheckpointLogger class.
+ """
+
+ def log_begun(self, flow: str):
+ CHECKPOINTS_TOTAL_BEGUN.labels(flow=flow).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_TOTAL_BEGUN.labels(flow=flow, repoid=repoid).inc()
+
+ def log_failure(self, flow: str):
+ CHECKPOINTS_TOTAL_FAILED.labels(flow=flow).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_TOTAL_FAILED.labels(flow=flow, repoid=repoid).inc()
+
+ def log_success(self, flow: str):
+ CHECKPOINTS_TOTAL_SUCCEEDED.labels(flow=flow).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_TOTAL_SUCCEEDED.labels(flow=flow, repoid=repoid).inc()
+
+ def log_total_ended(self, flow: str):
+ CHECKPOINTS_TOTAL_ENDED.labels(flow=flow).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_TOTAL_ENDED.labels(flow=flow, repoid=repoid).inc()
+
+ def log_checkpoints(self, flow: str, checkpoint: str):
+ CHECKPOINTS_EVENTS.labels(flow=flow, checkpoint=checkpoint).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_EVENTS.labels(
+ flow=flow, checkpoint=checkpoint, repoid=repoid
+ ).inc()
+
+ def log_errors(self, flow: str):
+ CHECKPOINTS_ERRORS.labels(flow=flow).inc()
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_ERRORS.labels(flow=flow, repoid=repoid).inc()
+
+ def log_subflow(
+ self,
+ flow: str,
+ subflow: str,
+ duration: int,
+ ):
+ CHECKPOINTS_SUBFLOW_DURATION.labels(flow=flow, subflow=subflow).observe(
+ duration
+ )
+ context = get_log_context()
+ repoid = context and context.repo_id
+ if repoid and CHECKPOINT_ENABLED_REPOSITORIES.check_value(identifier=repoid):
+ REPO_CHECKPOINTS_SUBFLOW_DURATION.labels(
+ flow=flow, subflow=subflow, repoid=repoid
+ ).observe(duration)
+
+
+PROMETHEUS_HANDLER = PrometheusCheckpointLoggerHandler()
diff --git a/apps/worker/helpers/clock.py b/apps/worker/helpers/clock.py
new file mode 100644
index 0000000000..e1dbf69f96
--- /dev/null
+++ b/apps/worker/helpers/clock.py
@@ -0,0 +1,15 @@
+from datetime import datetime, timezone
+
+
+def get_utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def get_utc_now_as_iso_format() -> str:
+ return get_utc_now().isoformat()
+
+
+def get_seconds_to_next_hour() -> int:
+ now = datetime.now(timezone.utc)
+ current_seconds = (now.minute * 60) + now.second
+ return 3600 - current_seconds
diff --git a/apps/worker/helpers/comparison.py b/apps/worker/helpers/comparison.py
new file mode 100644
index 0000000000..6b112d7d78
--- /dev/null
+++ b/apps/worker/helpers/comparison.py
@@ -0,0 +1,21 @@
+from shared.reports.types import ReportTotals
+
+
+def minimal_totals(totals: ReportTotals | None) -> dict:
+ if totals is None:
+ return {
+ "hits": 0,
+ "misses": 0,
+ "partials": 0,
+ "coverage": None,
+ }
+ return {
+ "hits": totals.hits,
+ "misses": totals.misses,
+ "partials": totals.partials,
+ # ReportTotals has coverage as a string, we want float in the DB
+ # Also the coverage from ReportTotals is 0-100, while in the DB it's 0-1
+ "coverage": (
+ (float(totals.coverage) / 100) if totals.coverage is not None else None
+ ),
+ }
diff --git a/apps/worker/helpers/components.py b/apps/worker/helpers/components.py
new file mode 100644
index 0000000000..bea35cdc72
--- /dev/null
+++ b/apps/worker/helpers/components.py
@@ -0,0 +1,42 @@
+import re
+from dataclasses import dataclass
+from typing import List
+
+
+@dataclass
+class Component:
+ """
+ Virtual representation of components defined in the user_schema yaml.
+ Definition: https://github.com/codecov/shared/pull/312/commits/c7bd48173da914bb16137526015791cb5a3c931c
+ """
+
+ component_id: str
+ name: str
+ flag_regexes: List[str]
+ paths: List[str]
+ statuses: List[dict]
+
+ @classmethod
+ def from_dict(cls, component_dict):
+ return Component(
+ component_id=component_dict.get("component_id", ""),
+ name=component_dict.get("name", ""),
+ flag_regexes=component_dict.get("flag_regexes", []),
+ paths=component_dict.get("paths", []),
+ statuses=component_dict.get("statuses", []),
+ )
+
+ def get_display_name(self) -> str:
+ return self.name or self.component_id or "default_component"
+
+ def get_matching_flags(self, current_flags: List[str]) -> List[str]:
+ ans = set()
+ compiled_regexes = map(
+ lambda flag_regex: re.compile(flag_regex), self.flag_regexes
+ )
+ for regex_to_match in compiled_regexes:
+ matches_to_this_regex = filter(
+ lambda flag: regex_to_match.match(flag), current_flags
+ )
+ ans.update(matches_to_this_regex)
+ return list(ans)
diff --git a/apps/worker/helpers/config.py b/apps/worker/helpers/config.py
new file mode 100644
index 0000000000..976eab1d41
--- /dev/null
+++ b/apps/worker/helpers/config.py
@@ -0,0 +1,22 @@
+from shared.config import get_config
+
+
+def should_write_data_to_storage_config_check(
+ master_switch_key: str, is_codecov_repo: bool, repoid: int
+) -> bool:
+ master_write_switch = get_config(
+ "setup",
+ "save_report_data_in_storage",
+ master_switch_key,
+ default=False,
+ )
+ if master_write_switch == "restricted_access":
+ allowed_repo_ids = get_config(
+ "setup", "save_report_data_in_storage", "repo_ids", default=[]
+ )
+ is_in_allowed_repoids = repoid in allowed_repo_ids
+ elif master_write_switch == "general_access":
+ is_in_allowed_repoids = True
+ else:
+ is_in_allowed_repoids = False
+ return master_write_switch and (is_codecov_repo or is_in_allowed_repoids)
diff --git a/apps/worker/helpers/email.py b/apps/worker/helpers/email.py
new file mode 100644
index 0000000000..b6053efcf2
--- /dev/null
+++ b/apps/worker/helpers/email.py
@@ -0,0 +1,14 @@
+from email.message import EmailMessage
+
+
+class Email:
+ def __init__(
+ self, to_addr=None, from_addr=None, subject=None, text=None, html=None
+ ):
+ self.message = EmailMessage()
+ self.message["To"] = to_addr
+ self.message["From"] = from_addr
+ self.message["Subject"] = subject
+ self.message.set_content(text)
+ if html:
+ self.message.add_alternative(html, "html")
diff --git a/apps/worker/helpers/environment.py b/apps/worker/helpers/environment.py
new file mode 100644
index 0000000000..7cd43abf4c
--- /dev/null
+++ b/apps/worker/helpers/environment.py
@@ -0,0 +1,65 @@
+import os
+import sys
+from enum import Enum
+from functools import lru_cache
+from pathlib import Path
+
+from shared.config import get_config
+
+
+class Environment(Enum):
+ production = "production"
+ local = "local"
+ enterprise = "enterprise"
+
+
+def get_current_env() -> Environment:
+ """
+ Gets the current environment of the system
+
+ Returns:
+ Environment: The current environment
+ """
+ return _get_cached_current_env()
+
+
+def is_enterprise() -> bool:
+ """Tells whether the current environment is enterprise or not
+
+ Returns:
+ bool: True if the current environment is enterprise, else False
+ """
+ return get_current_env() == Environment.enterprise
+
+
+@lru_cache()
+def _get_cached_current_env() -> Environment:
+ return _calculate_current_env()
+
+
+def _get_current_folder() -> str:
+ return getattr(sys, "_MEIPASS", os.getcwd())
+
+
+def _calculate_current_env() -> Environment:
+ run_env = os.getenv("RUN_ENV")
+ if run_env is not None:
+ if run_env == "ENTERPRISE":
+ return Environment.enterprise
+ elif run_env == "DEV":
+ return Environment.local
+ else:
+ return Environment.production
+ os.environ["CODECOV_HOME"] = _get_current_folder()
+ some_dir = Path(os.getenv("CODECOV_HOME"))
+ if os.path.exists(some_dir / "src/is_enterprise"):
+ return Environment.enterprise
+ if os.getenv("CURRENT_ENVIRONMENT", "production") == "local":
+ return Environment.local
+ return Environment.production
+
+
+def get_external_dependencies_folder():
+ return get_config(
+ "services", "external_dependencies_folder", default="./external_deps"
+ )
diff --git a/apps/worker/helpers/exceptions.py b/apps/worker/helpers/exceptions.py
new file mode 100644
index 0000000000..ad1e71236f
--- /dev/null
+++ b/apps/worker/helpers/exceptions.py
@@ -0,0 +1,44 @@
+import shared.bots.exceptions
+
+RepositoryWithoutValidBotError = shared.bots.exceptions.RepositoryWithoutValidBotError
+OwnerWithoutValidBotError = shared.bots.exceptions.OwnerWithoutValidBotError
+RequestedGithubAppNotFound = shared.bots.exceptions.RequestedGithubAppNotFound
+NoConfiguredAppsAvailable = shared.bots.exceptions.NoConfiguredAppsAvailable
+
+
+class ReportExpiredException(Exception):
+ def __init__(self, message=None, filename=None) -> None:
+ super().__init__(message)
+ self.filename = filename
+
+
+class ReportEmptyError(Exception):
+ pass
+
+
+class CorruptRawReportError(Exception):
+ """Error indicated that report is somehow different than it should be
+
+ Notice that this error should not be used to replace `matches_content` logic on each processor.
+ For header/top-level or even deeper checks that are quick and O(1), the method
+ `matches_content` should be used. Its purpose is to quickly look at the file and try to
+ determine which processor can handle it.
+
+ This error is meant for when the report header/top-level information truly indicated the file
+ format was X and could be read by processorX, and then something deep down the file did not
+ properly match this file expected structure, and it this could not be checked beforehand
+ without doing some parsing as complete as the actual processing of the file
+
+ The an example of such logic, see `VOneProcessor`. It is impractical there to check every
+ file dict to see if any of them do not have the proper format
+
+ Attributes:
+ corruption_error (str): A short description of the unexpected issue
+ expected_format (str): What format the file was expcted to have. Can be an actual format
+ name, or some identifier for people to understand what is the right structure to follow
+ """
+
+ def __init__(self, expected_format: str, corruption_error: str):
+ super().__init__(expected_format, corruption_error)
+ self.expected_format = expected_format
+ self.corruption_error = corruption_error
diff --git a/apps/worker/helpers/github_installation.py b/apps/worker/helpers/github_installation.py
new file mode 100644
index 0000000000..202ede12df
--- /dev/null
+++ b/apps/worker/helpers/github_installation.py
@@ -0,0 +1,33 @@
+import logging
+
+from database.models.core import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ Owner,
+ OwnerInstallationNameToUseForTask,
+)
+
+log = logging.getLogger(__file__)
+
+
+def get_installation_name_for_owner_for_task(task_name: str, owner: Owner) -> str:
+ if owner.service not in ["github", "github_enterprise"]:
+ # The `installation` concept only exists in GitHub.
+ # We still return a default here, primarily to satisfy types.
+ return GITHUB_APP_INSTALLATION_DEFAULT_NAME
+
+ dbsession = owner.get_db_session()
+ config_for_owner = (
+ dbsession.query(OwnerInstallationNameToUseForTask)
+ .filter(
+ OwnerInstallationNameToUseForTask.task_name == task_name,
+ OwnerInstallationNameToUseForTask.ownerid == owner.ownerid,
+ )
+ .first()
+ )
+ if config_for_owner:
+ log.info(
+ "Owner has dedicated app for this task",
+ extra=dict(this_task=task_name, ownerid=owner.ownerid),
+ )
+ return config_for_owner.installation_name
+ return GITHUB_APP_INSTALLATION_DEFAULT_NAME
diff --git a/apps/worker/helpers/health_check.py b/apps/worker/helpers/health_check.py
new file mode 100644
index 0000000000..c762dfe31e
--- /dev/null
+++ b/apps/worker/helpers/health_check.py
@@ -0,0 +1,27 @@
+import logging
+
+from shared.config import get_config
+
+HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS = 10000
+
+logger = logging.getLogger(__name__)
+
+
+def get_health_check_interval_seconds():
+ try:
+ interval_config = int(
+ get_config(
+ "setup",
+ "tasks",
+ "healthcheck",
+ "interval_seconds",
+ default=HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS,
+ )
+ )
+ if interval_config >= 1:
+ return interval_config
+ except (ValueError, TypeError):
+ logger.warning(
+ "Invalid configuration for healthcheck interval. Using default value of 10s"
+ )
+ return HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS
diff --git a/apps/worker/helpers/labels.py b/apps/worker/helpers/labels.py
new file mode 100644
index 0000000000..0784409951
--- /dev/null
+++ b/apps/worker/helpers/labels.py
@@ -0,0 +1,73 @@
+from enum import Enum
+
+from shared.reports.resources import Report
+
+# The SpecialLabelsEnum enum is place to hold sentinels for labels with special
+# meanings
+# One example is CODECOV_ALL_LABELS_PLACEHOLDER: it's a sentinel for
+# "all the labels from this report apply here"
+# Imagine a suite, with many tests, that import a particular file
+# The imports all happen before any of the tests is executed
+# So on this imported file, there might be some global code
+# Because it's global, it runs during this import phase
+# So it's not attached directly to any test, because it ran
+# outside of any tests
+# But it is responsible for those tests in a way, since this global variable
+# is used in the functions themselves (which run during the tests). At least
+# there is no simple way to guarantee the global variable didn't affect
+# anything that imported that file
+# So, from the coverage perspective, this global-level line was an indirect
+# part of every test. So, in the end, if we see this constant in the report
+# datapoints, we will replace it with all the labels (tests) that we saw in
+# that report
+# This is what CODECOV_ALL_LABELS_PLACEHOLDER is
+
+
+class SpecialLabelsEnum(Enum):
+ CODECOV_ALL_LABELS_PLACEHOLDER = ("Th2dMtk4M_codecov", 0)
+
+ def __init__(self, label, index):
+ self.corresponding_label = label
+ self.corresponding_index = index
+
+
+def get_labels_per_session(report: Report, sess_id: int) -> set[str | int]:
+ """Returns a Set with the labels present in a session from report, EXCLUDING the SpecialLabel.
+
+ The return value can either be a set of strings (the labels themselves) OR
+ a set of ints (the label indexes). The label can be looked up from the index
+ using Report.lookup_label_by_id(label_id) (assuming Report._labels_idx is set)
+ """
+ all_labels: set[str | int] = set()
+
+ for file in report:
+ for _, line in file.lines:
+ if line.datapoints:
+ for datapoint in line.datapoints:
+ if datapoint.sessionid == sess_id:
+ all_labels.update(datapoint.label_ids or [])
+
+ return all_labels - {
+ SpecialLabelsEnum.CODECOV_ALL_LABELS_PLACEHOLDER.corresponding_label,
+ SpecialLabelsEnum.CODECOV_ALL_LABELS_PLACEHOLDER.corresponding_index,
+ }
+
+
+def get_all_report_labels(report: Report) -> set[str | int]:
+ """Returns a Set with the labels present in report EXCLUDING the SpecialLabel.
+
+ The return value can either be a set of strings (the labels themselves) OR
+ a set of ints (the label indexes). The label can be looked up from the index
+ using Report.lookup_label_by_id(label_id) (assuming Report._labels_idx is set)
+ """
+ all_labels: set[str | int] = set()
+ for file in report:
+ for _, line in file.lines:
+ if line.datapoints:
+ for datapoint in line.datapoints:
+ all_labels.update(datapoint.label_ids or [])
+
+ return all_labels - {
+ SpecialLabelsEnum.CODECOV_ALL_LABELS_PLACEHOLDER.corresponding_label,
+ SpecialLabelsEnum.CODECOV_ALL_LABELS_PLACEHOLDER.corresponding_index,
+ }
diff --git a/apps/worker/helpers/log_context.py b/apps/worker/helpers/log_context.py
new file mode 100644
index 0000000000..2a04c5586e
--- /dev/null
+++ b/apps/worker/helpers/log_context.py
@@ -0,0 +1,144 @@
+import contextvars
+import logging
+from dataclasses import asdict, dataclass, field, replace
+
+import sentry_sdk
+from sentry_sdk import get_current_span
+from shared.config import get_config
+
+from database.models.core import Owner, Repository
+
+log = logging.getLogger("log_context")
+
+
+@dataclass
+class LogContext:
+ """
+ Class containing all the information we may want to add in logs and metrics.
+ """
+
+ task_name: str = "???"
+ task_id: str = "???"
+
+ _populated_from_db = False
+ owner_username: str | None = None
+ owner_service: str | None = None
+ owner_plan: str | None = None
+ owner_id: int | None = None
+ repo_name: str | None = None
+ repo_id: int | None = None
+ commit_sha: str | None = None
+
+ # TODO: begin populating this again or remove it
+ # we can populate this again if we reduce query load by passing the Commit,
+ # Owner, and Repository models we use to populate the log context into the
+ # various task implementations so they don't fetch the same models again
+ commit_id: int | None = None
+
+ checkpoints_data: dict = field(default_factory=lambda: {})
+
+ @property
+ def sentry_trace_id(self):
+ if span := get_current_span():
+ return span.trace_id
+ return None
+
+ def as_dict(self):
+ d = asdict(self)
+ d.pop("_populated_from_db", None)
+ d["sentry_trace_id"] = self.sentry_trace_id
+ return d
+
+ def populate_from_sqlalchemy(self, dbsession):
+ """
+ Attempt to use the information we have to fill in other context fields. For
+ example, if we have `self.repo_id` but not `self.owner_id`, we can look up
+ the latter in the database.
+
+ Ignore exceptions; no need to fail a task for a missing context field.
+ """
+ if self._populated_from_db or not get_config(
+ "setup", "log_context", "populate", default=True
+ ):
+ return
+
+ try:
+ # - commit_id (or commit_sha + repo_id) is enough to get everything else
+ # - however, this slams the commit table and we rarely really need the
+ # DB PK for a commit, so we don't do this.
+ # - repo_id is enough to get repo and owner
+ # - owner_id is just enough to get owner
+
+ if self.repo_id:
+ query = (
+ dbsession.query(
+ Repository.name,
+ Owner.ownerid,
+ Owner.username,
+ Owner.service,
+ Owner.plan,
+ )
+ .join(Repository.owner)
+ .filter(Repository.repoid == self.repo_id)
+ )
+
+ (
+ self.repo_name,
+ self.owner_id,
+ self.owner_username,
+ self.owner_service,
+ self.owner_plan,
+ ) = query.first()
+
+ elif self.owner_id:
+ query = dbsession.query(
+ Owner.username, Owner.service, Owner.plan
+ ).filter(Owner.ownerid == self.owner_id)
+
+ (self.owner_username, self.owner_service, self.owner_plan) = (
+ query.first()
+ )
+
+ except Exception:
+ log.exception("Failed to populate log context")
+
+ self._populated_from_db = True
+
+ def add_to_log_record(self, log_record: dict):
+ d = self.as_dict()
+ d.pop("checkpoints_data", None)
+ log_record["context"] = d
+
+ def add_to_sentry(self):
+ d = self.as_dict()
+ d.pop("sentry_trace_id", None)
+ d.pop("checkpoints_data", None)
+ sentry_sdk.set_tags(d)
+
+
+_log_context = contextvars.ContextVar("log_context", default=LogContext())
+
+
+def set_log_context(context: LogContext):
+ """
+ Overwrite whatever is currently in the log context. Also sets tags in the
+ Sentry SDK appropriately.
+ """
+ _log_context.set(context)
+ context.add_to_sentry()
+
+
+def update_log_context(context: dict):
+ """
+ Add new fields to the log context without removing old ones.
+ """
+ current_context: LogContext = _log_context.get()
+ new_context = replace(current_context, **context)
+ set_log_context(new_context)
+
+
+def get_log_context() -> LogContext:
+ """
+ Access the log context.
+ """
+ return _log_context.get()
diff --git a/apps/worker/helpers/logging_config.py b/apps/worker/helpers/logging_config.py
new file mode 100644
index 0000000000..e61785036f
--- /dev/null
+++ b/apps/worker/helpers/logging_config.py
@@ -0,0 +1,93 @@
+import json
+from copy import deepcopy
+
+from pythonjsonlogger.jsonlogger import JsonFormatter
+
+from helpers.environment import Environment, get_current_env
+from helpers.log_context import get_log_context
+
+
+class BaseLogger(JsonFormatter):
+ def add_fields(self, log_record, record, message_dict) -> None:
+ super(BaseLogger, self).add_fields(log_record, record, message_dict)
+
+ log_context = get_log_context()
+ log_context.add_to_log_record(log_record)
+
+ def format_json_on_new_lines(self, json_str):
+ # Parse the input JSON string
+ data = json.loads(json_str)
+
+ for key, value in data.items():
+ if isinstance(value, list) and len(value) > 10:
+ # If more than 10 elements in a list, concat to single line
+ data[key] = ", ".join(map(str, value))
+
+ # Convert the parsed JSON data back to a formatted JSON string
+ formatted_json = json.dumps(data, indent=4)
+ return formatted_json
+
+
+class CustomLocalJsonFormatter(BaseLogger):
+ def jsonify_log_record(self, log_record) -> str:
+ """Returns a json string of the log record."""
+ levelname = log_record.pop("levelname")
+ message = log_record.pop("message")
+ exc_info = log_record.pop("exc_info", "")
+ content = super().jsonify_log_record(log_record)
+ formatted = super().format_json_on_new_lines(content) if content else None
+ if exc_info:
+ return f"{levelname}: {message} \n {formatted}\n{exc_info}"
+ return f"{levelname}: {message} \n {formatted}"
+
+
+class CustomDatadogJsonFormatter(BaseLogger):
+ def add_fields(self, log_record, record, message_dict):
+ super(CustomDatadogJsonFormatter, self).add_fields(
+ log_record, record, message_dict
+ )
+ if not log_record.get("logger.name") and log_record.get("name"):
+ log_record["logger.name"] = log_record.get("name")
+ if not log_record.get("logger.thread_name") and log_record.get("threadName"):
+ log_record["logger.thread_name"] = log_record.get("threadName")
+ if log_record.get("level"):
+ log_record["level"] = log_record["level"].upper()
+ else:
+ log_record["level"] = record.levelname
+
+
+config_dict = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "standard": {
+ "format": "%(message)s %(asctime)s %(name)s %(levelname)s %(lineno)s %(pathname)s %(funcName)s %(threadName)s",
+ "class": "helpers.logging_config.CustomLocalJsonFormatter",
+ },
+ "json": {
+ "format": "%(message)s %(asctime)s %(name)s %(levelname)s %(lineno)s %(pathname)s %(funcName)s %(threadName)s",
+ "class": "helpers.logging_config.CustomDatadogJsonFormatter",
+ },
+ },
+ "root": { # root logger
+ "handlers": ["default"],
+ "level": "INFO",
+ "propagate": True,
+ },
+ "handlers": {
+ "default": {
+ "level": "INFO",
+ "formatter": "json",
+ "class": "logging.StreamHandler",
+ "stream": "ext://sys.stdout", # Default is stderr
+ }
+ },
+ "loggers": {},
+}
+
+
+def get_logging_config_dict() -> dict:
+ res = deepcopy(config_dict)
+ if get_current_env() == Environment.local:
+ res["handlers"]["default"]["formatter"] = "standard"
+ return res
diff --git a/apps/worker/helpers/match.py b/apps/worker/helpers/match.py
new file mode 100644
index 0000000000..4d94c59a26
--- /dev/null
+++ b/apps/worker/helpers/match.py
@@ -0,0 +1,30 @@
+import re
+from typing import List, Optional
+
+
+def match(patterns: Optional[List[str]], string: str) -> bool:
+ if patterns is None or string in patterns:
+ return True
+
+ patterns = set(filter(None, patterns))
+ negatives = set(filter(lambda a: a.startswith(("^!", "!")), patterns))
+ positives = patterns - negatives
+
+ # must not match
+ for pattern in negatives:
+ # matched a negative search
+ if re.match(pattern.replace("!", ""), string):
+ return False
+
+ if positives:
+ for pattern in positives:
+ # match was found
+ if re.match(pattern, string):
+ return True
+
+ # did not match any required paths
+ return False
+
+ else:
+ # no positives: everyting else is ok
+ return True
diff --git a/apps/worker/helpers/metrics.py b/apps/worker/helpers/metrics.py
new file mode 100644
index 0000000000..44caad0fde
--- /dev/null
+++ b/apps/worker/helpers/metrics.py
@@ -0,0 +1,9 @@
+from statsd.defaults.env import statsd
+
+KiB = 1024
+MiB = KiB * KiB
+
+metrics = statsd
+
+# enumerates all the powers-of-two, from 8K all the way to 1G:
+BYTE_SIZE_BUCKETS = [2**power for power in range(13, 31)]
diff --git a/apps/worker/helpers/notifier.py b/apps/worker/helpers/notifier.py
new file mode 100644
index 0000000000..fa4a43b8f2
--- /dev/null
+++ b/apps/worker/helpers/notifier.py
@@ -0,0 +1,94 @@
+import logging
+from dataclasses import dataclass
+from enum import Enum
+from typing import Literal
+
+from asgiref.sync import async_to_sync
+from shared.torngit.base import TorngitBaseAdapter
+from shared.torngit.exceptions import TorngitClientError
+
+from database.models import Commit
+from services.repository import (
+ EnrichedPull,
+ fetch_and_update_pull_request_information_from_commit,
+ get_repo_provider_service,
+)
+from services.yaml import UserYaml
+
+log = logging.getLogger(__name__)
+
+
+class NotifierResult(Enum):
+ COMMENT_POSTED = "comment_posted"
+ TORNGIT_ERROR = "torngit_error"
+ NO_PULL = "no_pull"
+ NO_COMMENT = "no_comment"
+
+
+@dataclass
+class BaseNotifier:
+ commit: Commit
+ commit_yaml: UserYaml | None
+ _pull: EnrichedPull | None | Literal[False] = False
+ _repo_service: TorngitBaseAdapter | None = None
+
+ def get_pull(self, do_log=True) -> EnrichedPull | None:
+ repo_service = self.get_repo_service()
+
+ if self._pull is False:
+ self._pull = async_to_sync(
+ fetch_and_update_pull_request_information_from_commit
+ )(repo_service, self.commit, self.commit_yaml)
+
+ if self._pull is None and do_log:
+ log.info(
+ "Not notifying since there is no pull request associated with this commit",
+ extra=dict(commitid=self.commit.commitid),
+ )
+
+ return self._pull
+
+ def get_repo_service(self) -> TorngitBaseAdapter:
+ if self._repo_service is None:
+ self._repo_service = get_repo_provider_service(self.commit.repository)
+
+ return self._repo_service
+
+ def send_to_provider(self, pull: EnrichedPull, message: str) -> bool:
+ repo_service = self.get_repo_service()
+ assert repo_service
+
+ pullid = pull.database_pull.pullid
+ try:
+ comment_id = pull.database_pull.commentid
+ if comment_id:
+ async_to_sync(repo_service.edit_comment)(pullid, comment_id, message)
+ else:
+ res = async_to_sync(repo_service.post_comment)(pullid, message)
+ pull.database_pull.commentid = res["id"]
+ return True
+ except TorngitClientError:
+ log.error(
+ "Error creating/updating PR comment",
+ extra=dict(
+ commitid=self.commit.commitid,
+ pullid=pullid,
+ ),
+ )
+ return False
+
+ def build_message(self) -> str:
+ raise NotImplementedError
+
+ def notify(self) -> NotifierResult:
+ pull = self.get_pull()
+ if pull is None:
+ return NotifierResult.NO_PULL
+
+ message = self.build_message()
+
+ sent_to_provider = self.send_to_provider(pull, message)
+ if sent_to_provider == False:
+ return NotifierResult.TORNGIT_ERROR
+
+ return NotifierResult.COMMENT_POSTED
diff --git a/apps/worker/helpers/number.py b/apps/worker/helpers/number.py
new file mode 100644
index 0000000000..36ce841bcc
--- /dev/null
+++ b/apps/worker/helpers/number.py
@@ -0,0 +1,19 @@
+from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_EVEN, Decimal
+
+
+def precise_round(
+ number: Decimal, precision: int = 2, rounding: str = "down"
+) -> Decimal:
+ """
+ Helper function to do more precise rounding given a precision and rounding strategy.
+ :param number: Number to round
+ :param precision: The number of decimal places to round to
+ :param rounding: Rounding strategy to use, which can be "down", "up" or "nearest"
+ :return: The rounded number as a Decimal object
+ """
+ quantizer = Decimal("0.1") ** precision
+ if rounding == "up":
+ return number.quantize(quantizer, rounding=ROUND_CEILING)
+ if rounding == "down":
+ return number.quantize(quantizer, rounding=ROUND_FLOOR)
+ return number.quantize(quantizer, rounding=ROUND_HALF_EVEN)
diff --git a/apps/worker/helpers/pathmap.py b/apps/worker/helpers/pathmap.py
new file mode 100644
index 0000000000..24077620e0
--- /dev/null
+++ b/apps/worker/helpers/pathmap.py
@@ -0,0 +1,183 @@
+from difflib import SequenceMatcher
+from os.path import relpath
+from typing import Sequence
+
+
+def _clean_path(path):
+ path = relpath(
+ path.strip()
+ .replace("**/", "")
+ .replace("\r", "")
+ .replace("\\ ", " ")
+ .replace("\\", "/")
+ )
+ return path
+
+
+def _check_ancestors(path, match, ancestors):
+ """
+ Require N ancestors to be in common with original path and matched path
+ """
+ pl = path.lower()
+ ml = match.lower()
+ if pl == ml:
+ return True
+ if len(ml.split("/")) < len(pl.split("/")) and pl.endswith(ml):
+ return True
+ return ml.endswith("/".join(pl.split("/")[(ancestors + 1) * -1 :]))
+
+
+def _get_best_match(path: str, possibilities: list[str]) -> str:
+ """
+ Given a `path`, return the most similar one out of `possibilities`.
+ """
+
+ best_match = (-1.0, "")
+ for possibility in possibilities:
+ match = SequenceMatcher(None, path, possibility).ratio()
+ if match > best_match[0]:
+ best_match = (match, possibility)
+
+ return best_match[1]
+
+
+class Node:
+ full_paths: list[str]
+ """
+ The full paths terminating in this node.
+ """
+
+ children: dict[str, "Node"]
+ """
+ Child nodes, keyed by path component.
+ """
+
+ def __init__(self) -> None:
+ self.full_paths = []
+ self.children = {}
+
+
+class Tree:
+ """
+ This tree maintains a list of files and allows matching on them.
+
+ It internally organizes the list of files (called `paths`) as a tree of `Node`s.
+ The paths are split into path components in reverse order.
+ Lookups in the tree also happen in reverse path-component order.
+
+ For example, the following list of files:
+ - `src/foo/mod.rs`
+ - `src/foo/bar/mod.rs`
+
+ ... are organized in a tree that looks like this:
+ - mod.rs
+ - foo
+ - src => src/foo/mod.rs
+ - bar
+ - foo
+ - src => src/foo/bar/mod.rs
+
+ Using this tree, it is possible to look up paths like:
+ - `C:\\Users\\ci\\repo\\src\\foo\\mod.rs`
+
+ Matching / lookup again happens in reverse path-component order, from right to left.
+ In this particular case, the tree traversal would walk the tree `Node`s `mod.rs`, `foo`, `src`
+ before it hits the `src/foo/mod.rs` "full_path", which is the result of the lookup.
+ """
+
+ def __init__(self, paths: Sequence[str]):
+ self.root = Node()
+ for path in paths:
+ self.insert(path)
+
+ def insert(self, path: str):
+ # the path components, in reverse order
+ components = reversed(path.split("/"))
+
+ node = self.root
+ for component in components:
+ component = component.lower()
+ if component not in node.children:
+ node.children[component] = Node()
+ node = node.children[component]
+
+ node.full_paths.append(path)
+
+ def resolve_path(self, path: str, ancestors: int | None = None) -> str | None:
+ path = _clean_path(path)
+ new_path = self.lookup(path, ancestors)
+
+ if new_path:
+ if ancestors and not _check_ancestors(path, new_path, ancestors):
+ # path ancestor count is not valid
+ return None
+
+ return new_path
+
+ # path was not resolved
+ return None
+
+ def _drill(self, node: Node) -> list[str] | None:
+ """
+ "Drill down" a straight branch of a tree, returning the first `full_paths`.
+ """
+ while len(node.children) == 1:
+ node = next(iter(node.children.values()))
+ if len(node.full_paths):
+ return node.full_paths
+
+ return None
+
+ def _recursive_lookup(
+ self,
+ node: Node,
+ components: list[str],
+ results: list[str],
+ i=0,
+ end=False,
+ match=False,
+ ) -> list[str]:
+ """
+ Performs a lookup in tree recursively
+
+ :bool: end - Indicates if last lookup was the end of a sequence
+ :bool: match - Indicates if filename has any match in tree
+ """
+
+ child_node = (
+ node.children.get(components[i].lower()) if i < len(components) else None
+ )
+ if child_node:
+ is_end = len(child_node.full_paths) > 0
+ if is_end:
+ results = child_node.full_paths
+ return self._recursive_lookup(
+ child_node, components, results, i + 1, is_end, True
+ )
+ else:
+ if not end and match:
+ next_path = self._drill(node)
+ if next_path:
+ results.extend(next_path)
+ return results
+
+ def lookup(self, path: str, ancestors=None) -> str | None:
+ """
+ Lookup a path in the tree, returning the closest matching path
+ in the tree if found.
+ """
+ path_hit = None
+ components = list(reversed(path.split("/")))
+ results = self._recursive_lookup(self.root, components, [])
+ if not results:
+ return None
+ if len(results) == 1:
+ path_hit = results[0]
+ else:
+ if path.replace(".", "").startswith("/") and ancestors:
+ path_lengths = list(map(lambda x: len(x), results))
+ closest_length = min(path_lengths, key=lambda x: abs(x - ancestors))
+ path_hit = next(x for x in results if len(x) == closest_length)
+ else:
+ path_hit = _get_best_match(path, list(reversed(results)))
+ return path_hit
diff --git a/apps/worker/helpers/reports.py b/apps/worker/helpers/reports.py
new file mode 100644
index 0000000000..7bbb3ec25a
--- /dev/null
+++ b/apps/worker/helpers/reports.py
@@ -0,0 +1,17 @@
+from shared.config import get_config
+from shared.yaml import UserYaml
+
+from services.yaml.reader import read_yaml_field
+
+
+def get_totals_from_file_in_reports(report, path):
+ file = report.get(path)
+ return file.totals if file else None
+
+
+def delete_archive_setting(commit_yaml: UserYaml | dict) -> bool:
+ if get_config("services", "minio", "expire_raw_after_n_days"):
+ return True
+ return not read_yaml_field(
+ commit_yaml, ("codecov", "archive", "uploads"), _else=True
+ )
diff --git a/apps/worker/helpers/save_commit_error.py b/apps/worker/helpers/save_commit_error.py
new file mode 100644
index 0000000000..5943355d0f
--- /dev/null
+++ b/apps/worker/helpers/save_commit_error.py
@@ -0,0 +1,23 @@
+from database.models import Commit
+from database.models.core import CommitError
+
+
+def save_commit_error(commit: Commit, error_code, error_params=None):
+ db_session = commit.get_db_session()
+ error_exist = (
+ db_session.query(CommitError)
+ .filter_by(commit=commit, error_code=error_code)
+ .first()
+ )
+
+ if error_params is None:
+ error_params = {}
+
+ if not error_exist:
+ err = CommitError(
+ commit=commit,
+ error_code=error_code,
+ error_params=error_params,
+ )
+ db_session.add(err)
+ db_session.flush()
diff --git a/apps/worker/helpers/sentry.py b/apps/worker/helpers/sentry.py
new file mode 100644
index 0000000000..1bf9cee3fb
--- /dev/null
+++ b/apps/worker/helpers/sentry.py
@@ -0,0 +1,40 @@
+import os
+
+import sentry_sdk
+from sentry_sdk.integrations.celery import CeleryIntegration
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.httpx import HttpxIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
+from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
+from shared.config import get_config
+
+from helpers.version import get_current_version
+
+
+def is_sentry_enabled() -> bool:
+ return bool(get_config("services", "sentry", "server_dsn"))
+
+
+def initialize_sentry() -> None:
+ version = get_current_version()
+ version_str = f"worker-{version}"
+ sentry_dsn = get_config("services", "sentry", "server_dsn")
+ sentry_sdk.init(
+ sentry_dsn,
+ sample_rate=float(os.getenv("SENTRY_PERCENTAGE", 1.0)),
+ environment=os.getenv("DD_ENV", "production"),
+ traces_sample_rate=float(os.environ.get("SERVICES__SENTRY__SAMPLE_RATE", 1)),
+ profiles_sample_rate=float(
+ os.environ.get("SERVICES__SENTRY__PROFILES_SAMPLE_RATE", 1)
+ ),
+ integrations=[
+ CeleryIntegration(monitor_beat_tasks=True),
+ DjangoIntegration(signals_spans=False),
+ SqlalchemyIntegration(),
+ RedisIntegration(cache_prefixes=["cache:"]),
+ HttpxIntegration(),
+ ],
+ release=os.getenv("SENTRY_RELEASE", version_str),
+ )
+ if os.getenv("CLUSTER_ENV"):
+ sentry_sdk.set_tag("cluster", os.getenv("CLUSTER_ENV"))
diff --git a/apps/worker/helpers/string.py b/apps/worker/helpers/string.py
new file mode 100644
index 0000000000..f699e1fd70
--- /dev/null
+++ b/apps/worker/helpers/string.py
@@ -0,0 +1,120 @@
+from dataclasses import dataclass
+from enum import Enum
+from typing import List
+
+import regex
+
+
+class EscapeEnum(Enum):
+ APPEND = "append"
+ PREPEND = "prepend"
+ REPLACE = "replace"
+
+
+@dataclass
+class Replacement:
+ strings: List[str]
+ output: str
+ method: EscapeEnum
+
+
+class StringEscaper:
+ """
+ Class to use to escape strings using format defined
+ through a dict.
+
+ Args:
+ escape_def: list of Replacement that defines how to escape
+ characters
+
+ string is escaped by applying method in each Replacement
+ to each char in Replacement.chars using the char in output
+
+ for example:
+ escape_def = [
+ Replacement(["1"], "2", EscapeEnum.APPEND),
+ Replacement(["3"], "4", EscapeEnum.PREPEND),
+ Replacement(["5", "6"], "6", EscapeEnum.REPLACE),
+ ]
+
+ escaper = StringEscaper(escape_def)
+
+ escaper.replace("123456")
+
+ will give: "12243466"
+ """
+
+ def __init__(self, escape_def: List[Replacement]):
+ self.escape_def = escape_def
+
+ def replace(self, replacement_target):
+ for replacement in self.escape_def:
+ for string in replacement.strings:
+ if replacement.method == EscapeEnum.PREPEND:
+ replacement_target = replacement_target.replace(
+ string, f"{replacement.output}{string}"
+ )
+ elif replacement.method == EscapeEnum.APPEND:
+ replacement_target = replacement_target.replace(
+ string, f"{string}{replacement.output}"
+ )
+ elif replacement.method == EscapeEnum.REPLACE:
+ replacement_target = replacement_target.replace(
+ string, replacement.output
+ )
+ return replacement_target
+
+
+MAX_PATH_COMPONENTS = 3
+
+
+# matches file paths with an optional line number and column at the end:
+# /Users/josephsawaya/dev/test-result-action/demo/calculator/calculator.test.ts:10:31
+# /Users/josephsawaya/dev/test-result-action/demo/calculator/calculator.test.ts
+# Users/josephsawaya/dev/test-result-action/demo/calculator/calculator.test.ts
+file_path_regex = regex.compile(
+ r"((\/*[\w\-]+\/)+([\w\.]+)(:\d+:\d+)*)",
+)
+
+
+def shorten_file_paths(string):
+ """
+ This function takes in a string and returns it with all the paths
+ it contains longer than 3 components shortened to 3 components
+
+ Example:
+ string = '''
+ Expected: 1
+ Received: -1
+ at Object.<anonymous> (/Users/josephsawaya/dev/test-result-action/demo/calculator/calculator.test.ts:10:31)
+ at Promise.then.completed (/Users/josephsawaya/dev/test-result-action/node_modules/jest-circus/build/utils.js:298:28)
+ '''
+ shortened_string = shorten_file_paths(string)
+ print(shortened_string)
+
+ will print:
+ Expected: 1
+ Received: -1
+ at Object.<anonymous> (.../demo/calculator/calculator.test.ts:10:31)
+ at Promise.then.completed (.../jest-circus/build/utils.js:298:28)
+ """
+
+ matches = file_path_regex.findall(string)
+ for match_tuple in matches:
+ file_path = match_tuple[0]
+ split_file_path = file_path.split("/")
+
+ # if the file_path has more than 3 components we should shorten it
+ if len(split_file_path) > MAX_PATH_COMPONENTS:
+ last_path_components = split_file_path[-MAX_PATH_COMPONENTS:]
+ no_dots_shortened_file_path = "/".join(last_path_components)
+
+ # possibly remove leading / because we're adding it with the dots
+ if no_dots_shortened_file_path.startswith("/"):
+ no_dots_shortened_file_path = no_dots_shortened_file_path[1:]
+
+ shortened_path = ".../" + no_dots_shortened_file_path
+
+ string = string.replace(file_path, shortened_path)
+
+ return string
diff --git a/apps/worker/helpers/tests/pathmap/__init__.py b/apps/worker/helpers/tests/pathmap/__init__.py
new file mode 100644
index 0000000000..40a96afc6f
--- /dev/null
+++ b/apps/worker/helpers/tests/pathmap/__init__.py
@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-
diff --git a/apps/worker/helpers/tests/pathmap/test_pathmap.py b/apps/worker/helpers/tests/pathmap/test_pathmap.py
new file mode 100644
index 0000000000..6e8625d06b
--- /dev/null
+++ b/apps/worker/helpers/tests/pathmap/test_pathmap.py
@@ -0,0 +1,144 @@
+from helpers.pathmap import Tree, _check_ancestors, _clean_path
+
+
+def test_clean_path():
+ path = "**/some/directory"
+ assert _clean_path(path) == "some/directory"
+ path = "some/path\r/with/tabs\r"
+ assert _clean_path(path) == "some/path/with/tabs"
+ path = "some\\ very_long/directory\\ name"
+ assert _clean_path(path) == "some very_long/directory name"
+ path = "ms\\style\\directory"
+ assert _clean_path(path) == "ms/style/directory"
+
+
+def test_resolve_path():
+ tree = Tree(["src/components/login.js"])
+
+ assert tree.resolve_path("Src/components/login.js") == "src/components/login.js"
+
+
+def test_resolve_case():
+ tree = Tree(["Aa/Bb/cc", "Aa/Bb/Cc"])
+ assert tree.resolve_path("aa/bb/cc") == "Aa/Bb/cc"
+ assert tree.resolve_path("aa/bb/Cc") == "Aa/Bb/Cc"
+
+
+def test_resolve_paths():
+ before = [
+ "not/found.py",
+ "/Users/user/owner/repo/src/components/login.js",
+ "site-packages/package/__init__.py",
+ "path.py",
+ "a/b/../Path With\\ Space",
+ ]
+
+ after = [
+ None,
+ "src/components/login.js",
+ "package/__init__.py",
+ "path.py",
+ "a/Path With Space",
+ ]
+
+ tree = Tree([path for path in after if path])
+ for path, expected in zip(before, after):
+ assert tree.resolve_path(path) == expected
+
+
+def test_resolve_path_when_to_short():
+ tree = Tree(["a/b/c"])
+ assert tree.resolve_path("b/c", 0) == "a/b/c"
+ assert tree.resolve_path("b/c", 1) == "a/b/c"
+
+
+def test_resolve_path_when_to_long():
+ tree = Tree(["a/b/c"])
+ assert tree.resolve_path("z/y/b/c", 1) == "a/b/c"
+
+
+def test_check_ancestors():
+ assert _check_ancestors("a", "a", 1) is True, "matches"
+ assert _check_ancestors("A", "a", 1) is True, "matches case insensative"
+ assert _check_ancestors("a/B", "a/B", 1) is True, "matches"
+ assert _check_ancestors("A/B", "a/b", 1) is True, "matches case insensative"
+ assert _check_ancestors("b/b", "a/b", 1) is False, "does not match first ancestor"
+ assert _check_ancestors("a/b/c", "x/b/c", 1) is True
+ assert _check_ancestors("a/b/c", "x/b/c", 2) is False
+ assert _check_ancestors("a/b/c/d", "X/B/C/D", 2) is True
+ assert _check_ancestors("a", "b/a", 2) is True, "original was missing ancestors"
+ assert _check_ancestors("a/b", "z/a/b", 2) is True
+ assert _check_ancestors("b", "a/b", 1) is True
+
+
+def test_resolve_paths_with_ancestors():
+ tree = Tree(["x/y/z"])
+
+ # default, no ancestors ============================
+ paths = ["z", "R/z", "R/y/z", "x/y/z", "w/x/y/z"]
+ expected = ["x/y/z", "x/y/z", "x/y/z", "x/y/z", "x/y/z"]
+ resolved = [tree.resolve_path(path) for path in paths]
+ assert resolved == expected
+
+ # one ancestors ====================================
+ paths = ["z", "R/z", "R/y/z", "x/y/z", "w/x/y/z"]
+ expected = [None, None, "x/y/z", "x/y/z", "x/y/z"]
+ resolved = [tree.resolve_path(path, 1) for path in paths]
+ assert set(resolved) == set(expected)
+
+ # two ancestors ====================================
+ paths = ["z", "R/z", "R/y/z", "x/y/z", "w/x/y/z"]
+ expected = [None, None, None, "x/y/z", "x/y/z"]
+ resolved = [tree.resolve_path(path, 2) for path in paths]
+ assert set(resolved) == set(expected)
+
+
+def test_resolving():
+ tree = Tree(["a/b/c", "a/r/c", "c"])
+ assert tree.resolve_path("r/c", 1) == "a/r/c"
+ assert tree.resolve_path("r/c") == "a/r/c"
+
+ tree = Tree(["a/b", "a/b/c/d", "x/y"])
+ assert tree.resolve_path("c/d", 1) == "a/b/c/d"
+
+
+def test_with_plus():
+ tree = Tree(["b+c"])
+ assert tree.resolve_path("b+c") == "b+c"
+
+ tree = Tree(["a/b+c"])
+ assert tree.resolve_path("b+c") == "a/b+c"
+
+
+def test_case_sensitive_ancestors():
+ tree = Tree(["src/HeapDump/GCHeapDump.cs"])
+ path = "C:/projects/perfview/src/heapDump/GCHeapDump.cs"
+ new_path = tree.resolve_path(path, 1)
+ assert new_path == "src/HeapDump/GCHeapDump.cs"
+
+
+def test_path_should_not_resolve():
+ tree = Tree(["four/six/three.py"])
+ assert tree.resolve_path("four/six/seven.py") is None
+
+
+def test_path_should_not_resolve_case_insensative():
+ tree = Tree(["a/b/C"])
+ assert tree.resolve_path("a/B/c") == "a/b/C"
+
+
+def test_ancestors_original_missing():
+ tree = Tree(["shorter.h"])
+ assert tree.resolve_path("a/long/path/shorter.h", 1) == "shorter.h"
+
+
+def test_ancestors_absolute_path():
+ tree = Tree(
+ [
+ "examples/ChurchNumerals.scala",
+ "tests/src/test/scala/at/logic/gapt/examples/ChurchNumerals.scala",
+ ]
+ )
+ path = "/home/travis/build/gapt/gapt/examples/ChurchNumerals.scala"
+
+ assert tree.resolve_path(path, 1) == "examples/ChurchNumerals.scala"
diff --git a/apps/worker/helpers/tests/pathmap/test_tree.py b/apps/worker/helpers/tests/pathmap/test_tree.py
new file mode 100644
index 0000000000..ec22b88adc
--- /dev/null
+++ b/apps/worker/helpers/tests/pathmap/test_tree.py
@@ -0,0 +1,43 @@
+from helpers.pathmap import Tree, _get_best_match
+
+
+def test_get_best_match():
+ path = "a/bB.py"
+ possibilities = ["c/bB.py", "d/Bb.py"]
+
+ assert _get_best_match(path, possibilities) == "c/bB.py"
+
+
+def test_drill():
+ tree = Tree(["a/b/c"])
+ assert tree._drill(tree.root) == ["a/b/c"]
+
+
+def test_drill_multiple_possible_paths():
+ tree = Tree(["src/list.rs", "benches/list.rs"])
+
+ branch = tree.root.children.get("list.rs")
+ assert tree._drill(branch) is None
+
+
+def test_recursive_lookup():
+ path = "one/two/three.py"
+
+ tree = Tree([path])
+
+ path_split = list(reversed(path.split("/")))
+ match = tree._recursive_lookup(tree.root, path_split, [])
+
+ assert match == ["one/two/three.py"]
+
+ path = "four/five/three.py"
+ path_split = list(reversed(path.split("/")))
+ match = tree._recursive_lookup(tree.root, path_split, [])
+
+ assert match == ["one/two/three.py"]
+
+
+def test_lookup():
+ tree = Tree(["one/two/three.py"])
+
+ assert tree.lookup("two/one/three.py") == "one/two/three.py"
diff --git a/apps/worker/helpers/tests/unit/test_checkpoint_logger.py b/apps/worker/helpers/tests/unit/test_checkpoint_logger.py
new file mode 100644
index 0000000000..1b95f19f71
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_checkpoint_logger.py
@@ -0,0 +1,671 @@
+import json
+import unittest
+from enum import auto
+from unittest.mock import patch
+
+import pytest
+from prometheus_client import REGISTRY
+
+from helpers.checkpoint_logger import (
+ BaseFlow,
+ _get_milli_timestamp,
+ failure_events,
+ from_kwargs,
+ reliability_counters,
+ subflows,
+ success_events,
+)
+from helpers.log_context import LogContext, set_log_context
+from rollouts import CHECKPOINT_ENABLED_REPOSITORIES
+
+
+class CounterAssertion:
+ def __init__(self, metric, labels, expected_value):
+ self.metric = metric
+ self.labels = labels
+ self.expected_value = expected_value
+
+ self.before_value = None
+ self.after_value = None
+
+ def __repr__(self):
+ return f""
+
+
+class CounterAssertionSet:
+ def __init__(self, counter_assertions):
+ self.counter_assertions = counter_assertions
+
+ def __enter__(self):
+ for assertion in self.counter_assertions:
+ assertion.before_value = (
+ REGISTRY.get_sample_value(assertion.metric, labels=assertion.labels)
+ or 0
+ )
+
+ def __exit__(self, exc_type, exc_value, exc_tb):
+ for assertion in self.counter_assertions:
+ assertion.after_value = (
+ REGISTRY.get_sample_value(assertion.metric, labels=assertion.labels)
+ or 0
+ )
+ assert (
+ assertion.after_value - assertion.before_value
+ == assertion.expected_value
+ )
+
+
+@failure_events("BRANCH_1_FAIL")
+@success_events("BRANCH_1_SUCCESS", "BRANCH_2_SUCCESS")
+@subflows(
+ ("first_checkpoint", "BEGIN", "CHECKPOINT"),
+ ("branch_1_to_finish", "BRANCH_1", "BRANCH_1_SUCCESS"),
+ ("total_branch_1_time", "BEGIN", "BRANCH_1_SUCCESS"),
+ ("total_branch_1_fail_time", "BEGIN", "BRANCH_1_FAIL"),
+)
+@reliability_counters
+class DecoratedEnum(BaseFlow):
+ BEGIN = auto()
+ CHECKPOINT = auto()
+ BRANCH_1 = auto()
+ BRANCH_1_FAIL = auto()
+ BRANCH_1_SUCCESS = auto()
+ BRANCH_2 = auto()
+ BRANCH_2_SUCCESS = auto()
+
+
+class TestEnum1(BaseFlow):
+ A = auto()
+ B = auto()
+ C = auto()
+
+
+class TestEnum2(BaseFlow):
+ D = auto()
+ E = auto()
+ F = auto()
+
+
+class SortOrderEnum(BaseFlow):
+ C = auto()
+ B = auto()
+ A = auto()
+
+
+class TestCheckpointLogger(unittest.TestCase):
+ @pytest.fixture(scope="function", autouse=True)
+ def inject_mocker(request, mocker):
+ request.mocker = mocker
+
+ def setUp(self):
+ set_log_context(LogContext())
+
+ @patch("time.time_ns", return_value=123456789)
+ def test_get_milli_timestamp(self, mocker):
+ expected_ms = 123456789 // 1000000
+ self.assertEqual(_get_milli_timestamp(), expected_ms)
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", return_value=1337)
+ def test_log_checkpoint(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+
+ self.assertEqual(TestEnum1._data_from_log_context()[TestEnum1.A], 1337)
+
+ @patch(
+ "helpers.checkpoint_logger._get_milli_timestamp",
+ side_effect=[1337, 9001, 100000],
+ )
+ def test_log_multiple_checkpoints(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+ TestEnum1.log(TestEnum1.B)
+ TestEnum1.log(TestEnum1.C)
+
+ data = TestEnum1._data_from_log_context()
+ self.assertEqual(data[TestEnum1.A], 1337)
+ self.assertEqual(data[TestEnum1.B], 9001)
+ self.assertEqual(data[TestEnum1.C], 100000)
+
+ def test_log_checkpoint_twice_throws(self):
+ TestEnum1.log(TestEnum1.A)
+
+ with self.assertRaises(ValueError):
+ TestEnum1.log(TestEnum1.A, strict=True)
+
+ def test_log_checkpoint_wrong_enum_throws(self) -> None:
+ with self.assertRaises(ValueError):
+ TestEnum1.log(TestEnum2.D, strict=True) # type: ignore[arg-type]
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337, 9001])
+ def test_subflow_duration(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+ TestEnum1.log(TestEnum1.B)
+
+ data = TestEnum1._data_from_log_context()
+ duration = TestEnum1._subflow_duration(TestEnum1.A, TestEnum1.B, data=data)
+ self.assertEqual(duration, 9001 - 1337)
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337, 9001])
+ def test_subflow_duration_missing_checkpoints(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+ TestEnum1.log(TestEnum1.C)
+ data = TestEnum1._data_from_log_context()
+
+ # Missing end checkpoint
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum1.A, TestEnum1.B, data=data, strict=True
+ )
+
+ # Missing start checkpoint
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum1.B, TestEnum1.C, data=data, strict=True
+ )
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337, 9001])
+ def test_subflow_duration_wrong_order(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+ TestEnum1.log(TestEnum1.B)
+ data = TestEnum1._data_from_log_context()
+
+ # End < start
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum1.B, TestEnum1.A, data=data, strict=True
+ )
+
+ # End == start
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum1.A, TestEnum1.A, data=data, strict=True
+ )
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", return_value=1337)
+ def test_subflow_duration_wrong_enum(self, mocker):
+ TestEnum1.log(TestEnum1.A)
+ data = TestEnum1._data_from_log_context()
+
+ # Wrong enum for start checkpoint
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum2.D, TestEnum1.A, data=data, strict=True
+ )
+
+ # Wrong enum for end checkpoint
+ with self.assertRaises(ValueError):
+ TestEnum1._subflow_duration(
+ TestEnum1.A, TestEnum2.D, data=data, strict=True
+ )
+
+ @pytest.mark.real_checkpoint_logger
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337, 9001])
+ @patch("sentry_sdk.set_measurement")
+ def test_submit_subflow(self, mock_sentry, mock_timestamp):
+ TestEnum1.log(TestEnum1.A)
+ TestEnum1.log(TestEnum1.B)
+ data = TestEnum1._data_from_log_context()
+
+ expected_duration = 9001 - 1337
+ TestEnum1.submit_subflow("metricname", TestEnum1.A, TestEnum1.B, data=data)
+ mock_sentry.assert_called_with("metricname", expected_duration, "milliseconds")
+ assert (
+ REGISTRY.get_sample_value(
+ "worker_checkpoints_subflow_duration_seconds_sum",
+ labels={"flow": "TestEnum1", "subflow": "metricname"},
+ )
+ == expected_duration / 1000
+ )
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337])
+ def test_log_ignore_repeat(self, mock_timestamp):
+ TestEnum1.log(TestEnum1.A)
+ time = TestEnum1._data_from_log_context()[TestEnum1.A]
+
+ TestEnum1.log(TestEnum1.A, ignore_repeat=True)
+ assert TestEnum1._data_from_log_context()[TestEnum1.A] == time
+
+ def test_create_from_kwargs(self):
+ good_data = {
+ TestEnum1.A: 1337,
+ TestEnum1.B: 9001,
+ }
+ deserialized_good_data = json.loads(json.dumps(good_data))
+ good_kwargs = {
+ "checkpoints_TestEnum1": deserialized_good_data,
+ }
+ from_kwargs([TestEnum1], good_kwargs, strict=True)
+ assert TestEnum1._data_from_log_context() == good_data
+
+ # Data is from TestEnum2 but we expected TestEnum1
+ bad_data = {
+ TestEnum2.D: 1337,
+ TestEnum2.E: 9001,
+ }
+ deserialized_bad_data = json.loads(json.dumps(bad_data))
+ bad_kwargs = {
+ "checkpoints_TestEnum1": deserialized_bad_data,
+ }
+ with self.assertRaises(ValueError):
+ checkpoints = from_kwargs([TestEnum1], bad_kwargs, strict=True)
+
+ from_kwargs([TestEnum1], bad_kwargs, strict=False)
+ assert TestEnum1._data_from_log_context() == {}
+
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[1337, 9001])
+ def test_log_to_kwargs(self, mock_timestamp):
+ kwargs = {}
+
+ TestEnum1.log(TestEnum1.A, kwargs=kwargs)
+ assert "checkpoints_TestEnum1" in kwargs
+ assert kwargs["checkpoints_TestEnum1"][TestEnum1.A] == 1337
+ assert TestEnum1.B not in kwargs["checkpoints_TestEnum1"]
+
+ TestEnum1.log(TestEnum1.B, kwargs=kwargs)
+ assert "checkpoints_TestEnum1" in kwargs
+ assert kwargs["checkpoints_TestEnum1"][TestEnum1.A] == 1337
+ assert kwargs["checkpoints_TestEnum1"][TestEnum1.B] == 9001
+
+ @pytest.mark.real_checkpoint_logger
+ @patch("sentry_sdk.set_measurement")
+ @patch("helpers.checkpoint_logger._get_milli_timestamp", side_effect=[9001])
+ def test_create_log_oneliner(self, mock_timestamp, mock_sentry):
+ kwargs = {
+ "checkpoints_TestEnum1": {
+ TestEnum1.A: 1337,
+ },
+ }
+
+ expected_duration = 9001 - 1337
+
+ from_kwargs([TestEnum1], kwargs, strict=True)
+ TestEnum1.log(TestEnum1.B, kwargs=kwargs)
+ data = TestEnum1._data_from_log_context()
+ TestEnum1.submit_subflow("x", TestEnum1.A, TestEnum1.B, data=data)
+
+ mock_sentry.assert_called_with("x", expected_duration, "milliseconds")
+ assert kwargs["checkpoints_TestEnum1"][TestEnum1.A] == 1337
+ assert kwargs["checkpoints_TestEnum1"][TestEnum1.B] == 9001
+
+ def test_log_before_beginning_throws(self):
+ with self.assertRaises(ValueError):
+ DecoratedEnum.log(DecoratedEnum.BRANCH_1_SUCCESS, strict=True)
+
+ def test_log_after_end_throws(self):
+ DecoratedEnum.log(DecoratedEnum.BEGIN)
+ DecoratedEnum.log(DecoratedEnum.BRANCH_1_SUCCESS)
+
+ with self.assertRaises(ValueError):
+ DecoratedEnum.log(DecoratedEnum.BRANCH_1_FAIL, strict=True)
+
+ def test_success_failure_decorators(self):
+ for val in DecoratedEnum.__members__.values():
+ if val in [DecoratedEnum.BRANCH_1_SUCCESS, DecoratedEnum.BRANCH_2_SUCCESS]:
+ assert val.is_success()
+ else:
+ assert not val.is_success()
+
+ if val in [DecoratedEnum.BRANCH_1_FAIL]:
+ assert val.is_failure()
+ else:
+ assert not val.is_failure()
+
+ def test_subflows_decorator(self):
+ subflows = DecoratedEnum._subflows()
+
+ # No subflows end at these checkpoints
+ assert DecoratedEnum.BEGIN not in subflows
+ assert DecoratedEnum.BRANCH_1 not in subflows
+ assert DecoratedEnum.BRANCH_2 not in subflows
+
+ # `DecoratedEnum.CHECKPOINT` is not a terminal event, but we explicitly
+ # defined a subflow ending there.
+ checkpoint_subflows = subflows.get(DecoratedEnum.CHECKPOINT)
+ assert checkpoint_subflows is not None
+ assert len(checkpoint_subflows) == 1
+ assert checkpoint_subflows[0] == ("first_checkpoint", DecoratedEnum.BEGIN)
+
+ # All terminal events should have a subflow defined for them which
+ # begins at the flow's first event. `BRANCH_1_FAIL` has had this
+ # subflow provided by the user, so we should use the user's name.
+ branch_1_fail_subflows = subflows.get(DecoratedEnum.BRANCH_1_FAIL)
+ assert branch_1_fail_subflows is not None
+ assert len(branch_1_fail_subflows) == 1
+ assert branch_1_fail_subflows[0] == (
+ "total_branch_1_fail_time",
+ DecoratedEnum.BEGIN,
+ )
+
+ # All terminal events should have a subflow defined for them which
+ # begins at the flow's first event. `BRANCH_1_SUCCESS` has had this
+ # subflow provided by the user, so we should use the user's name.
+ # Also, `BRANCH_1_SUCCESS` is the end of a second subflow. Ensure that
+ # both subflows are present.
+ branch_1_success_subflows = subflows.get(DecoratedEnum.BRANCH_1_SUCCESS)
+ assert branch_1_success_subflows is not None
+ assert len(branch_1_success_subflows) == 2
+ assert ("total_branch_1_time", DecoratedEnum.BEGIN) in branch_1_success_subflows
+ assert (
+ "branch_1_to_finish",
+ DecoratedEnum.BRANCH_1,
+ ) in branch_1_success_subflows
+
+ # All terminal events should have a subflow defined for them which
+ # begins at the flow's first event. `BRANCH_2_SUCCESS` has not had this
+ # subflow provided by the user, so we should use the default name.
+ branch_2_success_subflows = subflows.get(DecoratedEnum.BRANCH_2_SUCCESS)
+ assert branch_2_success_subflows is not None
+ assert len(branch_2_success_subflows) == 1
+ assert branch_2_success_subflows[0] == (
+ "DecoratedEnum_BEGIN_to_BRANCH_2_SUCCESS",
+ DecoratedEnum.BEGIN,
+ )
+
+ @patch.object(CHECKPOINT_ENABLED_REPOSITORIES, "check_value", return_value=123)
+ def test_reliability_counters_with_context(self, mock_object):
+ repoid = 123
+ mock_object.return_value = repoid
+ set_log_context(LogContext(repo_id=repoid))
+
+ counter_assertions = [
+ CounterAssertion(
+ "worker_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_begun_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BEGIN"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BEGIN", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_succeeded_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_succeeded_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_failed_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_failed_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_ended_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_ended_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ ]
+ with CounterAssertionSet(counter_assertions) as a:
+ DecoratedEnum.log(DecoratedEnum.BEGIN)
+
+ # Nothing special about `CHECKPOINT` - no counters should change
+ counter_assertions = [
+ CounterAssertion(
+ "worker_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_begun_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BEGIN"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BEGIN", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "CHECKPOINT"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {
+ "flow": "DecoratedEnum",
+ "checkpoint": "CHECKPOINT",
+ "repoid": f"{repoid}",
+ },
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_succeeded_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_succeeded_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_failed_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_failed_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_ended_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_reopo_checkpoints_ended_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ ]
+ with CounterAssertionSet(counter_assertions):
+ DecoratedEnum.log(DecoratedEnum.CHECKPOINT)
+
+ # Failures should increment both `failed` and `ended`
+ counter_assertions = [
+ CounterAssertion(
+ "worker_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_begun_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_succeeded_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_succeeded_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_failed_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_failed_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_ended_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_ended_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BRANCH_1_FAIL"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {
+ "flow": "DecoratedEnum",
+ "checkpoint": "BRANCH_1_FAIL",
+ "repoid": f"{repoid}",
+ },
+ 1,
+ ),
+ ]
+ with CounterAssertionSet(counter_assertions):
+ DecoratedEnum.log(DecoratedEnum.BRANCH_1_FAIL)
+
+ # Because we've logged a terminal event, we have to restart the flow
+ set_log_context(LogContext(repo_id=repoid))
+ DecoratedEnum.log(DecoratedEnum.BEGIN)
+
+ # Successes should increment both `succeeded` and `ended`
+ counter_assertions = [
+ CounterAssertion(
+ "worker_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_checkpoints_succeeded_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_succeeded_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_failed_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_failed_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_ended_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_ended_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BRANCH_1_SUCCESS"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {
+ "flow": "DecoratedEnum",
+ "checkpoint": "BRANCH_1_SUCCESS",
+ "repoid": f"{repoid}",
+ },
+ 1,
+ ),
+ ]
+ with CounterAssertionSet(counter_assertions):
+ DecoratedEnum.log(DecoratedEnum.BRANCH_1_SUCCESS)
+
+ # Because we've logged a terminal event, we have to restart the flow
+ set_log_context(LogContext(repo_id=repoid))
+ DecoratedEnum.log(DecoratedEnum.BEGIN)
+
+ # A different success path should also increment `succeeded` and `ended`
+ counter_assertions = [
+ CounterAssertion(
+ "worker_checkpoints_begun_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_begun_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_succeeded_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_succeeded_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_failed_total", {"flow": "DecoratedEnum"}, 0
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_failed_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 0,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_ended_total", {"flow": "DecoratedEnum"}, 1
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_ended_total",
+ {"flow": "DecoratedEnum", "repoid": f"{repoid}"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_checkpoints_events_total",
+ {"flow": "DecoratedEnum", "checkpoint": "BRANCH_2_SUCCESS"},
+ 1,
+ ),
+ CounterAssertion(
+ "worker_repo_checkpoints_events_total",
+ {
+ "flow": "DecoratedEnum",
+ "checkpoint": "BRANCH_2_SUCCESS",
+ "repoid": f"{repoid}",
+ },
+ 1,
+ ),
+ ]
+ with CounterAssertionSet(counter_assertions):
+ DecoratedEnum.log(DecoratedEnum.BRANCH_2_SUCCESS)
+
+ def test_serialize_between_tasks(self):
+ """
+ BaseFlow must inherit from str in order for our checkpoints dict to be
+ readily serializable to JSON for passing between celery tasks.
+
+ We encode the flow name and checkpoint name in the string value so that
+ we can validate when we deserialize. Make sure that all works.
+ """
+ original = {
+ TestEnum1.A: 1337,
+ TestEnum1.B: 9001,
+ }
+ serialized = json.dumps(original)
+ deserialized = json.loads(serialized)
+
+ assert serialized == '{"A": 1337, "B": 9001}'
+ assert deserialized == {
+ "A": 1337,
+ "B": 9001,
+ }
+
+ def test_sort_order(self):
+ assert TestEnum1.A == TestEnum1.A
+ assert TestEnum1.A < TestEnum1.B
+ assert TestEnum1.C > TestEnum1.B
+ assert SortOrderEnum.C < SortOrderEnum.B
+ assert SortOrderEnum.A > SortOrderEnum.B
+
+ SortOrderEnum.log(SortOrderEnum.C)
+ SortOrderEnum.log(SortOrderEnum.B)
+ data = SortOrderEnum._data_from_log_context()
+ SortOrderEnum._subflow_duration(SortOrderEnum.C, SortOrderEnum.B, data=data)
diff --git a/apps/worker/helpers/tests/unit/test_clock.py b/apps/worker/helpers/tests/unit/test_clock.py
new file mode 100644
index 0000000000..280299f442
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_clock.py
@@ -0,0 +1,36 @@
+from datetime import datetime, timezone
+
+import pytest
+from freezegun import freeze_time
+
+from helpers.clock import (
+ get_seconds_to_next_hour,
+ get_utc_now,
+ get_utc_now_as_iso_format,
+)
+
+
+def test_get_utc_now():
+ res = get_utc_now()
+ assert isinstance(res, datetime)
+ assert res.tzinfo == timezone.utc
+
+
+def test_get_utc_now_as_iso_format():
+ res = get_utc_now_as_iso_format()
+ assert isinstance(res, str)
+ assert isinstance(datetime.fromisoformat(res), datetime)
+
+
+@pytest.mark.parametrize(
+ "timestamp, expected",
+ [
+ ("2024-04-22T10:22:00", 38 * 60),
+ ("2024-04-22T10:22:59", 38 * 60 - 59),
+ ("2024-04-22T10:59:59", 1),
+ ("2024-04-22T10:59:00", 60),
+ ],
+)
+def test_get_seconds_to_next_hour(timestamp, expected):
+ with freeze_time(timestamp):
+ assert get_seconds_to_next_hour() == expected
diff --git a/apps/worker/helpers/tests/unit/test_commit_error.py b/apps/worker/helpers/tests/unit/test_commit_error.py
new file mode 100644
index 0000000000..954cc8c77f
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_commit_error.py
@@ -0,0 +1,23 @@
+from database.enums import CommitErrorTypes
+from database.tests.factories import CommitFactory
+from helpers.save_commit_error import save_commit_error
+
+
+class TestSaveCommitError(object):
+ def test_save_commit_error(self, mocker, dbsession):
+ commit = CommitFactory.create()
+ dbsession.add(commit)
+
+ save_commit_error(commit, error_code=CommitErrorTypes.REPO_BOT_INVALID.value)
+
+ assert commit.errors
+ assert len(commit.errors) == 1
+
+ def test_save_commit_error_already_saved(self, mocker, dbsession):
+ commit = CommitFactory.create()
+ dbsession.add(commit)
+
+ save_commit_error(commit, error_code=CommitErrorTypes.REPO_BOT_INVALID.value)
+ save_commit_error(commit, error_code=CommitErrorTypes.REPO_BOT_INVALID.value)
+
+ assert len(commit.errors) == 1
diff --git a/apps/worker/helpers/tests/unit/test_components.py b/apps/worker/helpers/tests/unit/test_components.py
new file mode 100644
index 0000000000..8ee4c81f8b
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_components.py
@@ -0,0 +1,26 @@
+from helpers.components import Component
+
+
+def test_from_dict():
+ default_component: Component = Component.from_dict({})
+ assert default_component.name == ""
+ assert default_component.component_id == ""
+ assert default_component.statuses == []
+ assert default_component.paths == []
+ assert default_component.flag_regexes == []
+ assert default_component.get_display_name() == "default_component"
+
+
+def test_get_display_name():
+ component: Component = Component.from_dict({"component_id": "myID"})
+ assert component.get_display_name() == "myID"
+ component.name = "myName"
+ assert component.get_display_name() == "myName"
+
+
+def test_get_matching_flags():
+ component: Component = Component.from_dict({"flag_regexes": ["teamA.*", "batata"]})
+ matched_flags = component.get_matching_flags(
+ ["teamA/unit", "teamB/unit", "teamA/core", "batata", "random"]
+ )
+ assert sorted(matched_flags) == ["batata", "teamA/core", "teamA/unit"]
diff --git a/apps/worker/helpers/tests/unit/test_config.py b/apps/worker/helpers/tests/unit/test_config.py
new file mode 100644
index 0000000000..664ebf8873
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_config.py
@@ -0,0 +1,107 @@
+import pytest
+
+from helpers.config import should_write_data_to_storage_config_check
+
+
+@pytest.mark.parametrize(
+ "inner_config, func_args, result",
+ [
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("commit_report", True, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": True,
+ "commit_report": False,
+ },
+ ("commit_report", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": True, # True is the same as "codecov_access"
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "codecov_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ False,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "codecov_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [],
+ "report_details_files_array": "general_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", False, 1),
+ True,
+ ),
+ (
+ {
+ "repo_ids": [1],
+ "report_details_files_array": "restricted_access",
+ "commit_report": False,
+ },
+ ("report_details_files_array", True, 1),
+ True,
+ ),
+ ],
+)
+def test_should_write_data_to_storage_config_check(
+ inner_config, func_args, result, mocker
+):
+ config = {"setup": {"save_report_data_in_storage": inner_config}}
+
+ def fake_config(*path, default=None):
+ curr = config
+ for key in path:
+ if key in curr:
+ curr = curr.get(key)
+ else:
+ return default
+ return curr
+
+ mocker.patch("helpers.config.get_config", side_effect=fake_config)
+ assert should_write_data_to_storage_config_check(*func_args) == result
diff --git a/apps/worker/helpers/tests/unit/test_environment.py b/apps/worker/helpers/tests/unit/test_environment.py
new file mode 100644
index 0000000000..6f55fe74c6
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_environment.py
@@ -0,0 +1,53 @@
+import os
+from pathlib import PosixPath
+
+from helpers.environment import (
+ Environment,
+ _calculate_current_env,
+ get_external_dependencies_folder,
+)
+
+
+class TestEnvironment(object):
+ def test_get_current_env(self, mocker):
+ # CURRENT_ENVIRONMENT is a fallback when RUN_ENV is not supplied
+ # have to clear out RUN_ENV to test CURRENT_ENVIRONMENT
+ mocker.patch.dict(os.environ, {}, clear=True)
+ mocker.patch.dict(os.environ, {"CURRENT_ENVIRONMENT": ""})
+ assert _calculate_current_env() == Environment.production
+
+ def test_get_current_env_local(self, mocker):
+ mocker.patch.dict(os.environ, {}, clear=True)
+ mocker.patch.dict(os.environ, {"CURRENT_ENVIRONMENT": "local"})
+ assert _calculate_current_env() == Environment.local
+
+ def test_get_current_env_enterprise(self, mocker):
+ mocker.patch.dict(os.environ, {}, clear=True)
+ mocker.patch.dict(os.environ, {"CURRENT_ENVIRONMENT": "local"})
+ mock_path_exists = mocker.patch(
+ "helpers.environment.os.path.exists", return_value=True
+ )
+ mocker.patch(
+ "helpers.environment._get_current_folder", return_value="/home/path"
+ )
+ assert _calculate_current_env() == Environment.enterprise
+ mock_path_exists.assert_called_with(PosixPath("/home/path/src/is_enterprise"))
+
+ def test_get_external_dependencies_folder(self, mock_configuration):
+ assert get_external_dependencies_folder() == "./external_deps"
+ mock_configuration.set_params(
+ {"services": {"external_dependencies_folder": "some/folder"}}
+ )
+ assert get_external_dependencies_folder() == "some/folder"
+
+ def test_get_current_env_run_env(self, mocker):
+ mocker.patch.dict(os.environ, {"RUN_ENV": "ENTERPRISE"})
+ assert _calculate_current_env() == Environment.enterprise
+ mocker.patch.dict(os.environ, {"RUN_ENV": "DEV"})
+ assert _calculate_current_env() == Environment.local
+ mocker.patch.dict(os.environ, {"RUN_ENV": "STAGING"})
+ assert _calculate_current_env() == Environment.production
+ mocker.patch.dict(os.environ, {"RUN_ENV": "TESTING"})
+ assert _calculate_current_env() == Environment.production
+ mocker.patch.dict(os.environ, {"RUN_ENV": "PRODUCTION"})
+ assert _calculate_current_env() == Environment.production
diff --git a/apps/worker/helpers/tests/unit/test_github_installation.py b/apps/worker/helpers/tests/unit/test_github_installation.py
new file mode 100644
index 0000000000..6707810852
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_github_installation.py
@@ -0,0 +1,33 @@
+from sqlalchemy.orm import Session
+
+from database.models.core import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ OwnerInstallationNameToUseForTask,
+)
+from database.tests.factories.core import OwnerFactory
+from helpers.github_installation import get_installation_name_for_owner_for_task
+
+
+def test_get_installation_name_for_owner_for_task(dbsession: Session):
+ owner = OwnerFactory(service="github")
+ other_owner = OwnerFactory()
+ task_name = "app.tasks.notify.Notify"
+ installation_task_config = OwnerInstallationNameToUseForTask(
+ owner=owner,
+ ownerid=owner.ownerid,
+ installation_name="my_installation",
+ task_name=task_name,
+ )
+ dbsession.add_all([owner, other_owner, installation_task_config])
+ dbsession.flush()
+ assert (
+ get_installation_name_for_owner_for_task(task_name, owner) == "my_installation"
+ )
+ assert (
+ get_installation_name_for_owner_for_task(task_name, other_owner)
+ == GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ assert (
+ get_installation_name_for_owner_for_task("other_task", owner)
+ == GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
diff --git a/apps/worker/helpers/tests/unit/test_health_check.py b/apps/worker/helpers/tests/unit/test_health_check.py
new file mode 100644
index 0000000000..66acdab7b5
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_health_check.py
@@ -0,0 +1,25 @@
+import pytest
+
+from helpers.health_check import (
+ HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS,
+ get_health_check_interval_seconds,
+)
+
+
+@pytest.mark.parametrize(
+ "input,expected",
+ [
+ (-10, HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS),
+ (0, HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS),
+ (None, HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS),
+ ("batata", HEALTH_CHECK_DEFAULT_INTERVAL_SECONDS),
+ (20, 20),
+ ("5", 5),
+ ],
+)
+def test_get_interval_seconds(mock_configuration, input, expected):
+ mock_configuration.set_params(
+ {"setup": {"tasks": {"healthcheck": {"interval_seconds": input}}}}
+ )
+ interval = get_health_check_interval_seconds()
+ assert interval == expected
diff --git a/apps/worker/helpers/tests/unit/test_log_context.py b/apps/worker/helpers/tests/unit/test_log_context.py
new file mode 100644
index 0000000000..27bc58dd64
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_log_context.py
@@ -0,0 +1,207 @@
+import asyncio
+
+from asgiref.sync import async_to_sync
+from sqlalchemy.exc import IntegrityError
+
+from database.tests.factories.core import CommitFactory, OwnerFactory, RepositoryFactory
+from helpers.log_context import LogContext, get_log_context, set_log_context
+
+
+def create_db_records(dbsession):
+ owner = OwnerFactory.create(
+ service="github",
+ username="codecove2e",
+ unencrypted_oauth_token="test76zow6xgh7modd88noxr245j2z25t4ustoff",
+ plan="users-basic",
+ )
+ dbsession.add(owner)
+
+ repo = RepositoryFactory.create(
+ owner=owner,
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ )
+ dbsession.add(repo)
+
+ commit = CommitFactory.create(
+ message="",
+ commitid="c5b67303452bbff57cc1f49984339cde39eb1db5",
+ repository=repo,
+ )
+ dbsession.add(commit)
+
+ dbsession.commit()
+ dbsession.expire(owner)
+ dbsession.expire(repo)
+ dbsession.expire(commit)
+
+ return owner, repo, commit
+
+
+def test_populate_just_owner(dbsession):
+ owner, _repo, _commit = create_db_records(dbsession)
+ log_context = LogContext(owner_id=owner.ownerid)
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(
+ owner_id=owner.ownerid,
+ owner_username="codecove2e",
+ owner_service="github",
+ owner_plan="users-basic",
+ )
+
+
+def test_populate_just_repo(dbsession):
+ owner, repo, _commit = create_db_records(dbsession)
+ log_context = LogContext(repo_id=repo.repoid)
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(
+ repo_id=repo.repoid,
+ repo_name="example-python",
+ owner_id=owner.ownerid,
+ owner_username="codecove2e",
+ owner_service="github",
+ owner_plan="users-basic",
+ )
+
+
+def test_populate_just_commit_sha(dbsession):
+ _owner, _repo, commit = create_db_records(dbsession)
+ log_context = LogContext(commit_sha=commit.commitid)
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(commit_sha=commit.commitid)
+
+
+def test_populate_just_commit_id(dbsession):
+ _owner, _repo, commit = create_db_records(dbsession)
+ log_context = LogContext(commit_id=commit.id_)
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(
+ commit_id=commit.id_,
+ )
+
+
+def test_populate_repo_and_commit_sha(dbsession):
+ owner, repo, commit = create_db_records(dbsession)
+ log_context = LogContext(repo_id=repo.repoid, commit_sha=commit.commitid)
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(
+ repo_id=repo.repoid,
+ repo_name="example-python",
+ owner_id=owner.ownerid,
+ owner_username="codecove2e",
+ owner_service="github",
+ owner_plan="users-basic",
+ commit_sha=commit.commitid,
+ )
+
+
+def test_populate_ignores_db_exceptions(dbsession, mocker):
+ owner, repo, commit = create_db_records(dbsession)
+ log_context = LogContext(repo_id=repo.repoid, commit_sha=commit.commitid)
+ mocker.patch.object(dbsession, "query", side_effect=IntegrityError("", {}, None))
+
+ # If this succeeds, the exception thrown by dbsession was ignored
+ log_context.populate_from_sqlalchemy(dbsession)
+
+
+def test_set_and_get_log_context(dbsession):
+ log_context = LogContext(repo_id=1, commit_sha="abcde", commit_id=2, owner_id=3)
+ set_log_context(log_context)
+
+ assert get_log_context() == log_context
+
+ async def check_context_in_coroutine():
+ coro_log_context = get_log_context()
+ assert coro_log_context == log_context
+
+ # Check that the ContextVar is propagated through multiple ways of running
+ # async functions
+ asyncio.run(check_context_in_coroutine())
+ async_to_sync(check_context_in_coroutine)()
+
+
+def test_as_dict(dbsession, mocker):
+ owner, repo, commit = create_db_records(dbsession)
+ log_context = LogContext(
+ commit_sha=commit.commitid, repo_id=repo.repoid, task_name="foo", task_id="bar"
+ )
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ mock_span = mocker.Mock()
+ mock_span.trace_id = 123
+ mocker.patch("helpers.log_context.get_current_span", return_value=mock_span)
+
+ # `_populated_from_db` is a dataclass field that we want to strip
+ # `sentry_trace_id` is a property that we want to include
+ assert log_context.as_dict() == {
+ "task_name": "foo",
+ "task_id": "bar",
+ "commit_id": None,
+ "commit_sha": commit.commitid,
+ "repo_id": repo.repoid,
+ "repo_name": repo.name,
+ "owner_id": owner.ownerid,
+ "owner_username": owner.username,
+ "owner_service": owner.service,
+ "owner_plan": owner.plan,
+ "sentry_trace_id": 123,
+ "checkpoints_data": {},
+ }
+
+
+def test_add_to_log_record(dbsession):
+ owner, repo, commit = create_db_records(dbsession)
+ log_context = LogContext(commit_id=commit.id_, task_name="foo", task_id="bar")
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ log_record = {}
+ log_context.add_to_log_record(log_record)
+
+ expected_dict = log_context.as_dict()
+ expected_dict.pop("checkpoints_data", None)
+ assert log_record["context"] == expected_dict
+
+
+def test_add_to_sentry(dbsession, mocker):
+ mock_set_tags = mocker.patch("sentry_sdk.set_tags")
+
+ owner, repo, commit = create_db_records(dbsession)
+ log_context = LogContext(commit_id=commit.id_, task_name="foo", task_id="bar")
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ # Calls `log_context.set_to_sentry()`
+ set_log_context(log_context)
+
+ expected_sentry_fields = log_context.as_dict()
+ expected_sentry_fields.pop("sentry_trace_id")
+ expected_sentry_fields.pop("checkpoints_data")
+ mock_set_tags.assert_called_with(expected_sentry_fields)
+
+
+def test_log_context_populate_from_sqlalchemy_is_disabled(
+ mock_configuration, dbsession
+):
+ mock_configuration.set_params({"setup": {"log_context": {"populate": False}}})
+ log_context = LogContext(task_name="foo", task_id="bar")
+ log_context.populate_from_sqlalchemy(dbsession)
+
+ assert log_context == LogContext(task_name="foo", task_id="bar")
+ assert log_context.as_dict() == {
+ "task_name": "foo",
+ "task_id": "bar",
+ "commit_id": None,
+ "commit_sha": None,
+ "repo_id": None,
+ "repo_name": None,
+ "owner_id": None,
+ "owner_username": None,
+ "owner_service": None,
+ "sentry_trace_id": None,
+ "checkpoints_data": {},
+ "owner_plan": None,
+ }
diff --git a/apps/worker/helpers/tests/unit/test_logging_config.py b/apps/worker/helpers/tests/unit/test_logging_config.py
new file mode 100644
index 0000000000..01ca878033
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_logging_config.py
@@ -0,0 +1,60 @@
+from helpers.environment import Environment
+from helpers.log_context import LogContext, set_log_context
+from helpers.logging_config import (
+ CustomLocalJsonFormatter,
+ config_dict,
+ get_logging_config_dict,
+)
+
+
+class TestLoggingConfig(object):
+ def test_local_formatter(self):
+ log_record = {"levelname": "weird_level", "message": "This is a message"}
+ cljf = CustomLocalJsonFormatter()
+ res = cljf.jsonify_log_record(log_record)
+ assert "weird_level: This is a message \n {}" == res
+
+ def test_local_formatter_with_exc_info(self):
+ log_record = {
+ "levelname": "weird_level",
+ "message": "This is a message",
+ "exc_info": "Line\nWith\nbreaks",
+ }
+ cljf = CustomLocalJsonFormatter()
+ res = cljf.jsonify_log_record(log_record)
+ assert "weird_level: This is a message \n {}\nLine\nWith\nbreaks" == res
+
+ def test_get_logging_config_dict(self, mocker):
+ get_current_env = mocker.patch("helpers.logging_config.get_current_env")
+ get_current_env.return_value = Environment.production
+ assert get_logging_config_dict() == config_dict
+
+ def test_add_fields_empty_log_context(self, mocker):
+ set_log_context(LogContext())
+ log_record, record, message_dict = {}, mocker.MagicMock(), {"message": "aaa"}
+ log_formatter = CustomLocalJsonFormatter()
+ log_formatter.add_fields(log_record, record, message_dict)
+
+ expected_log_record = {
+ "message": "aaa",
+ "method_calls": [],
+ }
+ LogContext().add_to_log_record(expected_log_record)
+ assert log_record == expected_log_record
+
+ def test_add_fields_populated_log_context(self, mocker):
+ log_context = LogContext(
+ task_name="lkjhg", task_id="abcdef", repo_id=5, owner_id=3
+ )
+ set_log_context(log_context)
+
+ log_record, record, message_dict = {}, mocker.MagicMock(), {"message": "aaa"}
+ log_formatter = CustomLocalJsonFormatter()
+ log_formatter.add_fields(log_record, record, message_dict)
+
+ expected_log_record = {
+ "message": "aaa",
+ "method_calls": [],
+ }
+ log_context.add_to_log_record(expected_log_record)
+ assert log_record == expected_log_record
diff --git a/apps/worker/helpers/tests/unit/test_match.py b/apps/worker/helpers/tests/unit/test_match.py
new file mode 100644
index 0000000000..82b8314bb4
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_match.py
@@ -0,0 +1,10 @@
+from helpers.match import match
+
+
+def test_match():
+ assert match(["old.*"], "new_branch") is False
+ assert match(["new.*"], "new_branch") is True
+ assert match(["old.*"], "new_branch") is False
+ # Negative matches return False
+ assert match(["!new.*"], "new_branch") is False
+ assert match(["!new_branch"], "new_branch") is False
diff --git a/apps/worker/helpers/tests/unit/test_number.py b/apps/worker/helpers/tests/unit/test_number.py
new file mode 100644
index 0000000000..91da08a837
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_number.py
@@ -0,0 +1,32 @@
+from decimal import Decimal
+
+import pytest
+
+from helpers.number import precise_round
+
+
+@pytest.mark.parametrize(
+ "number,precision,rounding,expected_rounding",
+ [
+ (Decimal("1.129"), 2, "down", Decimal("1.12")),
+ (Decimal("1.121"), 2, "up", Decimal("1.13")),
+ (Decimal("1.125"), 1, "nearest", Decimal("1.1")),
+ (Decimal("1.18"), 1, "nearest", Decimal("1.2")),
+ (Decimal("1.15"), 1, "nearest", Decimal("1.2")),
+ (Decimal("1.25"), 1, "nearest", Decimal("1.2")),
+ ],
+ ids=[
+ "number rounds down",
+ "number rounds up",
+ "number rounds nearest (down)",
+ "number rounds nearest (up)",
+ "number rounds half-even (up)",
+ "number rounds half-even (down)",
+ ],
+)
+def test_precise_round(
+ number: Decimal, precision: int, rounding: str, expected_rounding: Decimal
+):
+ assert expected_rounding == precise_round(
+ number, precision=precision, rounding=rounding
+ )
diff --git a/apps/worker/helpers/tests/unit/test_sentry.py b/apps/worker/helpers/tests/unit/test_sentry.py
new file mode 100644
index 0000000000..6941cbc1fc
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_sentry.py
@@ -0,0 +1,26 @@
+import os
+
+from helpers.sentry import initialize_sentry
+
+
+class TestSentry(object):
+ def test_initialize_sentry(self, mocker, mock_configuration):
+ mock_configuration._params["services"] = {"sentry": {"server_dsn": "this_dsn"}}
+ cluster = "test_env"
+ mocker.patch.dict(
+ os.environ,
+ {"RELEASE_VERSION": "FAKE_VERSION_FOR_YOU", "CLUSTER_ENV": cluster},
+ )
+ mocked_init = mocker.patch("helpers.sentry.sentry_sdk.init")
+ mocked_set_tag = mocker.patch("helpers.sentry.sentry_sdk.set_tag")
+ assert initialize_sentry() is None
+ mocked_init.assert_called_with(
+ "this_dsn",
+ release="worker-FAKE_VERSION_FOR_YOU",
+ sample_rate=1.0,
+ traces_sample_rate=1.0,
+ profiles_sample_rate=1.0,
+ environment="production",
+ integrations=mocker.ANY,
+ )
+ mocked_set_tag.assert_called_with("cluster", cluster)
diff --git a/apps/worker/helpers/tests/unit/test_string.py b/apps/worker/helpers/tests/unit/test_string.py
new file mode 100644
index 0000000000..50a430fe79
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_string.py
@@ -0,0 +1,35 @@
+from helpers.string import EscapeEnum, Replacement, StringEscaper, shorten_file_paths
+
+
+def test_string_escaper():
+ escape_def = [
+ Replacement(["1"], "2", EscapeEnum.APPEND),
+ Replacement(["3"], "4", EscapeEnum.PREPEND),
+ Replacement(["5"], "6", EscapeEnum.REPLACE),
+ ]
+
+ escaper = StringEscaper(escape_def)
+
+ assert escaper.replace("123456") == "12243466"
+
+
+def test_shorten_file_paths():
+ string = """Error: expect(received).toBe(expected) // Object.is equality
+
+Expected: 1
+Received: -1
+ at Object.<anonymous> (/Users/users/dir/repo/demo/calculator/calculator.test.ts:10:31)
+ at Promise.then.completed (/Users/users/dir/repo/node_modules/jest-circus/build/utils.js:298:28)
+ at Promise.then.completed (build/utils.js:298:28)
+
+"""
+ expected = """Error: expect(received).toBe(expected) // Object.is equality
+
+Expected: 1
+Received: -1
+ at Object.<anonymous> (.../demo/calculator/calculator.test.ts:10:31)
+ at Promise.then.completed (.../jest-circus/build/utils.js:298:28)
+ at Promise.then.completed (build/utils.js:298:28)
+
+"""
+ assert expected == shorten_file_paths(string)
diff --git a/apps/worker/helpers/tests/unit/test_version.py b/apps/worker/helpers/tests/unit/test_version.py
new file mode 100644
index 0000000000..f139f73047
--- /dev/null
+++ b/apps/worker/helpers/tests/unit/test_version.py
@@ -0,0 +1,13 @@
+import os
+
+from helpers.version import get_current_version
+
+
+class TestVersion(object):
+ def test_get_current_version(self, mocker):
+ mocker.patch.dict(os.environ, {"RELEASE_VERSION": "HAHA"})
+ assert get_current_version() == "HAHA"
+
+ def test_get_current_version_no_set_version(self, mocker):
+ mocker.patch.dict(os.environ, {"nada": "nada"}, clear=True)
+ assert get_current_version() == "NO_VERSION"
diff --git a/apps/worker/helpers/timeseries.py b/apps/worker/helpers/timeseries.py
new file mode 100644
index 0000000000..89dbdc47a5
--- /dev/null
+++ b/apps/worker/helpers/timeseries.py
@@ -0,0 +1,5 @@
+from shared.config import get_config
+
+
+def backfill_max_batch_size() -> int:
+ return get_config("setup", "timeseries", "backfill_max_batch_size", default=500)
diff --git a/apps/worker/helpers/token_refresh.py b/apps/worker/helpers/token_refresh.py
new file mode 100644
index 0000000000..dcb380f722
--- /dev/null
+++ b/apps/worker/helpers/token_refresh.py
@@ -0,0 +1,35 @@
+import logging
+from typing import Callable, Dict
+
+from shared.encryption.token import encode_token
+
+from database.models.core import Owner
+from services.encryption import encryptor
+
+log = logging.getLogger(__name__)
+
+
+def get_token_refresh_callback(owner: Owner) -> Callable[[Dict], None]:
+ """
+ Produces a callback function that will encode and update the oauth token of a user.
+ This callback is passed to the TorngitAdapter for the service.
+ """
+ # Some tokens don't have to be refreshed (GH integration, default bots)
+ # They don't belong to any owners.
+ if owner is None:
+ return None
+
+ service = owner.service
+ if service == "bitbucket" or service == "bitbucket_server":
+ return None
+
+ async def callback(new_token: Dict) -> None:
+ log.info(
+ "Saving new token after refresh",
+ extra=dict(owner=owner.username, ownerid=owner.ownerid),
+ )
+ string_to_save = encode_token(new_token)
+ oauth_token = encryptor.encode(string_to_save).decode()
+ owner.oauth_token = oauth_token
+
+ return callback
diff --git a/apps/worker/helpers/version.py b/apps/worker/helpers/version.py
new file mode 100644
index 0000000000..3775778725
--- /dev/null
+++ b/apps/worker/helpers/version.py
@@ -0,0 +1,5 @@
+import os
+
+
+def get_current_version() -> str:
+ return os.getenv("RELEASE_VERSION", "NO_VERSION")
diff --git a/apps/worker/json/ci.json b/apps/worker/json/ci.json
new file mode 100644
index 0000000000..02777e53a3
--- /dev/null
+++ b/apps/worker/json/ci.json
@@ -0,0 +1,177 @@
+{
+ "travis": {
+ "title": "Travis-CI",
+ "icon": "travis",
+ "require_token_when_public": false,
+ "instructions": "travis",
+ "build_url": "https://travis-ci.{tld}/{owner[username]}/{repo[name]}/jobs/{session.job}"
+ },
+ "azure-pipelines": {
+ "title": "Azure",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "azure_pipelines": {
+ "title": "Azure",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "azure-devops": {
+ "title": "Azure",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "docker": {
+ "title": "Docker",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "buildbot": {
+ "title": "Buildbot",
+ "icon": "buildbot",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "codefresh": {
+ "title": "Codefresh",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": "https://g.codefresh.io/repositories/{owner[username]}/{repo[name]}/builds/{session.build}"
+ },
+ "circleci": {
+ "title": "CircleCI",
+ "icon": "circleci",
+ "require_token_when_public": false,
+ "instructions": "circleci",
+ "build_url": "https://circleci.com/gh/{owner[username]}/{repo[name]}/{session.build}#tests/containers/{session.job}"
+ },
+ "buddybuild": {
+ "title": "buddybuild",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "solano": {
+ "title": "Solano",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "teamcity": {
+ "title": "TeamCity",
+ "icon": "teamcity",
+ "require_token_when_public": true,
+ "instructions": "teamcity",
+ "build_url": null
+ },
+ "appveyor": {
+ "title": "AppVeyor",
+ "icon": "appveyor",
+ "require_token_when_public": false,
+ "instructions": "appveyor",
+ "build_url": "https://ci.appveyor.com/project/{owner[username]}/{repo[name]}/build/job/{session.build}"
+ },
+ "wercker": {
+ "title": "Wercker",
+ "icon": "wercker",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": "https://app.wercker.com/#build/{session.build}"
+ },
+ "shippable": {
+ "title": "Shippable",
+ "icon": "shippable",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "codeship": {
+ "title": "Codeship",
+ "icon": "codeship",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "drone.io": {
+ "title": "Drone.io",
+ "icon": "drone.io",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "jenkins": {
+ "title": "Jenkins",
+ "icon": "jenkins",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "semaphore": {
+ "title": "Semaphore",
+ "icon": "semaphore",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": "https://semaphoreapp.com/{owner[username]}/{repo[name]}/branches/{commit[branch]}/builds/{session.build}"
+ },
+ "gitlab": {
+ "title": "GitLab CI",
+ "icon": "gitlab",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": "https://gitlab.com/{owner[username]}/{repo[name]}/builds/{session.build}"
+ },
+ "bamboo": {
+ "title": "Bamboo",
+ "icon": "bamboo",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "buildkite": {
+ "title": "BuildKite",
+ "icon": "buildkite",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "bitrise": {
+ "title": "Bitrise",
+ "icon": "bitrise",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "greenhouse": {
+ "title": "Greenhouse",
+ "icon": "greenhouse",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "heroku": {
+ "title": "Heroku",
+ "icon": "heroku",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ },
+ "custom": {
+ "title": "Custom",
+ "icon": "custom",
+ "require_token_when_public": true,
+ "instructions": "generic",
+ "build_url": null
+ }
+}
diff --git a/apps/worker/main.py b/apps/worker/main.py
new file mode 100644
index 0000000000..aac995974c
--- /dev/null
+++ b/apps/worker/main.py
@@ -0,0 +1,154 @@
+import os
+
+import django
+
+# we're moving this before we create the Celery object
+# so that celery can detect Django is being used
+# using the Django fixup will help fix some database issues
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_scaffold.settings")
+django.setup()
+
+import logging # noqa: E402
+import sys # noqa: E402
+
+import click # noqa: E402
+import shared.storage # noqa: E402
+from celery.signals import worker_process_shutdown # noqa: E402
+from prometheus_client import REGISTRY, CollectorRegistry, multiprocess # noqa: E402
+from shared.celery_config import BaseCeleryConfig # noqa: E402
+from shared.config import get_config # noqa: E402
+from shared.license import startup_license_logging # noqa: E402
+from shared.metrics import start_prometheus # noqa: E402
+from shared.storage.exceptions import BucketAlreadyExistsError # noqa: E402
+
+import app # noqa: E402
+from helpers.environment import get_external_dependencies_folder # noqa: E402
+from helpers.version import get_current_version # noqa: E402
+
+log = logging.getLogger(__name__)
+
+initialization_text = """
+ _____ _
+ / ____| | |
+| | ___ __| | ___ ___ _____ __
+| | / _ \\ / _` |/ _ \\/ __/ _ \\ \\ / /
+| |___| (_) | (_| | __/ (_| (_) \\ V /
+ \\_____\\___/ \\__,_|\\___|\\___\\___/ \\_/
+ {version}
+
+"""
+
+
+@click.group()
+@click.pass_context
+def cli(ctx: click.Context):
+ pass
+
+
+@cli.command()
+def test():
+ raise click.ClickException("System not suitable to run TEST mode")
+
+
+@cli.command()
+def web():
+ raise click.ClickException("System not suitable to run WEB mode")
+
+
+@worker_process_shutdown.connect
+def mark_process_dead(pid, exitcode, **kwargs):
+ multiprocess.mark_process_dead(pid)
+
+
+def setup_worker():
+ print(initialization_text.format(version=get_current_version())) # noqa: T201
+
+ if getattr(sys, "frozen", False):
+ # Only for enterprise builds
+ external_deps_folder = get_external_dependencies_folder()
+ log.info(f"External dependencies folder configured to {external_deps_folder}")
+ sys.path.append(external_deps_folder)
+
+ registry = REGISTRY
+ if "PROMETHEUS_MULTIPROC_DIR" in os.environ:
+ registry = CollectorRegistry()
+ multiprocess.MultiProcessCollector(registry)
+
+ start_prometheus(9996, registry=registry) # 9996 is an arbitrary port number
+
+ minio_config = get_config("services", "minio", default={})
+ auto_create_bucket = minio_config.get("auto_create_bucket", False)
+ if auto_create_bucket:
+ try:
+ bucket_name = minio_config.get("bucket", "archive")
+ region = minio_config.get("region", "us-east-1")
+
+ # note that this is a departure from the old default behavior.
+ # This is intended as the bucket will exist in most cases where IAC or manual setup is used
+ log.info("Initializing bucket %s", bucket_name)
+
+ # this storage client is only used to create the bucket so it doesn't need to be
+ # aware of the repoid
+ storage_client = shared.storage.get_appropriate_storage_service()
+ storage_client.create_root_storage(bucket_name, region)
+ except BucketAlreadyExistsError:
+ pass
+
+ startup_license_logging()
+
+
+@cli.command()
+@click.option("--name", envvar="HOSTNAME", default="worker", help="Node name")
+@click.option(
+ "--concurrency", type=int, default=2, help="Number for celery concurrency"
+)
+@click.option("--debug", is_flag=True, default=False, help="Enable celery debug mode")
+@click.option(
+ "--queue",
+ multiple=True,
+ default=["celery"],
+ help="Queues to listen to for this worker",
+)
+def worker(name: str, concurrency: int, debug: bool, queue: list[str]):
+ setup_worker()
+ args = [
+ "worker",
+ "-n",
+ name,
+ "-c",
+ concurrency,
+ "-l",
+ ("debug" if debug else "info"),
+ ]
+ if get_config("setup", "celery_queues_enabled", default=True):
+ actual_queues = _get_queues_param_from_queue_input(queue)
+ args += ["-Q", actual_queues]
+
+ if get_config("setup", "celery_beat_enabled", default=True):
+ args += ["-B", "-s", "/home/codecov/celerybeat-schedule"]
+
+ return app.celery_app.worker_main(argv=args)
+
+
+def _get_queues_param_from_queue_input(queues: list[str]) -> str:
+ # We always run the health_check queue to make sure the healthcheck is performed
+ # And also to avoid that queue fillign up with no workers to consume from it
+
+ # Support passing comma separated values, as those will be split again:
+ joined_queues = ",".join(queues)
+ enterprise_queues = ["enterprise_" + q for q in joined_queues.split(",")]
+ all_queues = [
+ joined_queues,
+ *enterprise_queues,
+ BaseCeleryConfig.health_check_default_queue,
+ ]
+
+ return ",".join(all_queues)
+
+
+def main():
+ cli(obj={})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/worker/manage.py b/apps/worker/manage.py
new file mode 100755
index 0000000000..48bcbb58a5
--- /dev/null
+++ b/apps/worker/manage.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_scaffold.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/worker/migrate-timeseries.sh b/apps/worker/migrate-timeseries.sh
new file mode 100644
index 0000000000..1f8ebbfa85
--- /dev/null
+++ b/apps/worker/migrate-timeseries.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+
+echo "Running Timeseries Django migrations"
+prefix=""
+if [ -f "/usr/local/bin/berglas" ]; then
+ prefix="berglas exec --"
+fi
+
+$prefix python migrate_timeseries.py
diff --git a/apps/worker/migrate.sh b/apps/worker/migrate.sh
new file mode 100644
index 0000000000..2d57bd4af8
--- /dev/null
+++ b/apps/worker/migrate.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+# Command ran by k8s to run migrations for worker
+echo "Running Django migrations"
+prefix=""
+if [ -f "/usr/local/bin/berglas" ]; then
+ prefix="berglas exec --"
+fi
+
+$prefix python manage.py migrate
+$prefix python manage.py pgpartition --yes --skip-delete
diff --git a/apps/worker/migrate_timeseries.py b/apps/worker/migrate_timeseries.py
new file mode 100644
index 0000000000..b40fbe33a9
--- /dev/null
+++ b/apps/worker/migrate_timeseries.py
@@ -0,0 +1,35 @@
+import logging
+import os
+
+import django
+from django.core.management import call_command
+
+# Setup Django environment
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_scaffold.settings")
+django.setup()
+
+from django.conf import settings # noqa: E402
+
+logger = logging.getLogger(__name__)
+
+
+def run_migrate_commands():
+ try:
+ if settings.TA_TIMESERIES_ENABLED:
+ logger.info("Running ta_timeseries migrations")
+ call_command(
+ "migrate",
+ database="ta_timeseries",
+ app_label="ta_timeseries",
+ settings="django_scaffold.settings",
+ verbosity=1,
+ )
+ else:
+ logger.info("Skipping ta_timeseries migrations")
+
+ except Exception as e:
+ logger.error(f"An error occurred: {e}")
+
+
+if __name__ == "__main__":
+ run_migrate_commands()
diff --git a/apps/worker/mypy.ini b/apps/worker/mypy.ini
new file mode 100644
index 0000000000..e551d98949
--- /dev/null
+++ b/apps/worker/mypy.ini
@@ -0,0 +1,17 @@
+[mypy]
+explicit_package_bases = True
+; disable type checks in tests (for now):
+exclude = tests
+
+; TODO: we eventually want these errors to be enabled:
+disable_error_code = attr-defined,import-untyped,name-defined
+
+; TODO: and we would also want these additional checks to be enabled
+; as they are defined in Sentry as well:
+; check_untyped_defs = True
+; no_implicit_reexport = True
+; warn_unreachable = True
+; warn_unused_configs = True
+; warn_unused_ignores = True
+; warn_redundant_casts = True
+; enable_error_code = ignore-without-code,redundant-self
diff --git a/apps/worker/protobuf/ta_testrun.proto b/apps/worker/protobuf/ta_testrun.proto
new file mode 100644
index 0000000000..7b460c43ae
--- /dev/null
+++ b/apps/worker/protobuf/ta_testrun.proto
@@ -0,0 +1,35 @@
+syntax = "proto2";
+
+message TestRun {
+ optional int64 timestamp = 1;
+ optional string name = 2;
+ optional string classname = 3;
+ optional string testsuite = 4;
+ optional string computed_name = 5;
+
+ enum Outcome {
+ PASSED = 0;
+ FAILED = 1;
+ SKIPPED = 2;
+ FLAKY_FAILED = 3;
+ }
+
+ optional Outcome outcome = 6;
+
+ optional string failure_message = 7;
+ optional float duration_seconds = 8;
+
+ optional int64 repoid = 10;
+ optional string commit_sha = 11;
+
+ optional string branch_name = 12;
+
+ repeated string flags = 13;
+
+ optional string filename = 14;
+ optional string framework = 15;
+
+ optional int64 upload_id = 16;
+ optional bytes flags_hash = 17;
+ optional bytes test_id = 18;
+}
diff --git a/apps/worker/pyproject.toml b/apps/worker/pyproject.toml
new file mode 100644
index 0000000000..16a2edb482
--- /dev/null
+++ b/apps/worker/pyproject.toml
@@ -0,0 +1,86 @@
+[project]
+name = "worker"
+version = "0.1.0"
+description = "The codecov worker"
+readme = "README.md"
+requires-python = "==3.13.*"
+dependencies = [
+ "asgiref>=3.7.2",
+ "analytics-python==1.3.0b1",
+ "billiard>=4.2.1",
+ "boto3>=1.34",
+ "celery>=5.3.6",
+ "click>=8.1.7",
+ "codecov-ribs==0.1.18",
+ "django>=4.2.16",
+ "django-postgres-extra>=2.0.8",
+ "google-cloud-pubsub>=2.27.1",
+ "google-cloud-storage>=2.10.0",
+ "grpcio>=1.66.2",
+ "httpx>0.23.1",
+ "jinja2>=3.1.3",
+ "lxml>=5.3.0",
+ "mmh3>=5.0.1",
+ "multidict>=6.1.0",
+ "openai>=1.2.4",
+ "orjson>=3.10.11",
+ "polars==1.12.0",
+ "proto-plus>=1.25.0",
+ "psycopg2-binary>=2.9.10",
+ "protobuf>=5.29.2",
+ "pydantic>=2.9.0",
+ "pyjwt>=2.4.0",
+ "python-dateutil>=2.9.0.post0",
+ "python-json-logger>=0.1.11",
+ "python-redis-lock>=4.0.0",
+ "pyyaml>=6.0.1",
+ "redis>=4.4.4",
+ "regex>=2023.12.25",
+ "requests>=2.32.0",
+ "sentry-sdk>=2.13.0",
+ "shared",
+ "sqlalchemy==1.3.*",
+ "sqlparse==0.5.0",
+ "statsd>=3.3.0",
+ "stripe>=11.4.1",
+ "test-results-parser",
+ "timestring",
+ "zstandard>=0.23.0",
+]
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+py-modules = []
+
+[tool.uv]
+dev-dependencies = [
+ "coverage>=7.5.0",
+ "factory-boy>=3.2.0",
+ "mock>=4.0.3",
+ "pre-commit>=3.4.0",
+ "pytest>=8.1.1",
+ "pytest-asyncio>=0.14.0",
+ "pytest-celery>=0.0.0",
+ "pytest-cov>=6.0.0",
+ "pytest-django>=4.7.0",
+ "pytest-freezegun>=0.4.2",
+ "pytest-insta>=0.3.0",
+ "pytest-mock>=1.13.0",
+ "pytest-sqlalchemy>=0.2.1",
+ "respx>=0.20.2",
+ "ruff>=0.9.8",
+ "sqlalchemy-utils>=0.41.2",
+ "time-machine>=2.16.0",
+ # NOTE: some weird interaction between existing `vcrpy` snapshots and the way
+ # `oauth2` / `minio` deal with requests forces us to downgrade `urllib3`:
+ "urllib3==1.26.19",
+ "vcrpy>=6.0.0",
+]
+
+[tool.uv.sources]
+timestring = { git = "https://github.com/codecov/timestring", rev = "d37ceacc5954dff3b5bd2f887936a98a668dda42" }
+test-results-parser = { git = "https://github.com/codecov/test-results-parser", rev = "190bbc8a911099749928e13d5fe57f6027ca1e74" }
+shared = { path = "../../libs/shared" }
diff --git a/apps/worker/pytest.ini b/apps/worker/pytest.ini
new file mode 100644
index 0000000000..cfb5b69e25
--- /dev/null
+++ b/apps/worker/pytest.ini
@@ -0,0 +1,7 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = django_scaffold.tests_settings
+addopts = --sqlalchemy-connect-url="postgresql://postgres@postgres:5432/test_postgres_sqlalchemy" --ignore-glob=**/test_results*
+markers=
+ integration: integration tests (includes tests with vcrs)
+ real_checkpoint_logger: prevents use of stubbed CheckpointLogger
+ real_feature: prevents use of stubbed Feature
diff --git a/apps/worker/rollouts/__init__.py b/apps/worker/rollouts/__init__.py
new file mode 100644
index 0000000000..6d63131d0b
--- /dev/null
+++ b/apps/worker/rollouts/__init__.py
@@ -0,0 +1,14 @@
+from shared.rollouts import Feature
+
+# Declare the feature variants and parameters via Django Admin
+CARRYFORWARD_BASE_SEARCH_RANGE_BY_OWNER = Feature("carryforward_base_search_range")
+
+SYNC_PULL_USE_MERGE_COMMIT_SHA = Feature("sync_pull_use_merge_commit_sha")
+
+CHECKPOINT_ENABLED_REPOSITORIES = Feature("checkpoint_enabled_repositories")
+
+NEW_TA_TASKS = Feature("new_ta_tasks")
+
+PARALLEL_COMPONENT_COMPARISON = Feature("parallel_component_comparison")
+
+TA_TIMESERIES = Feature("ta_timeseries")
diff --git a/apps/worker/ruff.toml b/apps/worker/ruff.toml
new file mode 100644
index 0000000000..e8523a3576
--- /dev/null
+++ b/apps/worker/ruff.toml
@@ -0,0 +1,64 @@
+# Exclude a variety of commonly ignored directories.
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+]
+
+# Same as Black.
+line-length = 88
+indent-width = 4
+
+# Assume Python 3.13
+target-version = "py313"
+
+[lint]
+# Currently only enabled for most F (Pyflakes), Pycodestyle (Error), PERF (Perflint),
+# PyLint (Convention, Error), I (isort), and T20 (flake8-print) rules: https://docs.astral.sh/ruff/rules/
+select = ["F", "E", "I", "PLC", "PLE", "PERF", "T20"]
+ignore = ["F841", "F405", "F403", "E501", "E712"]
+
+# Allow fix for all enabled rules (when `--fix`) is provided.
+# The preferred method (for now) w.r.t. fixable rules is to manually update the makefile
+# with --fix and re-run 'make lint'
+fixable = ["ALL"]
+unfixable = []
+
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+[format]
+# Like Black, use double quotes for strings.
+quote-style = "double"
+
+# Like Black, indent with spaces, rather than tabs.
+indent-style = "space"
+
+# Like Black, respect magic trailing commas.
+skip-magic-trailing-comma = false
+
+# Like Black, automatically detect the appropriate line ending.
+line-ending = "auto"
\ No newline at end of file
diff --git a/apps/worker/services/__init__.py b/apps/worker/services/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/activation.py b/apps/worker/services/activation.py
new file mode 100644
index 0000000000..2a87b15c5d
--- /dev/null
+++ b/apps/worker/services/activation.py
@@ -0,0 +1,121 @@
+import logging
+
+from shared.celery_config import (
+ activate_account_user_task_name,
+ new_user_activated_task_name,
+)
+from sqlalchemy import func
+from sqlalchemy.sql import text
+
+from app import celery_app
+from services.license import (
+ calculate_reason_for_not_being_valid,
+ get_current_license,
+ get_installation_plan_activated_users,
+ requires_license,
+)
+
+log = logging.getLogger(__name__)
+
+
+def activate_user(db_session, org_ownerid: int, user_ownerid: int) -> bool:
+ """
+ Attempt to activate the user for the given org
+
+ Returns:
+ bool: was the user successfully activated
+ """
+ if requires_license():
+ # we will not activate if the license is invalid for any reason.
+ license_status = calculate_reason_for_not_being_valid(db_session)
+ if license_status is None:
+ # check if you have an available seat with which to activate.
+ seat_query = get_installation_plan_activated_users(db_session)
+
+ this_license = get_current_license()
+ can_activate = all(
+ result[0] < this_license.number_allowed_users for result in seat_query
+ )
+ # add user_ownerid to orgs, plan activated users.
+ if can_activate:
+ query_string = text(
+ """
+ UPDATE owners
+ set plan_activated_users = array_append_unique(plan_activated_users, :user_ownerid)
+ where ownerid=:org_ownerid
+ returning ownerid,
+ plan_activated_users,
+ username,
+ plan_activated_users @> array[:user_ownerid]::int[] as has_access;"""
+ )
+ (activation_success,) = db_session.execute(
+ query_string,
+ {"user_ownerid": user_ownerid, "org_ownerid": org_ownerid},
+ ).fetchall()
+
+ log.info(
+ "PR Auto activation attempted",
+ extra=dict(
+ org_ownerid=org_ownerid,
+ author_ownerid=user_ownerid,
+ activation_success=activation_success,
+ ),
+ )
+
+ return True
+ else:
+ log.info(
+ "Auto activation failed due to no seats remaining",
+ extra=dict(
+ org_ownerid=org_ownerid,
+ author_ownerid=user_ownerid,
+ activation_success=False,
+ license_status=license_status,
+ ),
+ )
+ return False
+
+ else:
+ log.info(
+ "Auto activation failed due to invalid license",
+ extra=dict(
+ org_ownerid=org_ownerid,
+ author_ownerid=user_ownerid,
+ activation_success=False,
+ license_status=license_status,
+ ),
+ )
+ return False
+
+ # TODO: we need to decide the best way for this logic to be shared across
+ # worker and codecov-api - ideally moving logic from database to application layer
+ (activation_success,) = db_session.query(
+ func.public.try_to_auto_activate(org_ownerid, user_ownerid)
+ ).first()
+
+ log.info(
+ "Auto activation attempted",
+ extra=dict(
+ org_ownerid=org_ownerid,
+ author_ownerid=user_ownerid,
+ activation_success=activation_success,
+ ),
+ )
+ return activation_success
+
+
+def schedule_new_user_activated_task(org_ownerid, user_ownerid):
+ celery_app.send_task(
+ new_user_activated_task_name,
+ args=None,
+ kwargs=dict(org_ownerid=org_ownerid, user_ownerid=user_ownerid),
+ )
+ # Activate the account user if it exists.
+ celery_app.send_task(
+ activate_account_user_task_name,
+ args=None,
+ kwargs=dict(
+ user_ownerid=user_ownerid,
+ org_ownerid=org_ownerid,
+ ),
+ )
diff --git a/apps/worker/services/ai_pr_review.py b/apps/worker/services/ai_pr_review.py
new file mode 100644
index 0000000000..268848ac7f
--- /dev/null
+++ b/apps/worker/services/ai_pr_review.py
@@ -0,0 +1,378 @@
+import json
+import logging
+import re
+from dataclasses import dataclass
+from functools import cached_property
+from typing import Dict, List, Optional
+
+from openai import AsyncOpenAI
+from shared.config import get_config
+from shared.storage.exceptions import FileNotInStorageError
+from shared.torngit.base import TokenType, TorngitBaseAdapter
+
+from database.models.core import Repository
+from helpers.metrics import metrics
+from services.archive import ArchiveService
+from services.repository import get_repo_provider_service
+
+log = logging.getLogger(__name__)
+
+FILE_REGEX = re.compile(r"diff --git a/(.+) b/(.+)")
+
+
+def build_prompt(diff_text: str) -> str:
+ return f"""
+ Your purpose is to act as a highly experienced software engineer and provide a thorough
+ review of code changes and suggest improvements. Do not comment on minor style issues,
+ missing comments or documentation. Identify and resolve significant concerns to improve
+ overall code quality.
+
+ You will receive a Git diff where each line has been prefixed with a unique identifer in
+ square brackets. When referencing lines in this diff use that identifier.
+
+ Format your output as JSON such that there is 1 top-level comment that summarizes your review
+ and multiple additional comments addressing specific lines in the code with the changes you
+ deem appropriate.
+
+ The output should have this JSON form:
+
+ {{
+ "body": "This is the summary comment",
+ "comments": [
+ {{
+ "line_id": 123,
+ "body": "This is a comment about the code with line ID 123",
+ }}
+ ]
+ }}
+
+ Limit the number of comments to 10 at most.
+
+ Here is the Git diff on which you should base your review:
+
+ {diff_text}
+ """
+
+
+async def fetch_openai_completion(prompt: str):
+ client = AsyncOpenAI(api_key=get_config("services", "openai", "api_key"))
+ completion = await client.chat.completions.create(
+ messages=[
+ {
+ "role": "user",
+ "content": prompt,
+ }
+ ],
+ model="gpt-4",
+ )
+
+ output = completion.choices[0].message.content
+ return output
+
+
+@dataclass(frozen=True)
+class LineInfo:
+ file_path: str
+ position: int
+
+
+class Diff:
+ def __init__(self, diff):
+ self._diff = diff
+ self._index = {}
+ self._build_index()
+
+ @cached_property
+ def preprocessed(self) -> str:
+ """
+ This returns the full diff but with each line prefixed by:
+ [line_id] (where line_id is just a unique integer)
+ """
+ return "\n".join(
+ [f"[{i + 1}] {line}" for i, line in enumerate(self._diff.split("\n"))]
+ )
+
+ def line_info(self, line_id: int) -> LineInfo:
+ return self._index[line_id]
+
+ def _build_index(self):
+ file_path = None
+ position = None
+
+ for idx, line in enumerate(self._diff.split("\n")):
+ line_id = idx + 1
+ match = FILE_REGEX.match(line)
+ if match:
+ # start of a new file, extract the name and reset the position
+ file_path = match.groups()[-1]
+ position = None
+ elif line.startswith("@@"):
+ # start of new hunk
+ if position is None:
+ # 1st hunk of file, start tracking position
+ position = 0
+ else:
+ # new hunk same file, just ignore but keep tracking position
+ pass
+ elif position is not None:
+ # code line, increment position and keep track of it in the index
+ position += 1
+ self._index[line_id] = LineInfo(file_path=file_path, position=position)
+
+
+@dataclass
+class Comment:
+ body: str
+ comment_id: Optional[int] = None
+
+
+@dataclass
+class ReviewComments:
+ # top-level comment
+ body: str
+
+ # line-based code comments
+ comments: Dict[LineInfo, Comment]
+
+
+class PullWrapper:
+ def __init__(self, torngit: TorngitBaseAdapter, pullid: int):
+ self.torngit = torngit
+ self.pullid = pullid
+ self._head_sha = None
+
+ @property
+ def token(self):
+ return self.torngit.get_token_by_type_if_none(None, TokenType.read)
+
+ async def fetch_diff(self) -> str:
+ async with self.torngit.get_client() as client:
+ diff = await self.torngit.api(
+ client,
+ "get",
+ f"/repos/{self.torngit.slug}/pulls/{self.pullid}",
+ token=self.token,
+ headers={"Accept": "application/vnd.github.v3.diff"},
+ )
+ return diff
+
+ async def fetch_head_sha(self) -> str:
+ if self._head_sha is not None:
+ return self._head_sha
+
+ async with self.torngit.get_client() as client:
+ res = await self.torngit.api(
+ client,
+ "get",
+ f"/repos/{self.torngit.slug}/pulls/{self.pullid}",
+ token=self.token,
+ )
+ self._head_sha = res["head"]["sha"]
+ return self._head_sha
+
+ async def fetch_review_comments(self):
+ async with self.torngit.get_client() as client:
+ page = 1
+ while True:
+ res = await self.torngit.api(
+ client,
+ "get",
+ f"/repos/{self.torngit.slug}/pulls/{self.pullid}/comments?per_page=100&page={page}",
+ token=self.token,
+ )
+ if len(res) == 0:
+ break
+ for item in res:
+ yield item
+ page += 1
+
+ async def create_review(self, commit_sha: str, review_comments: ReviewComments):
+ body = dict(
+ commit_id=commit_sha,
+ body=review_comments.body,
+ event="COMMENT",
+ comments=[
+ {
+ "path": line_info.file_path,
+ "position": line_info.position,
+ "body": comment.body,
+ }
+ for line_info, comment in review_comments.comments.items()
+ ],
+ )
+ log.info(
+ "Creating AI PR review",
+ extra=body,
+ )
+
+ async with self.torngit.get_client() as client:
+ res = await self.torngit.api(
+ client,
+ "post",
+ f"/repos/{self.torngit.slug}/pulls/{self.pullid}/reviews",
+ token=self.token,
+ body=body,
+ )
+ return res
+
+ async def update_comment(self, comment: Comment):
+ log.info(
+ "Updating comment",
+ extra=dict(
+ comment_id=comment.comment_id,
+ body=comment.body,
+ ),
+ )
+ async with self.torngit.get_client() as client:
+ await self.torngit.api(
+ client,
+ "patch",
+ f"/repos/{self.torngit.slug}/pulls/comments/{comment.comment_id}",
+ token=self.token,
+ body=dict(body=comment.body),
+ )
+
+
+class Review:
+ def __init__(
+ self, pull_wrapper: PullWrapper, review_ids: Optional[List[int]] = None
+ ):
+ self.pull_wrapper = pull_wrapper
+ self.review_ids = review_ids or []
+ self.diff = None
+
+ async def perform(self) -> Optional[int]:
+ raw_diff = await self.pull_wrapper.fetch_diff()
+ self.diff = Diff(raw_diff)
+
+ prompt = build_prompt(self.diff.preprocessed)
+ log.debug(
+ "OpenAI prompt",
+ extra=dict(
+ prompt=prompt,
+ ),
+ )
+
+ res = await fetch_openai_completion(prompt)
+ log.debug(
+ "OpenAI response",
+ extra=dict(
+ res=res,
+ ),
+ )
+
+ try:
+ data = json.loads(res)
+ except json.decoder.JSONDecodeError:
+ metrics.incr("ai_pr_review.non_json_completion")
+ log.error(
+ "OpenAI completion was expected to be JSON but wasn't",
+ extra=dict(res=res),
+ exc_info=True,
+ )
+ return
+
+ try:
+ review_comments = ReviewComments(
+ body=data["body"],
+ comments={
+ self.diff.line_info(comment["line_id"]): Comment(
+ body=comment["body"]
+ )
+ for comment in data["comments"]
+ },
+ )
+ except KeyError:
+ metrics.incr("ai_pr_review.malformed_completion")
+ log.error(
+ "OpenAI completion JSON was not formed as expected",
+ extra=dict(data=data),
+ exc_info=True,
+ )
+ return
+
+ if len(self.review_ids) > 0:
+ comments_to_update = []
+ async for comment in self.pull_wrapper.fetch_review_comments():
+ if comment["pull_request_review_id"] not in self.review_ids:
+ continue
+
+ line_info = LineInfo(
+ file_path=comment["path"],
+ position=comment["position"],
+ )
+ if line_info in review_comments.comments:
+ # we have an existing comment on this line that we'll need
+ # to update instead of create
+ line_comment = review_comments.comments[line_info]
+
+ # we'll update this existing comment
+ line_comment.comment_id = comment["id"]
+ comments_to_update.append(line_comment)
+
+ # remove it from the current review comments since those will
+ # be posted as new comments
+ del review_comments.comments[line_info]
+
+ for comment in comments_to_update:
+ await self.pull_wrapper.update_comment(comment)
+
+ if len(review_comments.comments) > 0:
+ head_commit_sha = await self.pull_wrapper.fetch_head_sha()
+ if len(self.review_ids) > 0:
+ # we already made a top-level comment w/ summary of suggestions,
+ # this is more of a placeholder since we're obligated to pass a top-level
+ # comment when creating a new review
+ review_comments.body = (
+ f"CodecovAI submitted a new review for {head_commit_sha}"
+ )
+ res = await self.pull_wrapper.create_review(
+ head_commit_sha, review_comments
+ )
+ return res["id"], head_commit_sha
+
+
+async def perform_review(repository: Repository, pullid: int):
+ repository_service = get_repo_provider_service(repository)
+ pull_wrapper = PullWrapper(repository_service, pullid)
+
+ archive = ArchiveService(repository)
+ archive_path = f"ai_pr_review/{archive.storage_hash}/pull_{pullid}.json"
+
+ archive_data = None
+ try:
+ archive_data = archive.read_file(archive_path)
+ archive_data = json.loads(archive_data)
+ except FileNotInStorageError:
+ pass
+
+ commit_sha = None
+ review_ids = []
+ if archive_data is not None:
+ commit_sha = archive_data.get("commit_sha")
+ review_ids = archive_data.get("review_ids", [])
+
+ head_sha = await pull_wrapper.fetch_head_sha()
+ if head_sha == commit_sha:
+ log.info(
+ "Review already performed on SHA",
+ extra=dict(sha=head_sha),
+ )
+ return
+
+ review = Review(pull_wrapper, review_ids=review_ids)
+ res = await review.perform()
+ if res is not None:
+ # we created a new review
+ review_id, commit_sha = res
+ review_ids.append(review_id)
+
+ archive.write_file(
+ archive_path,
+ json.dumps(
+ {
+ "commit_sha": commit_sha,
+ "review_ids": review_ids,
+ }
+ ),
+ )
diff --git a/apps/worker/services/archive.py b/apps/worker/services/archive.py
new file mode 100644
index 0000000000..c54be2c818
--- /dev/null
+++ b/apps/worker/services/archive.py
@@ -0,0 +1,181 @@
+import json
+import logging
+from base64 import b16encode
+from datetime import datetime
+from enum import Enum
+from hashlib import md5
+
+import sentry_sdk
+import shared.storage
+from shared.config import get_config
+from shared.utils.ReportEncoder import ReportEncoder
+
+from helpers.metrics import metrics
+
+log = logging.getLogger(__name__)
+
+
+class MinioEndpoints(Enum):
+ chunks = "{version}/repos/{repo_hash}/commits/{commitid}/{chunks_file_name}.txt"
+ json_data = "{version}/repos/{repo_hash}/commits/{commitid}/json_data/{table}/{field}/{external_id}.json"
+ json_data_no_commit = (
+ "{version}/repos/{repo_hash}/json_data/{table}/{field}/{external_id}.json"
+ )
+ raw = "v4/raw/{date}/{repo_hash}/{commit_sha}/{reportid}.txt"
+ computed_comparison = "{version}/repos/{repo_hash}/comparisons/{comparison_id}.json"
+
+ def get_path(self, **kwaargs) -> str:
+ return self.value.format(**kwaargs)
+
+
+# Service class for performing archive operations. Meant to work against the
+# underlying StorageService
+class ArchiveService(object):
+ root: str
+ """
+ The root level of the archive. In s3 terms,
+ this would be the name of the bucket
+ """
+
+ storage_hash: str
+ """
+ A hash key of the repo for internal storage
+ """
+
+ def __init__(self, repository, bucket=None) -> None:
+ if bucket is None:
+ self.root = get_config("services", "minio", "bucket", default="archive")
+ else:
+ self.root = bucket
+ self.storage = shared.storage.get_appropriate_storage_service(repository.repoid)
+ log.debug("Getting archive hash")
+ self.storage_hash = self.get_archive_hash(repository)
+
+ def get_now(self) -> datetime:
+ return datetime.now()
+
+ @classmethod
+ def get_archive_hash(cls, repository) -> str:
+ """
+ Generates a hash key from repo specific information.
+ Provides slight obfuscation of data in minio storage
+ """
+ _hash = md5()
+ hash_key = get_config("services", "minio", "hash_key")
+ val = "".join(
+ map(
+ str,
+ (
+ repository.repoid,
+ repository.service,
+ repository.service_id,
+ hash_key,
+ ),
+ )
+ ).encode()
+ _hash.update(val)
+ return b16encode(_hash.digest()).decode()
+
+ @sentry_sdk.trace
+ def write_file(
+ self, path, data, reduced_redundancy=False, *, is_already_gzipped=False
+ ) -> None:
+ """
+ Writes a generic file to the archive -- it's typically recommended to
+ not use this in lieu of the convenience method `write_chunks`
+ """
+ self.storage.write_file(
+ self.root,
+ path,
+ data,
+ reduced_redundancy=reduced_redundancy,
+ is_already_gzipped=is_already_gzipped,
+ )
+
+ def write_computed_comparison(self, comparison, data) -> str:
+ path = MinioEndpoints.computed_comparison.get_path(
+ version="v4", repo_hash=self.storage_hash, comparison_id=comparison.id
+ )
+ self.write_file(path, json.dumps(data))
+ return path
+
+ def write_json_data_to_storage(
+ self,
+ commit_id,
+ table: str,
+ field: str,
+ external_id: str,
+ data: dict,
+ *,
+ encoder=ReportEncoder,
+ ):
+ if commit_id is None:
+ # Some classes don't have a commit associated with them
+ # For example Pull belongs to multiple commits.
+ path = MinioEndpoints.json_data_no_commit.get_path(
+ version="v4",
+ repo_hash=self.storage_hash,
+ table=table,
+ field=field,
+ external_id=external_id,
+ )
+ else:
+ path = MinioEndpoints.json_data.get_path(
+ version="v4",
+ repo_hash=self.storage_hash,
+ commitid=commit_id,
+ table=table,
+ field=field,
+ external_id=external_id,
+ )
+ stringified_data = json.dumps(data, cls=encoder)
+ self.write_file(path, stringified_data)
+ return path
+
+ def write_chunks(self, commit_sha, data, report_code=None) -> str:
+ """
+ Convenience method to write a chunks.txt file to storage.
+ """
+ chunks_file_name = report_code if report_code is not None else "chunks"
+ path = MinioEndpoints.chunks.get_path(
+ version="v4",
+ repo_hash=self.storage_hash,
+ commitid=commit_sha,
+ chunks_file_name=chunks_file_name,
+ )
+
+ self.write_file(path, data)
+ return path
+
+ @sentry_sdk.trace
+ def read_file(self, path: str) -> bytes:
+ """
+ Generic method to read a file from the archive
+ """
+ with metrics.timer("services.archive.read_file") as t:
+ contents = self.storage.read_file(self.root, path)
+ log.debug(
+ "Downloaded file", extra=dict(timing_ms=t.ms, content_len=len(contents))
+ )
+ return contents
+
+ @sentry_sdk.trace
+ def delete_file(self, path) -> None:
+ """
+ Generic method to delete a file from the archive.
+ """
+ self.storage.delete_file(self.root, path)
+
+ def read_chunks(self, commit_sha, report_code=None) -> str:
+ """
+ Convenience method to read a chunks file from the archive.
+ """
+ chunks_file_name = report_code if report_code is not None else "chunks"
+ path = MinioEndpoints.chunks.get_path(
+ version="v4",
+ repo_hash=self.storage_hash,
+ commitid=commit_sha,
+ chunks_file_name=chunks_file_name,
+ )
+
+ return self.read_file(path).decode(errors="replace")
diff --git a/apps/worker/services/bundle_analysis/__init__.py b/apps/worker/services/bundle_analysis/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/bundle_analysis/comparison.py b/apps/worker/services/bundle_analysis/comparison.py
new file mode 100644
index 0000000000..c6fa10f157
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/comparison.py
@@ -0,0 +1,83 @@
+from functools import cached_property
+
+import shared.storage
+from shared.bundle_analysis import (
+ BundleAnalysisComparison,
+ BundleAnalysisReportLoader,
+)
+
+from database.enums import ReportType
+from database.models.core import Commit, Repository
+from database.models.reports import CommitReport
+from services.archive import ArchiveService
+from services.bundle_analysis.exceptions import (
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingHeadCommit,
+ MissingHeadReport,
+)
+from services.repository import EnrichedPull
+
+
+class ComparisonLoader:
+ def __init__(self, base_commit: Commit | None, head_commit: Commit | None):
+ self._base_commit: Commit | None = base_commit
+ self._head_commit: Commit | None = head_commit
+
+ @classmethod
+ def from_EnrichedPull(cls, pull: EnrichedPull) -> "ComparisonLoader":
+ return cls(
+ base_commit=pull.database_pull.get_comparedto_commit(),
+ head_commit=pull.database_pull.get_head_commit(),
+ )
+
+ @cached_property
+ def repository(self) -> Repository:
+ return self.head_commit.repository
+
+ @cached_property
+ def base_commit(self) -> Commit:
+ commit = self._base_commit
+ if commit is None:
+ raise MissingBaseCommit()
+ return commit
+
+ @cached_property
+ def head_commit(self) -> Commit:
+ commit = self._head_commit
+ if commit is None:
+ raise MissingHeadCommit()
+ return commit
+
+ @cached_property
+ def base_commit_report(self) -> CommitReport:
+ commit_report = self.base_commit.commit_report(
+ report_type=ReportType.BUNDLE_ANALYSIS
+ )
+ if commit_report is None:
+ raise MissingBaseReport()
+ return commit_report
+
+ @cached_property
+ def head_commit_report(self) -> CommitReport:
+ commit_report = self.head_commit.commit_report(
+ report_type=ReportType.BUNDLE_ANALYSIS
+ )
+ if commit_report is None:
+ raise MissingHeadReport()
+ return commit_report
+
+ def get_comparison(self) -> BundleAnalysisComparison:
+ loader = BundleAnalysisReportLoader(
+ storage_service=shared.storage.get_appropriate_storage_service(
+ self.repository.repoid
+ ),
+ repo_key=ArchiveService.get_archive_hash(self.repository),
+ )
+
+ return BundleAnalysisComparison(
+ loader=loader,
+ base_report_key=self.base_commit_report.external_id,
+ head_report_key=self.head_commit_report.external_id,
+ repository=self.repository.repoid,
+ )
diff --git a/apps/worker/services/bundle_analysis/exceptions.py b/apps/worker/services/bundle_analysis/exceptions.py
new file mode 100644
index 0000000000..edbc6b4311
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/exceptions.py
@@ -0,0 +1,18 @@
+class ComparisonError(Exception):
+ pass
+
+
+class MissingBaseCommit(ComparisonError):
+ pass
+
+
+class MissingBaseReport(ComparisonError):
+ pass
+
+
+class MissingHeadCommit(ComparisonError):
+ pass
+
+
+class MissingHeadReport(ComparisonError):
+ pass
diff --git a/apps/worker/services/bundle_analysis/notify/__init__.py b/apps/worker/services/bundle_analysis/notify/__init__.py
new file mode 100644
index 0000000000..93fa982e8f
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/__init__.py
@@ -0,0 +1,204 @@
+import logging
+from typing import NamedTuple, Never
+
+import sentry_sdk
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME, Commit, Owner
+from services.bundle_analysis.notify.contexts import (
+ BaseBundleAnalysisNotificationContext,
+ NotificationContextBuilder,
+ NotificationContextBuildError,
+)
+from services.bundle_analysis.notify.contexts.comment import (
+ BundleAnalysisPRCommentContextBuilder,
+)
+from services.bundle_analysis.notify.contexts.commit_status import (
+ CommitStatusNotificationContextBuilder,
+)
+from services.bundle_analysis.notify.helpers import get_notification_types_configured
+from services.bundle_analysis.notify.messages import MessageStrategyInterface
+from services.bundle_analysis.notify.messages.comment import (
+ BundleAnalysisCommentMarkdownStrategy,
+)
+from services.bundle_analysis.notify.messages.commit_status import (
+ CommitStatusMessageStrategy,
+)
+from services.bundle_analysis.notify.types import NotificationSuccess, NotificationType
+
+log = logging.getLogger(__name__)
+
+
+class NotificationFullContext(NamedTuple):
+ notification_context: BaseBundleAnalysisNotificationContext
+ message_strategy: MessageStrategyInterface
+
+
+class BundleAnalysisNotifyReturn(NamedTuple):
+ notifications_configured: tuple[NotificationType, ...]
+ notifications_attempted: tuple[NotificationType, ...] | tuple[Never]
+ notifications_successful: tuple[NotificationType, ...] | tuple[Never]
+
+ def to_NotificationSuccess(self) -> NotificationSuccess:
+ notification_configured_count = len(self.notifications_configured)
+ notifications_successful_count = len(self.notifications_successful)
+ if notification_configured_count == 0:
+ return NotificationSuccess.NOTHING_TO_NOTIFY
+ if notification_configured_count == notifications_successful_count:
+ return NotificationSuccess.FULL_SUCCESS
+ return NotificationSuccess.PARTIAL_SUCCESS
+
+
+class BundleAnalysisNotifyService:
+ def __init__(
+ self,
+ commit: Commit,
+ current_yaml: UserYaml,
+ gh_app_installation_name: str | None = None,
+ ):
+ self.commit = commit
+ self.current_yaml = current_yaml
+ self.gh_app_installation_name = (
+ gh_app_installation_name or GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ @property
+ def owner(self) -> Owner:
+ return self.commit.repository.owner
+
+ @sentry_sdk.trace
+ def build_base_context(self) -> BaseBundleAnalysisNotificationContext | None:
+ try:
+ return (
+ NotificationContextBuilder()
+ .initialize(
+ self.commit, self.current_yaml, self.gh_app_installation_name
+ )
+ .build_context()
+ .get_result()
+ )
+ except NotificationContextBuildError as exp:
+ log.warning(
+ "Failed to build NotificationContext",
+ extra=dict(
+ notification_type="base_context", failed_step=exp.failed_step
+ ),
+ )
+ return None
+
+ def create_context_for_notification(
+ self,
+ base_context: BaseBundleAnalysisNotificationContext,
+ notification_type: NotificationType,
+ ) -> NotificationFullContext | None:
+ """Builds the NotificationContext for the given notification_type
+ If the NotificationContext failed to build we can't send this notification.
+
+ Each NotificationType is paired with a ContextBuilder and MessageStrategyInterface.
+ The MessageStrategy is later used to build and send the message based on the NotificationContext
+ """
+ notifier_lookup: dict[
+ NotificationType,
+ tuple[NotificationContextBuilder, MessageStrategyInterface],
+ ] = {
+ NotificationType.PR_COMMENT: (
+ BundleAnalysisPRCommentContextBuilder(),
+ BundleAnalysisCommentMarkdownStrategy(),
+ ),
+ NotificationType.COMMIT_STATUS: (
+ CommitStatusNotificationContextBuilder(),
+ CommitStatusMessageStrategy(),
+ ),
+ # The commit-check API is more powerful than COMMIT_STATUS
+ # but we currently don't differentiate between them
+ NotificationType.GITHUB_COMMIT_CHECK: (
+ CommitStatusNotificationContextBuilder(),
+ CommitStatusMessageStrategy(),
+ ),
+ }
+ notifier_strategy = notifier_lookup.get(notification_type)
+ if notifier_strategy is None:
+ msg = f"No context builder for {notification_type}. Skipping"
+ log.error(msg)
+ return None
+ builder_instance, message_strategy_instance = notifier_strategy
+ try:
+ builder = builder_instance.initialize_from_context(
+ self.current_yaml, base_context
+ )
+ return NotificationFullContext(
+ builder.build_context().get_result(),
+ message_strategy_instance,
+ )
+ except NotificationContextBuildError as exp:
+ log.warning(
+ "Failed to build NotificationContext",
+ extra=dict(
+ notification_type=notification_type, failed_step=exp.failed_step
+ ),
+ )
+ return None
+
+ def get_notification_contexts(
+ self,
+ base_context: BaseBundleAnalysisNotificationContext,
+ notification_types: tuple[NotificationType, ...],
+ ) -> list[NotificationFullContext]:
+ previous_context = base_context
+ specific_contexts = []
+ for notification_type in notification_types:
+ full_context = self.create_context_for_notification(
+ previous_context, notification_type
+ )
+ if full_context:
+ previous_context = full_context.notification_context
+ specific_contexts.append(full_context)
+ return specific_contexts
+
+ def notify(self) -> BundleAnalysisNotifyReturn:
+ """Entrypoint for BundleAnalysis notifications. This function does the following:
+ 1. Gets the configured notifications. Those are the ones we must send;
+ 2. Attempts to build a BaseContext with necessary info for all notifications;
+ 3. Attempts to build a Context for each notification to be sent;
+ 4. For each notification with a context, build and send the message.
+
+ Returns: BundleAnalysisNotifyReturn - tuple with notifications configured and the ones
+ that we successfully notified.
+ """
+ notification_types = get_notification_types_configured(
+ self.current_yaml, self.owner
+ )
+ base_context = self.build_base_context()
+ if base_context is None:
+ log.warning("Skipping ALL notifications because there's no base context")
+ return BundleAnalysisNotifyReturn(
+ notifications_configured=notification_types,
+ notifications_attempted=tuple(),
+ notifications_successful=tuple(),
+ )
+
+ notification_full_contexts = self.get_notification_contexts(
+ base_context, notification_types
+ )
+ notifications_sent = []
+ notifications_successful = []
+ for notification_context, message_strategy in notification_full_contexts:
+ message = message_strategy.build_message(notification_context)
+ result = message_strategy.send_message(notification_context, message)
+ if result.notification_attempted:
+ notifications_sent.append(notification_context.notification_type)
+ if result.notification_successful:
+ notifications_successful.append(notification_context.notification_type)
+ log.info(
+ "Notification done",
+ extra=dict(
+ notification_type=notification_context.notification_type,
+ notification_result=result,
+ ),
+ )
+
+ return BundleAnalysisNotifyReturn(
+ notifications_configured=notification_types,
+ notifications_attempted=tuple(notifications_sent),
+ notifications_successful=tuple(notifications_successful),
+ )
diff --git a/apps/worker/services/bundle_analysis/notify/conftest.py b/apps/worker/services/bundle_analysis/notify/conftest.py
new file mode 100644
index 0000000000..bef75d1359
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/conftest.py
@@ -0,0 +1,81 @@
+from pathlib import Path
+
+from shared.bundle_analysis.storage import get_bucket_name
+
+from database.enums import ReportType
+from database.models.core import Commit, Repository
+from database.models.reports import CommitReport
+from database.tests.factories.core import CommitFactory, PullFactory
+from services.archive import ArchiveService
+from services.repository import EnrichedPull
+
+SAMPLE_FOLDER_PATH = Path(__file__).resolve().parent / "tests" / "samples"
+
+
+def get_commit_pair(dbsession) -> tuple[Commit, Commit]:
+ base_commit = CommitFactory(repository__owner__service="github")
+ head_commit = CommitFactory(repository=base_commit.repository)
+ dbsession.add_all([base_commit, head_commit])
+ dbsession.commit()
+ return (head_commit, base_commit)
+
+
+def get_report_pair(dbsession, commit_pair) -> tuple[CommitReport, CommitReport]:
+ head_commit, base_commit = commit_pair
+ base_commit_report = CommitReport(
+ commit=base_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ head_commit_report = CommitReport(
+ commit=head_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add_all([base_commit_report, head_commit_report])
+ dbsession.commit()
+ return (head_commit_report, base_commit_report)
+
+
+def get_enriched_pull_setting_up_mocks(dbsession, mocker, commit_pair) -> EnrichedPull:
+ head_commit, base_commit = commit_pair
+ pull = PullFactory(
+ repository=base_commit.repository,
+ head=head_commit.commitid,
+ base=base_commit.commitid,
+ compared_to=base_commit.commitid,
+ )
+ dbsession.add(pull)
+ dbsession.commit()
+ enriched_pull = EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ )
+ fake_pull_patches_to_apply = [
+ "services.bundle_analysis.notify.contexts.comment.fetch_and_update_pull_request_information_from_commit",
+ "services.bundle_analysis.notify.contexts.commit_status.fetch_and_update_pull_request_information_from_commit",
+ ]
+ for patch_to_apply in fake_pull_patches_to_apply:
+ mocker.patch(
+ patch_to_apply,
+ return_value=enriched_pull,
+ )
+ fake_repo_service = mocker.MagicMock(name="fake_repo_service")
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=fake_repo_service,
+ )
+
+ return enriched_pull
+
+
+def save_mock_bundle_analysis_report(
+ repository: Repository,
+ commit_report: CommitReport,
+ mock_storage,
+ sample_report_number,
+) -> None:
+ repo_key = ArchiveService.get_archive_hash(repository)
+ sample_path = SAMPLE_FOLDER_PATH / f"sample_{sample_report_number}.sqlite"
+ sample_contents = sample_path.read_bytes()
+ mock_storage.write_file(
+ get_bucket_name(),
+ f"v1/repos/{repo_key}/{commit_report.external_id}/bundle_report.sqlite",
+ sample_contents,
+ )
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/__init__.py b/apps/worker/services/bundle_analysis/notify/contexts/__init__.py
new file mode 100644
index 0000000000..139813558b
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/__init__.py
@@ -0,0 +1,234 @@
+from enum import Enum, auto
+from functools import cached_property
+from typing import Generic, Literal, Self, TypeVar
+
+import sentry_sdk
+import shared.storage
+from shared.bundle_analysis import BundleAnalysisReport, BundleAnalysisReportLoader
+from shared.torngit.base import TorngitBaseAdapter
+from shared.typings.torngit import AdditionalData, UploadType
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.enums import ReportType
+from database.models.core import Commit, Repository
+from database.models.reports import CommitReport
+from services.archive import ArchiveService
+from services.bundle_analysis.notify.helpers import to_BundleThreshold
+from services.bundle_analysis.notify.types import (
+ NotificationType,
+ NotificationUserConfig,
+)
+from services.repository import get_repo_provider_service
+
+T = TypeVar("T")
+
+
+class ContextNotLoadedError(Exception):
+ pass
+
+
+class CommitStatusLevel(Enum):
+ INFO = auto()
+ WARNING = auto()
+ ERROR = auto()
+
+ def to_str(self) -> Literal["success"] | Literal["failure"]:
+ if self.value == "ERROR":
+ return "failure"
+ return "success"
+
+
+class NotificationContextField(Generic[T]):
+ """NotificationContextField is a descriptor akin to a Django model field.
+ If you create one as a class member named `foo`, it will define the behavior to get and set an instance member named `foo`.
+ It is also similar to @property
+ """
+
+ def __set_name__(self, owner, name) -> None:
+ self._name = name
+
+ def __get__(self, instance, owner) -> T:
+ if self._name not in instance.__dict__:
+ msg = f"Property {self._name} is not loaded. Make sure to build the context before using it."
+ raise ContextNotLoadedError(msg)
+ return instance.__dict__[self._name]
+
+ def __set__(self, instance, value: T) -> None:
+ instance.__dict__[self._name] = value
+
+
+class BaseBundleAnalysisNotificationContext:
+ """Base NotificationContext for bundle analysis notifications.
+ It includes basic information that all bundle analysis notifications need.
+ Use NotificationContextBuilder to populate the context.
+
+ Example:
+ builder = NotificationContextBuilder(commit, current_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME)
+ notification_context = builder.build_context().get_result()
+ """
+
+ notification_type: NotificationType
+
+ def __init__(self, commit: Commit, gh_app_installation_name: str) -> None:
+ self.commit = commit
+ self.gh_app_installation_name = gh_app_installation_name
+
+ @cached_property
+ def repository(self) -> Repository:
+ return self.commit.repository
+
+ @cached_property
+ def repository_service(self) -> TorngitBaseAdapter:
+ additional_data: AdditionalData = {"upload_type": UploadType.BUNDLE_ANALYSIS}
+ return get_repo_provider_service(
+ self.repository,
+ installation_name_to_use=self.gh_app_installation_name,
+ additional_data=additional_data,
+ )
+
+ commit_report = NotificationContextField[CommitReport]()
+ bundle_analysis_report = NotificationContextField[BundleAnalysisReport]()
+ user_config = NotificationContextField[NotificationUserConfig]()
+
+
+class NotificationContextBuildError(Exception):
+ def __init__(self, failed_step: str, detail: str | None = None) -> None:
+ super().__init__(failed_step, detail)
+ self.failed_step = failed_step
+ self.detail = detail
+
+
+class WrongContextBuilderError(Exception):
+ pass
+
+
+class NotificationContextBuilder:
+ """Creates the BaseBundleAnalysisNotificationContext one step at a time, in the correct order."""
+
+ current_yaml: UserYaml
+ """ Used with `initialize_from_context` method. Declare the fields the class wants to copy over when initializing from another context."""
+ fields_of_interest: tuple[str, ...] = (
+ "commit_report",
+ "bundle_analysis_report",
+ "user_config",
+ )
+
+ def initialize(
+ self, commit: Commit, current_yaml: UserYaml, gh_app_installation_name: str
+ ) -> "NotificationContextBuilder":
+ self.current_yaml = current_yaml
+ self._notification_context = BaseBundleAnalysisNotificationContext(
+ commit=commit,
+ gh_app_installation_name=gh_app_installation_name,
+ )
+ return self
+
+ def initialize_from_context(
+ self, current_yaml: UserYaml, context: BaseBundleAnalysisNotificationContext
+ ) -> Self:
+ self.initialize(
+ commit=context.commit,
+ current_yaml=current_yaml,
+ gh_app_installation_name=context.gh_app_installation_name,
+ )
+
+ for field_name in self.fields_of_interest:
+ if field_name in context.__dict__:
+ self._notification_context.__dict__[field_name] = context.__dict__[
+ field_name
+ ]
+ return self
+
+ def is_field_loaded(self, field_name: str):
+ return field_name in self._notification_context.__dict__
+
+ def load_commit_report(self) -> "NotificationContextBuilder":
+ """Loads the CommitReport into the NotificationContext
+ Raises:
+ NotificationContextBuildError: no CommitReport exist for the commit
+ """
+ if self.is_field_loaded("commit_report"):
+ return self
+ commit_report = self._notification_context.commit.commit_report(
+ report_type=ReportType.BUNDLE_ANALYSIS
+ )
+ if commit_report is None:
+ raise NotificationContextBuildError("load_commit_report")
+ self._notification_context.commit_report = commit_report
+ return self
+
+ @sentry_sdk.trace
+ def load_bundle_analysis_report(self) -> "NotificationContextBuilder":
+ """Loads the BundleAnalysisReport into the NotificationContext
+ BundleAnalysisReport is an SQLite report generated by processing uploads
+ Raises:
+ NotificationContextBuildError: no BundleAnalysisReport exists for the commit.
+ """
+ if self.is_field_loaded("bundle_analysis_report"):
+ return self
+ repo_hash = ArchiveService.get_archive_hash(
+ self._notification_context.repository
+ )
+ storage_service = shared.storage.get_appropriate_storage_service(
+ self._notification_context.repository.repoid
+ )
+ analysis_report_loader = BundleAnalysisReportLoader(storage_service, repo_hash)
+ bundle_analysis_report = analysis_report_loader.load(
+ self._notification_context.commit_report.external_id
+ )
+ if bundle_analysis_report is None:
+ raise NotificationContextBuildError("load_bundle_analysis_report")
+ self._notification_context.bundle_analysis_report = bundle_analysis_report
+ return self
+
+ def load_user_config(self) -> "NotificationContextBuilder":
+ """Parses the configuration from the `current_yaml` related to bundle analysis notification
+ into a NotificationUserConfig object for the context.
+
+ This allows all notifiers to access configuration for any notifier and already have the defaults
+ """
+ comment_config: bool | dict = self.current_yaml.read_yaml_field("comment")
+ required_changes: bool | Literal["bundle_increase"]
+ if isinstance(comment_config, bool):
+ required_changes = comment_config
+ required_changes_threshold = BundleThreshold("absolute", 0)
+ else:
+ required_changes = comment_config.get("require_bundle_changes", False)
+ required_changes_threshold = comment_config.get(
+ "bundle_change_threshold",
+ BundleThreshold("absolute", 0),
+ )
+ warning_threshold: int | float = self.current_yaml.read_yaml_field(
+ "bundle_analysis",
+ "warning_threshold",
+ _else=BundleThreshold("percentage", 5.0),
+ )
+ status_level: bool | Literal["informational"] = (
+ self.current_yaml.read_yaml_field(
+ "bundle_analysis", "status", _else="informational"
+ )
+ )
+ self._notification_context.user_config = NotificationUserConfig(
+ required_changes=required_changes,
+ warning_threshold=to_BundleThreshold(warning_threshold),
+ status_level=status_level,
+ required_changes_threshold=to_BundleThreshold(required_changes_threshold),
+ )
+ return self
+
+ def build_context(self) -> "NotificationContextBuilder":
+ """Calls all the steps necessary to fully load the NotificationContext
+ Raises:
+ NotificationContextBuildError: if any of the steps fail
+ """
+ self.load_user_config()
+ self.load_commit_report()
+ self.load_bundle_analysis_report()
+ return self
+
+ def get_result(self) -> BaseBundleAnalysisNotificationContext:
+ """Returns the NotificationContext.
+ Should be called after `build_context`, or you get an empty context back.
+ """
+ return self._notification_context
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/comment.py b/apps/worker/services/bundle_analysis/notify/contexts/comment.py
new file mode 100644
index 0000000000..c50532bcf7
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/comment.py
@@ -0,0 +1,213 @@
+import logging
+from typing import Self
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.bundle_analysis import (
+ BundleAnalysisComparison,
+)
+from shared.yaml import UserYaml
+
+from database.models.core import Commit
+from services.activation import activate_user, schedule_new_user_activated_task
+from services.bundle_analysis.comparison import ComparisonLoader
+from services.bundle_analysis.exceptions import (
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingHeadCommit,
+ MissingHeadReport,
+)
+from services.bundle_analysis.notify.contexts import (
+ BaseBundleAnalysisNotificationContext,
+ CommitStatusLevel,
+ NotificationContextBuilder,
+ NotificationContextBuildError,
+ NotificationContextField,
+)
+from services.bundle_analysis.notify.helpers import (
+ is_bundle_comparison_change_within_configured_threshold,
+)
+from services.bundle_analysis.notify.types import NotificationType
+from services.repository import (
+ EnrichedPull,
+ fetch_and_update_pull_request_information_from_commit,
+)
+from services.seats import ShouldActivateSeat, determine_seat_activation
+
+log = logging.getLogger(__name__)
+
+
+class BundleAnalysisPRCommentNotificationContext(BaseBundleAnalysisNotificationContext):
+ """Context for the Bundle Analysis PR Comment. Extends BaseBundleAnalysisNotificationContext."""
+
+ notification_type = NotificationType.PR_COMMENT
+
+ pull = NotificationContextField[EnrichedPull]()
+ bundle_analysis_comparison = NotificationContextField[BundleAnalysisComparison]()
+ commit_status_level = NotificationContextField[CommitStatusLevel]()
+ should_use_upgrade_comment = NotificationContextField[bool]()
+
+
+class BundleAnalysisPRCommentContextBuilder(NotificationContextBuilder):
+ fields_of_interest: tuple[str, ...] = (
+ "commit_report",
+ "bundle_analysis_report",
+ "user_config",
+ "pull",
+ "bundle_analysis_comparison",
+ "should_use_upgrade_comment",
+ )
+
+ def initialize(
+ self, commit: Commit, current_yaml: UserYaml, gh_app_installation_name: str
+ ) -> "BundleAnalysisPRCommentContextBuilder":
+ self.current_yaml = current_yaml
+ self._notification_context = BundleAnalysisPRCommentNotificationContext(
+ commit=commit,
+ gh_app_installation_name=gh_app_installation_name,
+ )
+ return self
+
+ @sentry_sdk.trace
+ async def load_enriched_pull(self) -> Self:
+ """Loads the EnrichedPull into the NotificationContext.
+ EnrichedPull includes updated info from the git provider and info saved in the database.
+ Raises:
+ NotificationContextBuildError: failed to get EnrichedPull.
+ This can be because there's no Pull saved in the database,
+ or because we couldn't update the pull's info from the git provider.
+ """
+ if self.is_field_loaded("pull"):
+ return self
+ pull: (
+ EnrichedPull | None
+ ) = await fetch_and_update_pull_request_information_from_commit(
+ self._notification_context.repository_service,
+ self._notification_context.commit,
+ self.current_yaml,
+ )
+ if pull is None:
+ raise NotificationContextBuildError("load_enriched_pull")
+ self._notification_context.pull = pull
+ return self
+
+ @sentry_sdk.trace
+ def load_bundle_comparison(self) -> Self:
+ """Loads the BundleAnalysisComparison into the NotificationContext.
+ BundleAnalysisComparison is the diff between 2 BundleAnalysisReports,
+ respectively the one for the pull's base and one for the pull's head.
+ Raises:
+ NotificationContextBuildError: missing some information necessary to create
+ the BundleAnalysisComparison.
+ """
+ if self.is_field_loaded("bundle_analysis_comparison"):
+ return self
+ pull = self._notification_context.pull
+ try:
+ comparison = ComparisonLoader.from_EnrichedPull(pull).get_comparison()
+ self._notification_context.bundle_analysis_comparison = comparison
+ return self
+ except (
+ MissingBaseCommit,
+ MissingHeadCommit,
+ MissingBaseReport,
+ MissingHeadReport,
+ ) as exp:
+ raise NotificationContextBuildError(
+ "load_bundle_comparison", detail=exp.__class__.__name__
+ )
+
+ def evaluate_has_enough_changes(self) -> Self:
+ """Evaluates if the NotificationContext includes enough changes to send the notification.
+ Configuration is done via UserYaml.
+ If a comment was previously made for this PR the required changes are bypassed so that we
+ update the existing comment with the latest information.
+ Raises:
+ NotificationContextBuildError: required changes are not met.
+ """
+ pull = self._notification_context.pull
+ required_changes_threshold = (
+ self._notification_context.user_config.required_changes_threshold
+ )
+ required_changes = self._notification_context.user_config.required_changes
+ if pull.database_pull.bundle_analysis_commentid:
+ log.info(
+ "Skipping required_changes verification because comment already exists",
+ extra=dict(
+ pullid=pull.database_pull.id,
+ commitid=self._notification_context.commit.commitid,
+ ),
+ )
+ return self
+ comparison = self._notification_context.bundle_analysis_comparison
+ should_continue = {
+ False: True,
+ True: not is_bundle_comparison_change_within_configured_threshold(
+ comparison,
+ required_changes_threshold,
+ compare_non_negative_numbers=True,
+ ),
+ "bundle_increase": (
+ comparison.total_size_delta > 0
+ and not is_bundle_comparison_change_within_configured_threshold(
+ comparison,
+ required_changes_threshold,
+ compare_non_negative_numbers=True,
+ )
+ ),
+ }.get(required_changes, True)
+ if not should_continue:
+ raise NotificationContextBuildError("evaluate_has_enough_changes")
+ return self
+
+ @sentry_sdk.trace
+ def evaluate_should_use_upgrade_message(self) -> Self:
+ activate_seat_info = determine_seat_activation(self._notification_context.pull)
+ match activate_seat_info.should_activate_seat:
+ case ShouldActivateSeat.AUTO_ACTIVATE:
+ successful_activation = activate_user(
+ db_session=self._notification_context.commit.get_db_session(),
+ org_ownerid=activate_seat_info.owner_id,
+ user_ownerid=activate_seat_info.author_id,
+ )
+ if successful_activation:
+ schedule_new_user_activated_task(
+ activate_seat_info.owner_id, activate_seat_info.author_id
+ )
+ self._notification_context.should_use_upgrade_comment = False
+ else:
+ self._notification_context.should_use_upgrade_comment = True
+ case ShouldActivateSeat.MANUAL_ACTIVATE:
+ self._notification_context.should_use_upgrade_comment = True
+ case ShouldActivateSeat.NO_ACTIVATE:
+ self._notification_context.should_use_upgrade_comment = False
+ return self
+
+ def load_commit_status_level(self) -> Self:
+ bundle_analysis_comparison = (
+ self._notification_context.bundle_analysis_comparison
+ )
+ user_config = self._notification_context.user_config
+
+ if is_bundle_comparison_change_within_configured_threshold(
+ bundle_analysis_comparison, user_config.warning_threshold
+ ):
+ self._notification_context.commit_status_level = CommitStatusLevel.INFO
+ elif user_config.status_level == "informational":
+ self._notification_context.commit_status_level = CommitStatusLevel.WARNING
+ else:
+ self._notification_context.commit_status_level = CommitStatusLevel.ERROR
+ return self
+
+ def build_context(self) -> Self:
+ super().build_context()
+ async_to_sync(self.load_enriched_pull)()
+ return (
+ self.load_bundle_comparison()
+ .evaluate_has_enough_changes()
+ .evaluate_should_use_upgrade_message()
+ .load_commit_status_level()
+ )
+
+ def get_result(self) -> BundleAnalysisPRCommentNotificationContext:
+ return self._notification_context # type: ignore
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/commit_status.py b/apps/worker/services/bundle_analysis/notify/contexts/commit_status.py
new file mode 100644
index 0000000000..5b7bfbf4d5
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/commit_status.py
@@ -0,0 +1,206 @@
+from typing import Self
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.bundle_analysis import (
+ BundleAnalysisComparison,
+)
+from shared.config import get_config
+from shared.yaml import UserYaml
+
+from database.models.core import Commit
+from services.activation import activate_user, schedule_new_user_activated_task
+from services.bundle_analysis.comparison import ComparisonLoader
+from services.bundle_analysis.exceptions import (
+ MissingBaseCommit,
+ MissingBaseReport,
+ MissingHeadCommit,
+ MissingHeadReport,
+)
+from services.bundle_analysis.notify.contexts import (
+ BaseBundleAnalysisNotificationContext,
+ CommitStatusLevel,
+ NotificationContextBuilder,
+ NotificationContextBuildError,
+ NotificationContextField,
+)
+from services.bundle_analysis.notify.helpers import (
+ is_bundle_comparison_change_within_configured_threshold,
+)
+from services.bundle_analysis.notify.types import NotificationType
+from services.repository import (
+ EnrichedPull,
+ fetch_and_update_pull_request_information_from_commit,
+)
+from services.seats import ShouldActivateSeat, determine_seat_activation
+from services.urls import get_bundle_analysis_pull_url, get_commit_url
+
+
+class CommitStatusNotificationContext(BaseBundleAnalysisNotificationContext):
+ notification_type = NotificationType.COMMIT_STATUS
+
+ pull = NotificationContextField[EnrichedPull | None]()
+ bundle_analysis_comparison = NotificationContextField[BundleAnalysisComparison]()
+ commit_status_level = NotificationContextField[CommitStatusLevel]()
+ commit_status_url = NotificationContextField[str]()
+ cache_ttl = NotificationContextField[int]()
+ should_use_upgrade_comment = NotificationContextField[bool]()
+
+ @property
+ def base_commit(self) -> Commit:
+ if self.pull:
+ return self.pull.database_pull.get_comparedto_commit()
+ return self.commit.get_parent_commit()
+
+
+class CommitStatusNotificationContextBuilder(NotificationContextBuilder):
+ fields_of_interest: tuple[str, ...] = (
+ "commit_report",
+ "bundle_analysis_report",
+ "user_config",
+ "pull",
+ "bundle_analysis_comparison",
+ "should_use_upgrade_comment",
+ )
+
+ def initialize(
+ self, commit: Commit, current_yaml: UserYaml, gh_app_installation_name: str
+ ) -> Self:
+ self.current_yaml = current_yaml
+ self._notification_context = CommitStatusNotificationContext(
+ commit=commit,
+ gh_app_installation_name=gh_app_installation_name,
+ )
+ return self
+
+ @sentry_sdk.trace
+ async def load_optional_enriched_pull(
+ self,
+ ) -> Self:
+ """Loads an optional EnrichedPull into the NotificationContext.
+ EnrichedPull includes updated info from the git provider and info saved in the database.
+ If the value is None it's because the commit is not in a Pull Request
+ """
+ if self.is_field_loaded("pull"):
+ return self
+ optional_pull: (
+ EnrichedPull | None
+ ) = await fetch_and_update_pull_request_information_from_commit(
+ self._notification_context.repository_service,
+ self._notification_context.commit,
+ self.current_yaml,
+ )
+ self._notification_context.pull = optional_pull
+ return self
+
+ @sentry_sdk.trace
+ def load_bundle_comparison(
+ self,
+ ) -> Self:
+ """Loads the BundleAnalysisComparison into the NotificationContext.
+ BundleAnalysisComparison is the diff between 2 BundleAnalysisReports.
+ IF pull is not None, comparison is pull's BASE vs HEAD
+ ELSE comparison is HEAD vs HEAD's parent
+
+ Raises:
+ NotificationContextBuildError: missing some information necessary to create
+ the BundleAnalysisComparison.
+ """
+ if self.is_field_loaded("bundle_analysis_comparison"):
+ return self
+ pull = self._notification_context.pull
+ try:
+ if pull is None:
+ comparison = ComparisonLoader(
+ base_commit=self._notification_context.commit.get_parent_commit(),
+ head_commit=self._notification_context.commit,
+ ).get_comparison()
+ else:
+ comparison = ComparisonLoader.from_EnrichedPull(pull).get_comparison()
+ self._notification_context.bundle_analysis_comparison = comparison
+ return self
+ except (
+ MissingBaseCommit,
+ MissingHeadCommit,
+ MissingBaseReport,
+ MissingHeadReport,
+ ) as exp:
+ raise NotificationContextBuildError(
+ "load_bundle_comparison", detail=exp.__class__.__name__
+ )
+
+ def load_commit_status_level(self) -> Self:
+ bundle_analysis_comparison = (
+ self._notification_context.bundle_analysis_comparison
+ )
+ user_config = self._notification_context.user_config
+
+ if is_bundle_comparison_change_within_configured_threshold(
+ bundle_analysis_comparison, user_config.warning_threshold
+ ):
+ self._notification_context.commit_status_level = CommitStatusLevel.INFO
+ elif user_config.status_level == "informational":
+ self._notification_context.commit_status_level = CommitStatusLevel.WARNING
+ else:
+ self._notification_context.commit_status_level = CommitStatusLevel.ERROR
+ return self
+
+ def load_commit_status_url(self) -> Self:
+ if self._notification_context.pull:
+ self._notification_context.commit_status_url = get_bundle_analysis_pull_url(
+ self._notification_context.pull.database_pull
+ )
+ else:
+ self._notification_context.commit_status_url = get_commit_url(
+ self._notification_context.commit
+ )
+ return self
+
+ def load_cache_ttl(self) -> Self:
+ self._notification_context.cache_ttl = int(
+ # using `get_config` instead of `current_yaml` because
+ # `current_yaml` does not include the install configuration
+ get_config("setup", "cache", "send_status_notification", default=600)
+ ) # 10 min default
+ return self
+
+ @sentry_sdk.trace
+ def evaluate_should_use_upgrade_message(self) -> Self:
+ if self._notification_context.pull is None:
+ self._notification_context.should_use_upgrade_comment = False
+ return self
+ activate_seat_info = determine_seat_activation(self._notification_context.pull)
+ match activate_seat_info.should_activate_seat:
+ case ShouldActivateSeat.AUTO_ACTIVATE:
+ successful_activation = activate_user(
+ db_session=self._notification_context.commit.get_db_session(),
+ org_ownerid=activate_seat_info.owner_id,
+ user_ownerid=activate_seat_info.author_id,
+ )
+ if successful_activation:
+ schedule_new_user_activated_task(
+ activate_seat_info.owner_id,
+ activate_seat_info.author_id,
+ )
+ self._notification_context.should_use_upgrade_comment = False
+ else:
+ self._notification_context.should_use_upgrade_comment = True
+ case ShouldActivateSeat.MANUAL_ACTIVATE:
+ self._notification_context.should_use_upgrade_comment = True
+ case ShouldActivateSeat.NO_ACTIVATE:
+ self._notification_context.should_use_upgrade_comment = False
+ return self
+
+ def build_context(self) -> Self:
+ super().build_context()
+ async_to_sync(self.load_optional_enriched_pull)()
+ return (
+ self.load_bundle_comparison()
+ .load_commit_status_level()
+ .evaluate_should_use_upgrade_message()
+ .load_commit_status_url()
+ .load_cache_ttl()
+ )
+
+ def get_result(self) -> CommitStatusNotificationContext:
+ return self._notification_context
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/tests/__init__.py b/apps/worker/services/bundle_analysis/notify/contexts/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/tests/test_comment_context.py b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_comment_context.py
new file mode 100644
index 0000000000..67247e09db
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_comment_context.py
@@ -0,0 +1,360 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from database.tests.factories.core import CommitFactory, PullFactory
+from services.bundle_analysis.comparison import ComparisonLoader
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_enriched_pull_setting_up_mocks,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts import (
+ ContextNotLoadedError,
+ NotificationContextBuildError,
+)
+from services.bundle_analysis.notify.contexts.comment import (
+ BundleAnalysisPRCommentContextBuilder,
+)
+from services.repository import EnrichedPull
+from services.seats import SeatActivationInfo, ShouldActivateSeat
+from tests.helpers import mock_all_plans_and_tiers
+
+
+class TestBundleAnalysisPRCommentNotificationContext:
+ @pytest.mark.asyncio
+ async def test_load_pull_not_found(self, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict({})
+ fake_repo_service = mocker.MagicMock(name="fake_repo_service")
+ mock_fetch_pr = mocker.patch(
+ "services.bundle_analysis.notify.contexts.comment.fetch_and_update_pull_request_information_from_commit",
+ return_value=None,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=fake_repo_service,
+ )
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ with pytest.raises(NotificationContextBuildError) as exp:
+ await builder.load_enriched_pull()
+ assert exp.value.failed_step == "load_enriched_pull"
+ mock_fetch_pr.assert_called_with(fake_repo_service, head_commit, user_yaml)
+
+ @pytest.mark.parametrize(
+ "expected_missing_detail",
+ [
+ pytest.param("MissingBaseCommit"),
+ pytest.param("MissingHeadCommit"),
+ pytest.param("MissingBaseReport"),
+ pytest.param("MissingHeadReport"),
+ ],
+ )
+ @pytest.mark.asyncio
+ async def test_load_bundle_comparison_missing_some_info(
+ self, expected_missing_detail, dbsession, mocker
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ sink_commit = CommitFactory(repository=head_commit.repository)
+ dbsession.add(sink_commit)
+ get_report_pair(dbsession, (head_commit, base_commit))
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict({})
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_commit_report()
+ match expected_missing_detail:
+ case "MissingBaseCommit":
+ enriched_pull.database_pull.compared_to = None
+ case "MissingHeadCommit":
+ enriched_pull.database_pull.head = None
+ case "MissingBaseReport":
+ # Deleting the BaseReport also deleted base_commit
+ # So instead we point the Pull object to a commit that doesn't have reports
+ enriched_pull.database_pull.compared_to = sink_commit.commitid
+ case "MissingHeadReport":
+ enriched_pull.database_pull.head = sink_commit.commitid
+
+ with pytest.raises(NotificationContextBuildError) as exp:
+ await builder.load_enriched_pull()
+ builder.load_bundle_comparison()
+ assert exp.value.failed_step == "load_bundle_comparison"
+ assert exp.value.detail == expected_missing_detail
+
+ @pytest.mark.parametrize(
+ "config, total_size_delta",
+ [
+ pytest.param(
+ {
+ "comment": {
+ "require_bundle_changes": "bundle_increase",
+ "bundle_change_threshold": 1000000,
+ }
+ },
+ 100,
+ id="required_increase_with_big_threshold",
+ ),
+ pytest.param(
+ {
+ "comment": {
+ "require_bundle_changes": "bundle_increase",
+ "bundle_change_threshold": 10,
+ }
+ },
+ -100,
+ id="required_increase_but_decreased",
+ ),
+ pytest.param(
+ {
+ "comment": {
+ "require_bundle_changes": True,
+ "bundle_change_threshold": 1000000,
+ }
+ },
+ 100,
+ id="required_changes_with_big_threshold",
+ ),
+ ],
+ )
+ def test_evaluate_changes_fail(self, config, total_size_delta, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(config)
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_user_config()
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(bundle_analysis_commentid=None, id=12),
+ )
+ builder._notification_context.pull = mock_pull
+ mock_comparison = MagicMock(
+ name="fake_bundle_analysis_comparison", total_size_delta=total_size_delta
+ )
+ builder._notification_context.bundle_analysis_comparison = mock_comparison
+ with pytest.raises(NotificationContextBuildError) as exp:
+ builder.evaluate_has_enough_changes()
+ assert exp.value.failed_step == "evaluate_has_enough_changes"
+
+ @pytest.mark.parametrize(
+ "config, total_size_delta",
+ [
+ pytest.param(
+ PATCH_CENTRIC_DEFAULT_CONFIG,
+ 100,
+ id="default_config",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": False}},
+ 100,
+ id="no_required_changes",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": True}},
+ 100,
+ id="required_changes_increase",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": True}},
+ -100,
+ id="required_changes_decrease",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": "bundle_increase"}},
+ 100,
+ id="required_increase",
+ ),
+ pytest.param(
+ {
+ "comment": {
+ "require_bundle_changes": "bundle_increase",
+ "bundle_change_threshold": 1000,
+ }
+ },
+ 1001,
+ id="required_increase_with_small_threshold",
+ ),
+ ],
+ )
+ def test_evaluate_changes_success(self, config, total_size_delta, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(config)
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_user_config()
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(bundle_analysis_commentid=None, id=12),
+ )
+ builder._notification_context.pull = mock_pull
+ mock_comparison = MagicMock(
+ name="fake_bundle_analysis_comparison", total_size_delta=total_size_delta
+ )
+ builder._notification_context.bundle_analysis_comparison = mock_comparison
+ result = builder.evaluate_has_enough_changes()
+ assert result == builder
+
+ def test_evaluate_changes_comment_exists(self, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(
+ {
+ "comment": {
+ "require_bundle_changes": "bundle_increase",
+ "bundle_change_threshold": 1000000,
+ }
+ }
+ )
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_user_config()
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(bundle_analysis_commentid=12345, id=12),
+ )
+ builder._notification_context.pull = mock_pull
+ mock_comparison = MagicMock(
+ name="fake_bundle_analysis_comparison", total_size_delta=100
+ )
+ builder._notification_context.bundle_analysis_comparison = mock_comparison
+ result = builder.evaluate_has_enough_changes()
+ assert result == builder
+
+ @pytest.mark.parametrize(
+ "activation_result, auto_activate_succeeds, expected",
+ [
+ (ShouldActivateSeat.AUTO_ACTIVATE, True, False),
+ (ShouldActivateSeat.AUTO_ACTIVATE, False, True),
+ (ShouldActivateSeat.MANUAL_ACTIVATE, False, True),
+ (ShouldActivateSeat.NO_ACTIVATE, False, False),
+ ],
+ )
+ def test_evaluate_should_use_upgrade_message(
+ self, activation_result, dbsession, auto_activate_succeeds, expected, mocker
+ ):
+ activation_result = SeatActivationInfo(
+ should_activate_seat=activation_result,
+ owner_id=1,
+ author_id=10,
+ reason="mocked",
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.comment.determine_seat_activation",
+ return_value=activation_result,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.comment.activate_user",
+ return_value=auto_activate_succeeds,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.comment.schedule_new_user_activated_task",
+ return_value=auto_activate_succeeds,
+ )
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict({})
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(bundle_analysis_commentid=12345, id=12),
+ )
+ builder._notification_context.pull = mock_pull
+ builder.evaluate_should_use_upgrade_message()
+ assert builder._notification_context.should_use_upgrade_comment == expected
+
+ @pytest.mark.django_db
+ def test_build_context(self, dbsession, mocker, mock_storage):
+ mock_all_plans_and_tiers()
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=2
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=1
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ context = builder.build_context().get_result()
+ assert context.commit_report == head_commit_report
+ assert context.bundle_analysis_report.session_count() == 18
+ assert context.pull == enriched_pull
+ assert (
+ context.bundle_analysis_comparison.base_report_key
+ == base_commit_report.external_id
+ )
+ assert (
+ context.bundle_analysis_comparison.head_report_key
+ == head_commit_report.external_id
+ )
+
+ @pytest.mark.django_db
+ def test_initialize_from_context(self, dbsession, mocker):
+ mock_all_plans_and_tiers()
+ head_commit, base_commit = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ context = builder.get_result()
+ context.commit_report = MagicMock(name="fake_commit_report")
+ context.bundle_analysis_report = MagicMock(name="fake_bundle_analysis_report")
+
+ pull = PullFactory(
+ repository=base_commit.repository,
+ head=head_commit.commitid,
+ base=base_commit.commitid,
+ compared_to=base_commit.commitid,
+ )
+ dbsession.add(pull)
+ dbsession.commit()
+ context.pull = EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ )
+
+ other_builder = BundleAnalysisPRCommentContextBuilder().initialize_from_context(
+ user_yaml, context
+ )
+ other_context = other_builder.get_result()
+
+ assert context.commit == other_context.commit
+ assert context.commit_report == other_context.commit_report
+ assert context.bundle_analysis_report == other_context.bundle_analysis_report
+ assert context.pull == other_context.pull
+ with pytest.raises(ContextNotLoadedError):
+ other_context.bundle_analysis_comparison
+
+ fake_comparison = MagicMock(
+ name="fake_comparison", percentage_delta=10.0, total_size_delta=10.0
+ )
+ mocker.patch.object(
+ ComparisonLoader, "get_comparison", return_value=fake_comparison
+ )
+ other_context = other_builder.build_context().get_result()
+
+ assert context.commit == other_context.commit
+ assert context.commit_report == other_context.commit_report
+ assert context.bundle_analysis_report == other_context.bundle_analysis_report
+ assert context.pull == other_context.pull
+ assert other_context.bundle_analysis_comparison == fake_comparison
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/tests/test_commit_status_context.py b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_commit_status_context.py
new file mode 100644
index 0000000000..dc9dd06eee
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_commit_status_context.py
@@ -0,0 +1,376 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from database.tests.factories.core import CommitFactory, PullFactory
+from services.bundle_analysis.comparison import ComparisonLoader
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_enriched_pull_setting_up_mocks,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts import (
+ ContextNotLoadedError,
+ NotificationContextBuildError,
+)
+from services.bundle_analysis.notify.contexts.commit_status import (
+ CommitStatusLevel,
+ CommitStatusNotificationContextBuilder,
+)
+from services.bundle_analysis.notify.types import NotificationUserConfig
+from services.repository import EnrichedPull
+from services.seats import SeatActivationInfo, ShouldActivateSeat
+from tests.helpers import mock_all_plans_and_tiers
+
+
+class TestBundleAnalysisPRCommentNotificationContext:
+ @pytest.mark.asyncio
+ async def test_load_pull_not_found(self, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict({})
+ fake_repo_service = mocker.MagicMock(name="fake_repo_service")
+ mock_fetch_pr = mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.fetch_and_update_pull_request_information_from_commit",
+ return_value=None,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=fake_repo_service,
+ )
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ await builder.load_optional_enriched_pull()
+ assert builder._notification_context.pull is None
+ mock_fetch_pr.assert_called_with(fake_repo_service, head_commit, user_yaml)
+
+ @pytest.mark.parametrize(
+ "expected_missing_detail",
+ [
+ pytest.param("MissingBaseCommit"),
+ pytest.param("MissingHeadCommit"),
+ pytest.param("MissingBaseReport"),
+ pytest.param("MissingHeadReport"),
+ ],
+ )
+ @pytest.mark.asyncio
+ async def test_load_bundle_comparison_missing_some_info(
+ self, expected_missing_detail, dbsession, mocker
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ sink_commit = CommitFactory(repository=head_commit.repository)
+ dbsession.add(sink_commit)
+ get_report_pair(dbsession, (head_commit, base_commit))
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict({})
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_commit_report()
+ match expected_missing_detail:
+ case "MissingBaseCommit":
+ enriched_pull.database_pull.compared_to = None
+ case "MissingHeadCommit":
+ enriched_pull.database_pull.head = None
+ case "MissingBaseReport":
+ # Deleting the BaseReport also deleted base_commit
+ # So instead we point the Pull object to a commit that doesn't have reports
+ enriched_pull.database_pull.compared_to = sink_commit.commitid
+ case "MissingHeadReport":
+ enriched_pull.database_pull.head = sink_commit.commitid
+
+ with pytest.raises(NotificationContextBuildError) as exp:
+ await builder.load_optional_enriched_pull()
+ builder.load_bundle_comparison()
+ assert builder._notification_context.pull is not None
+ assert exp.value.failed_step == "load_bundle_comparison"
+ assert exp.value.detail == expected_missing_detail
+
+ @pytest.mark.asyncio
+ async def test_load_bundle_comparison_when_pull_is_none(
+ self, dbsession, mocker, mock_storage
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit.parent_commit_id = base_commit.commitid
+ head_report, base_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.fetch_and_update_pull_request_information_from_commit",
+ return_value=None,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=mocker.MagicMock(name="fake_repo_service"),
+ )
+
+ mock_check_compare_sha = mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison._check_compare_sha",
+ return_value=None,
+ )
+
+ save_mock_bundle_analysis_report(
+ repository, head_report, mock_storage, sample_report_number=2
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_report, mock_storage, sample_report_number=1
+ )
+ user_yaml = UserYaml.from_dict({})
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_commit_report()
+ await builder.load_optional_enriched_pull()
+ builder.load_bundle_comparison()
+ context = builder.get_result()
+ assert context.pull is None
+ assert context.bundle_analysis_comparison is not None
+ assert (
+ context.bundle_analysis_comparison.base_report_key
+ == base_report.external_id
+ )
+ assert (
+ context.bundle_analysis_comparison.head_report_key
+ == head_report.external_id
+ )
+
+ @pytest.mark.parametrize(
+ "yaml_dict, percent_change, absolute_change, expected",
+ [
+ pytest.param(
+ PATCH_CENTRIC_DEFAULT_CONFIG,
+ 1.0,
+ 10000,
+ CommitStatusLevel.INFO,
+ id="default_config_within_5%_change",
+ ),
+ pytest.param(
+ PATCH_CENTRIC_DEFAULT_CONFIG,
+ 5.5,
+ 10000,
+ CommitStatusLevel.WARNING,
+ id="default_config_outside_5%_change",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 10000},
+ },
+ 1.0,
+ 10001,
+ CommitStatusLevel.WARNING,
+ id="informational_outside_range",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 10000},
+ },
+ 1.0,
+ 10000,
+ CommitStatusLevel.INFO,
+ id="informational_within_range",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 10000, "status": True},
+ },
+ 1.0,
+ 10001,
+ CommitStatusLevel.ERROR,
+ id="fail_outside_range_absolute",
+ ),
+ pytest.param(
+ {**PATCH_CENTRIC_DEFAULT_CONFIG, "bundle_analysis": {"status": True}},
+ 5.1,
+ 10000,
+ CommitStatusLevel.ERROR,
+ id="fail_outside_range_percentage",
+ ),
+ ],
+ )
+ def test_load_commit_status_level(
+ self, yaml_dict, percent_change, absolute_change, expected, dbsession, mocker
+ ):
+ head_commit = CommitFactory()
+ dbsession.add(head_commit)
+ yaml_dict.update(PATCH_CENTRIC_DEFAULT_CONFIG)
+ user_yaml = UserYaml.from_dict(yaml_dict)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder.load_user_config()
+ builder._notification_context.bundle_analysis_comparison = mocker.MagicMock(
+ name="fake_comparison",
+ total_size_delta=absolute_change,
+ percentage_delta=percent_change,
+ )
+ builder.load_commit_status_level()
+ context = builder.get_result()
+ assert context.commit_status_level == expected
+
+ @pytest.mark.django_db
+ def test_build_context(self, dbsession, mocker, mock_storage):
+ mock_all_plans_and_tiers()
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=2
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=1
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ context = builder.build_context().get_result()
+ assert context.commit_report == head_commit_report
+ assert context.bundle_analysis_report.session_count() == 18
+ assert context.pull == enriched_pull
+ assert (
+ context.bundle_analysis_comparison.base_report_key
+ == base_commit_report.external_id
+ )
+ assert (
+ context.bundle_analysis_comparison.head_report_key
+ == head_commit_report.external_id
+ )
+ assert context.user_config == NotificationUserConfig(
+ required_changes=False,
+ warning_threshold=BundleThreshold("percentage", 5.0),
+ status_level="informational",
+ required_changes_threshold=BundleThreshold("absolute", 0),
+ )
+ assert context.commit_status_level == CommitStatusLevel.INFO
+ assert context.cache_ttl == 600
+ assert context.commit_status_url is not None
+
+ @pytest.mark.django_db
+ def test_initialize_from_context(self, dbsession, mocker):
+ mock_all_plans_and_tiers()
+ head_commit, base_commit = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ context = builder.get_result()
+ context.commit_report = MagicMock(name="fake_commit_report")
+ context.bundle_analysis_report = MagicMock(name="fake_bundle_analysis_report")
+ pull = PullFactory(
+ repository=base_commit.repository,
+ head=head_commit.commitid,
+ base=base_commit.commitid,
+ compared_to=base_commit.commitid,
+ )
+ dbsession.add(pull)
+ dbsession.commit()
+ context.pull = EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ )
+
+ other_builder = (
+ CommitStatusNotificationContextBuilder().initialize_from_context(
+ user_yaml, context
+ )
+ )
+ other_context = other_builder.get_result()
+
+ assert context.commit == other_context.commit
+ assert context.commit_report == other_context.commit_report
+ assert context.bundle_analysis_report == other_context.bundle_analysis_report
+ assert context.pull == other_context.pull
+ with pytest.raises(ContextNotLoadedError):
+ other_context.bundle_analysis_comparison
+
+ fake_comparison = MagicMock(
+ name="fake_comparison", total_size_delta=1000, percentage_delta=1
+ )
+ mocker.patch.object(
+ ComparisonLoader, "get_comparison", return_value=fake_comparison
+ )
+ other_context = other_builder.build_context().get_result()
+
+ assert context.commit == other_context.commit
+ assert context.commit_report == other_context.commit_report
+ assert context.bundle_analysis_report == other_context.bundle_analysis_report
+ assert context.pull == other_context.pull
+ assert other_context.bundle_analysis_comparison == fake_comparison
+
+ def test_evaluate_should_use_upgrade_message_no_pull(self, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict({})
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder._notification_context.pull = None
+ builder.evaluate_should_use_upgrade_message()
+ assert builder._notification_context.should_use_upgrade_comment is False
+
+ @pytest.mark.parametrize(
+ "activation_result, auto_activate_succeeds, expected",
+ [
+ (ShouldActivateSeat.AUTO_ACTIVATE, True, False),
+ (ShouldActivateSeat.AUTO_ACTIVATE, False, True),
+ (ShouldActivateSeat.MANUAL_ACTIVATE, False, True),
+ (ShouldActivateSeat.NO_ACTIVATE, False, False),
+ ],
+ )
+ def test_evaluate_should_use_upgrade_message(
+ self, activation_result, dbsession, auto_activate_succeeds, expected, mocker
+ ):
+ activation_result = SeatActivationInfo(
+ should_activate_seat=activation_result,
+ owner_id=1,
+ author_id=10,
+ reason="mocked",
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.determine_seat_activation",
+ return_value=activation_result,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.activate_user",
+ return_value=auto_activate_succeeds,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.schedule_new_user_activated_task",
+ return_value=auto_activate_succeeds,
+ )
+ head_commit, _ = get_commit_pair(dbsession)
+ user_yaml = UserYaml.from_dict(
+ {
+ "comment": {
+ "layout": "reach,diff,flags,tree,reach",
+ "behavior": "default",
+ "show_carryforward_flags": False,
+ }
+ }
+ )
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(bundle_analysis_commentid=12345, id=12),
+ )
+ builder._notification_context.pull = mock_pull
+ builder.evaluate_should_use_upgrade_message()
+ assert builder._notification_context.should_use_upgrade_comment == expected
diff --git a/apps/worker/services/bundle_analysis/notify/contexts/tests/test_contexts.py b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_contexts.py
new file mode 100644
index 0000000000..c2f6e2ab6a
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/contexts/tests/test_contexts.py
@@ -0,0 +1,146 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts import (
+ ContextNotLoadedError,
+ NotificationContextBuilder,
+ NotificationContextBuildError,
+)
+from services.bundle_analysis.notify.types import NotificationUserConfig
+
+
+class TestBaseBundleAnalysisNotificationContextBuild:
+ def test_access_not_loaded_field_raises(self, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ builder = NotificationContextBuilder().initialize(
+ head_commit, UserYaml.from_dict({}), GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ with pytest.raises(ContextNotLoadedError) as exp:
+ builder._notification_context.commit_report
+ assert (
+ str(exp.value)
+ == "Property commit_report is not loaded. Make sure to build the context before using it."
+ )
+
+ @pytest.mark.parametrize(
+ "field_name, expected",
+ [
+ ("commit_report", True),
+ ("bundle_analysis_report", False),
+ ("field_doesnt_exist", False),
+ ],
+ )
+ def test_is_field_loaded(self, field_name, expected, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ builder = NotificationContextBuilder().initialize(
+ head_commit, UserYaml.from_dict({}), GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ builder._notification_context.commit_report = MagicMock(
+ name="fake_commit_report"
+ )
+ assert builder.is_field_loaded(field_name) == expected
+
+ def test_load_commit_report_no_report(self, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ builder = NotificationContextBuilder().initialize(
+ head_commit, UserYaml.from_dict({}), GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ with pytest.raises(NotificationContextBuildError) as exp:
+ builder.load_commit_report()
+ assert exp.value.failed_step == "load_commit_report"
+
+ def test_load_bundle_analysis_report_no_report(self, dbsession, mock_storage):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ get_report_pair(dbsession, (head_commit, base_commit))
+ builder = NotificationContextBuilder().initialize(
+ head_commit, UserYaml.from_dict({}), GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ with pytest.raises(NotificationContextBuildError) as exp:
+ builder.load_commit_report()
+ builder.load_bundle_analysis_report()
+ assert exp.value.failed_step == "load_bundle_analysis_report"
+
+ @pytest.mark.parametrize(
+ "config, expected_user_config",
+ [
+ pytest.param(
+ {
+ "comment": {
+ "layout": "reach,diff,flags,tree,reach",
+ "behavior": "default",
+ "show_carryforward_flags": False,
+ }
+ },
+ NotificationUserConfig(
+ required_changes=False,
+ warning_threshold=BundleThreshold("percentage", 5.0),
+ status_level="informational",
+ required_changes_threshold=BundleThreshold("absolute", 0),
+ ),
+ id="default_site_config",
+ ),
+ pytest.param(
+ {"comment": False},
+ NotificationUserConfig(
+ required_changes=False,
+ warning_threshold=BundleThreshold("percentage", 5.0),
+ status_level="informational",
+ required_changes_threshold=BundleThreshold("absolute", 0),
+ ),
+ id="comment_is_bool",
+ ),
+ ],
+ )
+ def test_build_context(
+ self, dbsession, mock_storage, mocker, config, expected_user_config
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ head_commit_report, _ = get_report_pair(dbsession, (head_commit, base_commit))
+ save_mock_bundle_analysis_report(
+ head_commit.repository,
+ head_commit_report,
+ mock_storage,
+ sample_report_number=1,
+ )
+ builder = NotificationContextBuilder().initialize(
+ head_commit,
+ UserYaml.from_dict(config),
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ )
+ context = builder.build_context().get_result()
+ assert context.commit_report == head_commit_report
+ assert context.bundle_analysis_report.session_count() == 19
+ assert context.user_config == expected_user_config
+ assert [
+ bundle_report.name
+ for bundle_report in context.bundle_analysis_report.bundle_reports()
+ ] == [
+ "@codecov/sveltekit-plugin-cjs",
+ "@codecov/webpack-plugin-cjs",
+ "@codecov/webpack-plugin-esm",
+ "@codecov/vite-plugin-esm",
+ "@codecov/bundler-plugin-core-esm",
+ "@codecov/remix-vite-plugin-esm",
+ "@codecov/nuxt-plugin-esm",
+ "@codecov/rollup-plugin-esm",
+ "@codecov/example-webpack-app-array-push",
+ "@codecov/remix-vite-plugin-cjs",
+ "@codecov/nuxt-plugin-cjs",
+ "@codecov/example-vite-app-esm",
+ "@codecov/example-next-app-edge-server-array-push",
+ "@codecov/rollup-plugin-cjs",
+ "@codecov/sveltekit-plugin-esm",
+ "@codecov/vite-plugin-cjs",
+ "@codecov/bundler-plugin-core-cjs",
+ "@codecov/example-rollup-app-iife",
+ "@codecov/example-next-app-server-cjs",
+ ]
diff --git a/apps/worker/services/bundle_analysis/notify/helpers.py b/apps/worker/services/bundle_analysis/notify/helpers.py
new file mode 100644
index 0000000000..a40eab87f6
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/helpers.py
@@ -0,0 +1,117 @@
+import numbers
+from typing import Literal, Optional
+
+from shared.bundle_analysis import BundleAnalysisComparison, BundleChange
+from shared.django_apps.codecov_auth.models import Service
+from shared.torngit.base import TorngitBaseAdapter
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.models.core import Owner
+from services.bundle_analysis.notify.types import NotificationType
+
+
+def is_commit_status_configured(
+ yaml: UserYaml, owner: Owner
+) -> None | NotificationType:
+ """Verifies if we should attempt to send bundle analysis commit status based on given YAML.
+ Config field is `bundle_analysis.status` (default: "informational")
+
+ If the user is from GitHub and has an app we can send NotificationType.GITHUB_COMMIT_CHECK.
+ """
+ is_status_configured: bool | Literal["informational"] = yaml.read_yaml_field(
+ "bundle_analysis", "status", _else="informational"
+ )
+ is_github = Service(owner.service) in (Service.GITHUB, Service.GITHUB_ENTERPRISE)
+ owner_has_app = owner.github_app_installations != []
+ if is_status_configured:
+ if is_github and owner_has_app:
+ return NotificationType.GITHUB_COMMIT_CHECK
+ return NotificationType.COMMIT_STATUS
+ return None
+
+
+def is_comment_configured(yaml: UserYaml, owner: Owner) -> None | NotificationType:
+ """Verifies if we should attempt to send bundle analysis PR comment based on given YAML.
+ Config field is `comment` (default: see shared.config)
+ """
+ is_comment_configured: dict | bool = yaml.read_yaml_field("comment") is not False
+ if is_comment_configured:
+ return NotificationType.PR_COMMENT
+ return None
+
+
+def get_notification_types_configured(
+ yaml: UserYaml, owner: Owner
+) -> tuple[NotificationType, ...]:
+ """Gets a tuple with all the different bundle analysis notifications that we should attempt to send,
+ based on the given YAML"""
+ notification_types = [
+ is_comment_configured(yaml, owner),
+ is_commit_status_configured(yaml, owner),
+ ]
+ return tuple(filter(None, notification_types))
+
+
+def get_github_app_used(torngit: TorngitBaseAdapter | None) -> int | None:
+ if torngit is None:
+ return None
+ torngit_installation = torngit.data.get("installation")
+ selected_installation_id = (
+ torngit_installation.get("id") if torngit_installation else None
+ )
+ return selected_installation_id
+
+
+def bytes_readable(bytes: int, show_negative: Optional[bool] = True) -> str:
+ """Converts bytes into human-readable string (up to GB)"""
+ is_negative = bytes < 0
+ value: float = abs(bytes)
+ exponent_index = 0
+
+ while value >= 1000 and exponent_index < 3:
+ value /= 1000
+ exponent_index += 1
+
+ exponent_str = [" bytes", "kB", "MB", "GB"][exponent_index]
+ rounded_value = round(value, 2)
+
+ if is_negative and show_negative:
+ return f"-{rounded_value}{exponent_str}"
+ else:
+ return f"{rounded_value}{exponent_str}"
+
+
+def to_BundleThreshold(value: int | float | BundleThreshold) -> BundleThreshold:
+ if isinstance(value, (list, tuple)) and value[0] in ["absolute", "percentage"]:
+ return BundleThreshold(*value)
+ if isinstance(value, numbers.Integral):
+ return BundleThreshold("absolute", value)
+ elif isinstance(value, numbers.Number):
+ return BundleThreshold("percentage", value)
+ raise TypeError(f"Can't parse {value} into BundleThreshold")
+
+
+def is_bundle_comparison_change_within_configured_threshold(
+ comparison: BundleAnalysisComparison,
+ threshold: BundleThreshold,
+ compare_non_negative_numbers: bool = False,
+) -> bool:
+ if threshold.type == "absolute":
+ total_size_delta = (
+ abs(comparison.total_size_delta)
+ if compare_non_negative_numbers
+ else comparison.total_size_delta
+ )
+ return total_size_delta <= threshold.threshold
+ else:
+ return comparison.percentage_delta <= threshold.threshold
+
+
+def is_bundle_change_within_configured_threshold(
+ bundle_change: BundleChange, threshold: BundleThreshold
+) -> bool:
+ if threshold.type == "absolute":
+ return bundle_change.size_delta <= threshold.threshold
+ else:
+ return bundle_change.percentage_delta <= threshold.threshold
diff --git a/apps/worker/services/bundle_analysis/notify/messages/__init__.py b/apps/worker/services/bundle_analysis/notify/messages/__init__.py
new file mode 100644
index 0000000000..4678f43c62
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/__init__.py
@@ -0,0 +1,21 @@
+from abc import ABC, abstractmethod
+
+from services.bundle_analysis.notify.contexts import (
+ BaseBundleAnalysisNotificationContext,
+)
+from services.notification.notifiers.base import NotificationResult
+
+
+class MessageStrategyInterface(ABC):
+ @abstractmethod
+ def build_message(
+ self, context: BaseBundleAnalysisNotificationContext
+ ) -> str | bytes:
+ """Builds the message to be sent using the `context` information"""
+ pass
+
+ @abstractmethod
+ def send_message(
+ self, context: BaseBundleAnalysisNotificationContext, message: str | bytes
+ ) -> NotificationResult:
+ pass
diff --git a/apps/worker/services/bundle_analysis/notify/messages/comment.py b/apps/worker/services/bundle_analysis/notify/messages/comment.py
new file mode 100644
index 0000000000..3fdabcf56d
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/comment.py
@@ -0,0 +1,385 @@
+import logging
+from typing import List, Literal, TypedDict
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from django.template import loader
+from shared.bundle_analysis import (
+ BundleAnalysisComparison,
+ BundleChange,
+ MissingBundleError,
+)
+from shared.bundle_analysis.comparison import AssetChange, RouteChange
+from shared.torngit.exceptions import TorngitClientError
+from shared.validation.types import BundleThreshold
+
+from services.bundle_analysis.notify.contexts.comment import (
+ BundleAnalysisPRCommentNotificationContext,
+)
+from services.bundle_analysis.notify.helpers import (
+ bytes_readable,
+ get_github_app_used,
+ is_bundle_change_within_configured_threshold,
+)
+from services.bundle_analysis.notify.messages import MessageStrategyInterface
+from services.license import requires_license
+from services.notification.notifiers.base import NotificationResult
+from services.urls import get_bundle_analysis_pull_url, get_members_url
+
+log = logging.getLogger(__name__)
+
+
+class BundleRow(TypedDict):
+ bundle_name: str
+ bundle_size: str
+ change_size_readable: str
+ percentage_change_readable: str
+ change_icon: str
+ has_cached: bool
+ is_change_outside_threshold: bool
+
+
+class BundleRouteRow(TypedDict):
+ route_name: str
+ change_size_readable: str
+ percentage_change_readable: str
+ change_icon: str
+ route_size: str
+
+
+class ModuleData(TypedDict):
+ module_name: str
+ change_size_readable: str
+
+
+class AssetData(TypedDict):
+ asset_display_name_1: str
+ asset_display_name_2: str
+ change_size_readable: str
+ percentage_change_readable: str
+ change_icon: str
+ asset_size_readable: str
+ module_data: List[ModuleData]
+
+
+class IndividualBundleData(TypedDict):
+ bundle_name: str
+ asset_data: List[AssetData]
+ app_routes_data: List[BundleRouteRow]
+
+
+class BundleCommentTemplateContext(TypedDict):
+ pull_url: str
+ total_size_delta: int
+ total_size_readable: str
+ total_percentage: str
+ status_level: Literal["INFO"] | Literal["WARNING"] | Literal["ERROR"]
+ warning_threshold_readable: str
+ bundle_rows: list[BundleRow]
+ has_cached_bundles: bool
+ individual_bundle_data: dict[str, IndividualBundleData]
+
+
+class UpgradeCommentTemplateContext(TypedDict):
+ author_username: str
+ is_saas: bool
+ activation_link: str
+
+
+class BundleAnalysisCommentMarkdownStrategy(MessageStrategyInterface):
+ def build_message(
+ self, context: BundleAnalysisPRCommentNotificationContext
+ ) -> str | bytes:
+ if context.should_use_upgrade_comment:
+ return self.build_upgrade_message(context)
+ else:
+ return self.build_default_message(context)
+
+ @sentry_sdk.trace
+ def build_default_message(
+ self, context: BundleAnalysisPRCommentNotificationContext
+ ) -> str:
+ try:
+ pull = context.pull.database_pull
+ repository_service = context.repository_service
+ changed_files = async_to_sync(repository_service.get_pull_request_files)(
+ pull.pullid
+ )
+ except Exception:
+ changed_files = None
+ log.error(
+ "Unable to retrieve PR files",
+ extra=dict(
+ commit=context.commit.commitid,
+ report_key=context.commit_report.external_id,
+ pullid=pull.pullid,
+ ),
+ exc_info=True,
+ )
+
+ template = loader.get_template("bundle_analysis_notify/bundle_comment.md")
+ total_size_delta = context.bundle_analysis_comparison.total_size_delta
+ warning_threshold = context.user_config.warning_threshold
+ bundle_rows = self._create_bundle_rows(
+ context.bundle_analysis_comparison, warning_threshold
+ )
+
+ individual_bundle_data = self._create_individual_bundle_data(
+ context.bundle_analysis_comparison,
+ changed_files,
+ warning_threshold,
+ )
+
+ if warning_threshold.type == "absolute":
+ warning_threshold_readable = bytes_readable(warning_threshold.threshold)
+ else:
+ warning_threshold_readable = str(round(warning_threshold.threshold)) + "%"
+ context = BundleCommentTemplateContext(
+ has_cached=any(row["is_cached"] for row in bundle_rows),
+ bundle_rows=bundle_rows,
+ pull_url=get_bundle_analysis_pull_url(pull=context.pull.database_pull),
+ total_size_delta=total_size_delta,
+ status_level=context.commit_status_level.name,
+ total_percentage=str(
+ round(context.bundle_analysis_comparison.percentage_delta, 2)
+ )
+ + "%",
+ total_size_readable=bytes_readable(total_size_delta, show_negative=False),
+ warning_threshold_readable=warning_threshold_readable,
+ individual_bundle_data=individual_bundle_data,
+ )
+ return template.render(context)
+
+ @sentry_sdk.trace
+ def build_upgrade_message(
+ self, context: BundleAnalysisPRCommentNotificationContext
+ ) -> str:
+ template = loader.get_template("bundle_analysis_notify/upgrade_comment.md")
+ context = UpgradeCommentTemplateContext(
+ activation_link=get_members_url(context.pull.database_pull),
+ is_saas=not requires_license(),
+ author_username=context.pull.provider_pull["author"].get("username"),
+ )
+ return template.render(context)
+
+ @sentry_sdk.trace
+ def send_message(
+ self, context: BundleAnalysisPRCommentNotificationContext, message: str
+ ) -> NotificationResult:
+ pull = context.pull.database_pull
+ repository_service = context.repository_service
+ try:
+ comment_id = pull.bundle_analysis_commentid
+ if comment_id:
+ async_to_sync(repository_service.edit_comment)(
+ pull.pullid, comment_id, message
+ )
+ else:
+ res = async_to_sync(repository_service.post_comment)(
+ pull.pullid, message
+ )
+ pull.bundle_analysis_commentid = res["id"]
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=get_github_app_used(repository_service),
+ )
+ except TorngitClientError:
+ log.error(
+ "Error creating/updating PR comment",
+ extra=dict(
+ commit=context.commit.commitid,
+ report_key=context.commit_report.external_id,
+ pullid=pull.pullid,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="TorngitClientError",
+ )
+
+ def _create_bundle_rows(
+ self,
+ comparison: BundleAnalysisComparison,
+ configured_threshold: BundleThreshold,
+ ) -> list[BundleRow]:
+ bundle_rows = []
+ bundle_changes = comparison.bundle_changes()
+ # Calculate bundle change data in one loop since bundle_changes is a generator
+ for bundle_change in bundle_changes:
+ # Define row table data
+ bundle_name = bundle_change.bundle_name
+ if bundle_change.change_type == BundleChange.ChangeType.REMOVED:
+ size = "(removed)"
+ is_cached = False
+ else:
+ head_bundle_report = comparison.head_report.bundle_report(bundle_name)
+ size = bytes_readable(head_bundle_report.total_size())
+ is_cached = head_bundle_report.is_cached()
+
+ change_size = bundle_change.size_delta
+ if change_size == 0:
+ # Don't include bundles that were not changed in the table
+ continue
+ icon = ""
+ if change_size > 0:
+ icon = ":arrow_up:"
+ elif change_size < 0:
+ icon = ":arrow_down:"
+
+ bundle_rows.append(
+ BundleRow(
+ bundle_name=bundle_name,
+ bundle_size=size,
+ change_size_readable=bytes_readable(change_size),
+ change_icon=icon,
+ is_cached=is_cached,
+ percentage_change_readable=f"{bundle_change.percentage_delta}%",
+ is_change_outside_threshold=(
+ not is_bundle_change_within_configured_threshold(
+ bundle_change, configured_threshold
+ )
+ ),
+ )
+ )
+
+ return bundle_rows
+
+ def _create_bundle_route_data(
+ self,
+ comparison: BundleAnalysisComparison,
+ warning_threshold: BundleThreshold,
+ ) -> dict[str, list[BundleRouteRow]]:
+ """
+ Translate BundleRouteComparison dict data into a template compatible dict data
+ """
+ bundle_route_data = {}
+ changes_dict = comparison.bundle_routes_changes()
+
+ for bundle_name, route_changes in changes_dict.items():
+ rows = []
+ for route_change in route_changes:
+ change_size, size = (
+ route_change.size_delta,
+ bytes_readable(route_change.size_head),
+ )
+
+ if change_size == 0:
+ continue
+
+ exceeds_threshold = (
+ warning_threshold.type == "percentage"
+ and route_change.percentage_delta > warning_threshold.threshold
+ )
+ bundle_display_name, icon = route_change.route_name, ""
+ if route_change.change_type == RouteChange.ChangeType.ADDED:
+ icon = ":rocket:"
+ bundle_display_name = f"**{route_change.route_name}** _(New)_"
+ elif route_change.change_type == RouteChange.ChangeType.REMOVED:
+ icon = ":wastebasket:"
+ bundle_display_name = (
+ f"~~**{route_change.route_name}**~~ _(Deleted)_"
+ )
+ elif exceeds_threshold:
+ icon = ":warning:"
+
+ rows.append(
+ BundleRouteRow(
+ route_name=bundle_display_name,
+ change_size_readable=bytes_readable(change_size),
+ percentage_change_readable=f"{route_change.percentage_delta}%",
+ change_icon=icon,
+ route_size=size,
+ )
+ )
+ bundle_route_data[bundle_name] = rows
+ return bundle_route_data
+
+ def _create_asset_data(
+ self,
+ comparison: BundleAnalysisComparison,
+ bundle_name: str,
+ changed_files: List[str] | None,
+ warning_threshold: BundleThreshold,
+ ) -> List[AssetData]:
+ try:
+ asset_data = []
+ asset_comparisons = comparison.bundle_comparison(
+ bundle_name
+ ).asset_comparisons()
+ for asset_comparison in asset_comparisons:
+ asset_change = asset_comparison.asset_change()
+
+ # If not change in size for the asset then we don't show it
+ if asset_change.size_delta == 0:
+ continue
+
+ # Determine what asset name styling and change icon to use
+ asset_display_name_1 = f"```{asset_change.asset_name}```"
+ asset_display_name_2 = asset_display_name_2 = (
+ f"**```{asset_change.asset_name}```**"
+ )
+ exceeds_threshold = (
+ warning_threshold.type == "percentage"
+ and asset_change.percentage_delta > warning_threshold.threshold
+ )
+ if asset_change.change_type == AssetChange.ChangeType.ADDED:
+ asset_display_name_1 = f"**{asset_display_name_1}** _(New)_"
+ change_icon = ":rocket:"
+ elif asset_change.change_type == AssetChange.ChangeType.REMOVED:
+ asset_display_name_1 = f"~~**{asset_display_name_1}**~~ _(Deleted)_"
+ change_icon = ":wastebasket:"
+ elif exceeds_threshold:
+ change_icon = ":warning:"
+ else:
+ change_icon = ""
+
+ modules = asset_comparison.contributing_modules(
+ pr_changed_files=changed_files
+ )
+ asset_data.append(
+ AssetData(
+ asset_display_name_1=asset_display_name_1,
+ asset_display_name_2=asset_display_name_2,
+ change_size_readable=bytes_readable(asset_change.size_delta),
+ percentage_change_readable=f"{asset_change.percentage_delta}%",
+ change_icon=change_icon,
+ asset_size_readable=bytes_readable(asset_change.size_head),
+ module_data=[
+ ModuleData(
+ module_name=f"```{module.name}```",
+ change_size_readable=bytes_readable(module.size),
+ )
+ for module in modules
+ ],
+ )
+ )
+ return asset_data
+ except MissingBundleError:
+ # Won't have assets changed comparisons if either head or base report doesn't have the bundle
+ return []
+
+ def _create_individual_bundle_data(
+ self,
+ comparison: BundleAnalysisComparison,
+ changed_files: List[str] | None,
+ warning_threshold: BundleThreshold,
+ ) -> dict[str, IndividualBundleData]:
+ data = {}
+ bundle_route_data = self._create_bundle_route_data(
+ comparison, warning_threshold
+ )
+ for bundle_name in bundle_route_data.keys():
+ asset_data = self._create_asset_data(
+ comparison, bundle_name, changed_files, warning_threshold
+ )
+
+ # Only create an entry for this bundle if the there's either app routes or asset changes
+ if asset_data or bundle_route_data.get(bundle_name):
+ data[bundle_name] = IndividualBundleData(
+ bundle_name=bundle_name,
+ asset_data=asset_data,
+ app_routes_data=bundle_route_data[bundle_name],
+ )
+ return data
diff --git a/apps/worker/services/bundle_analysis/notify/messages/commit_status.py b/apps/worker/services/bundle_analysis/notify/messages/commit_status.py
new file mode 100644
index 0000000000..5eee45344a
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/commit_status.py
@@ -0,0 +1,129 @@
+import logging
+from typing import TypedDict
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from django.template import loader
+from shared.helpers.cache import cache, make_hash_sha256
+from shared.torngit.exceptions import TorngitClientError
+
+from services.bundle_analysis.notify.contexts.commit_status import (
+ CommitStatusLevel,
+ CommitStatusNotificationContext,
+)
+from services.bundle_analysis.notify.helpers import bytes_readable, get_github_app_used
+from services.bundle_analysis.notify.messages import MessageStrategyInterface
+from services.notification.notifiers.base import NotificationResult
+
+log = logging.getLogger(__name__)
+
+
+class BundleCommentTemplateContext(TypedDict):
+ prefix_message: str
+ change_readable: str
+ warning_threshold_readable: str
+
+
+class CommitStatusMessageStrategy(MessageStrategyInterface):
+ def build_message(self, context: CommitStatusNotificationContext) -> str | bytes:
+ if context.should_use_upgrade_comment:
+ return self.build_upgrade_message(context)
+ else:
+ return self.build_default_message(context)
+
+ @sentry_sdk.trace
+ def build_default_message(
+ self, context: CommitStatusNotificationContext
+ ) -> str | bytes:
+ template = loader.get_template(
+ "bundle_analysis_notify/commit_status_summary.md"
+ )
+ # Prefix message is based on the commit status level
+ prefix_message = {
+ CommitStatusLevel.INFO: "",
+ CommitStatusLevel.WARNING: "Passed with Warnings - ",
+ CommitStatusLevel.ERROR: "Failed - ",
+ }.get(context.commit_status_level)
+
+ warning_threshold = context.user_config.warning_threshold
+ if warning_threshold.type == "absolute":
+ warning_threshold_readable = bytes_readable(warning_threshold.threshold)
+ absolute_change = context.bundle_analysis_comparison.total_size_delta
+ change_readable = bytes_readable(absolute_change)
+ else:
+ warning_threshold_readable = str(warning_threshold.threshold) + "%"
+ change_readable = (
+ str(context.bundle_analysis_comparison.percentage_delta) + "%"
+ )
+
+ context = BundleCommentTemplateContext(
+ prefix_message=prefix_message,
+ change_readable=change_readable,
+ warning_threshold_readable=warning_threshold_readable,
+ )
+ return template.render(context)
+
+ @sentry_sdk.trace
+ def build_upgrade_message(self, context: CommitStatusNotificationContext) -> str:
+ author_username = context.pull.provider_pull["author"].get("username")
+ return (
+ f"Please activate user {author_username} to display a detailed status check"
+ )
+
+ def _cache_key(self, context: CommitStatusNotificationContext) -> str:
+ return "cache:" + make_hash_sha256(
+ dict(
+ type="status_check_notification",
+ repoid=context.repository.repoid,
+ base_commitid=context.base_commit.commitid,
+ head_commitid=context.commit.commitid,
+ notifier_name="bundle_analysis_commit_status",
+ notifier_title="codecov/bundles",
+ )
+ )
+
+ @sentry_sdk.trace
+ def send_message(
+ self, context: CommitStatusNotificationContext, message: str | bytes
+ ) -> NotificationResult:
+ repository_service = context.repository_service
+ cache_key = self._cache_key(context)
+ last_payload = cache.get_backend().get(cache_key)
+ if message == last_payload:
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=False,
+ explanation="payload_unchanged",
+ )
+ try:
+ async_to_sync(repository_service.set_commit_status)(
+ context.commit.commitid,
+ context.commit_status_level.to_str(),
+ "codecov/bundles",
+ message,
+ context.commit_status_url,
+ )
+ # Update the recently-sent messages cache
+ cache.get_backend().set(
+ cache_key,
+ context.cache_ttl,
+ message,
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=get_github_app_used(repository_service),
+ )
+ except TorngitClientError:
+ log.error(
+ "Failed to set commit status",
+ extra=dict(
+ commit=context.commit.commitid,
+ report_key=context.commit_report.external_id,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="TorngitClientError",
+ )
diff --git a/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_comment.md b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_comment.md
new file mode 100644
index 0000000000..1eeceef544
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_comment.md
@@ -0,0 +1,11 @@
+## [Bundle]({{pull_url}}) Report
+{% if total_size_delta == 0 %}
+Bundle size has no change :white_check_mark:
+{% else %}
+{% if status_level == "ERROR" %}:x: Check failed: c{% else %}C{% endif %}hanges will {% if total_size_delta > 0 %}increase{% else %}decrease{% endif %} total bundle size by {{total_size_readable}} ({{total_percentage}}) {% if total_size_delta > 0 %}:arrow_up:{% else %}:arrow_down:{% endif %}{% if status_level == "WARNING" %}:warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of {{warning_threshold_readable}}.{% elif status_level == "ERROR" %}, **exceeding** the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of {{warning_threshold_readable}}.{% else %}. This is within the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold :white_check_mark:{% endif %}
+{% endif %}
+{% if bundle_rows %}{% include "bundle_analysis_notify/bundle_table.md" %}{% if has_cached %}
+
+ℹ️ *Bundle size includes cached data from a previous commit
+{%endif%}{% endif %}
+{% if individual_bundle_data %}{% include "bundle_analysis_notify/individual_bundle_data.md" %}{% endif %}
\ No newline at end of file
diff --git a/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_table.md b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_table.md
new file mode 100644
index 0000000000..bc270594cf
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/bundle_table.md
@@ -0,0 +1,7 @@
+{% if status_level == "INFO" %}Detailed changes
+
+{% endif %}| Bundle name | Size | Change |
+| ----------- | ---- | ------ |{% for bundle_row in bundle_rows %}
+| {{bundle_row.bundle_name}}{% if bundle_row.is_cached %}*{% endif %} | {{bundle_row.bundle_size}} | {{bundle_row.change_size_readable}} ({{bundle_row.percentage_change_readable}}) {{bundle_row.change_icon}}{% if bundle_row.is_change_outside_threshold and status_level == "WARNING" %}:warning:{% elif bundle_row.is_change_outside_threshold and status_level == "ERROR"%}:x:{% endif %}{{bundle_row.}} |{% endfor %}{% if status_level == "INFO" %}
+
+ {% endif %}
\ No newline at end of file
diff --git a/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/commit_status_summary.md b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/commit_status_summary.md
new file mode 100644
index 0000000000..10203eda8b
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/commit_status_summary.md
@@ -0,0 +1 @@
+{{prefix_message}}Bundle change: {{change_readable}} (Threshold: {{warning_threshold_readable}})
\ No newline at end of file
diff --git a/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/individual_bundle_data.md b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/individual_bundle_data.md
new file mode 100644
index 0000000000..1f76a2f68b
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/individual_bundle_data.md
@@ -0,0 +1,28 @@
+
+### Affected Assets, Files, and Routes:
+{% for bundle_name, bundle_data in individual_bundle_data.items %}
+
+view changes for bundle: {{ bundle_name }}
+
+{% if bundle_data.asset_data %}#### **Assets Changed:**
+| Asset Name | Size Change | Total Size | Change (%) |
+| ---------- | ----------- | ---------- | ---------- |{% for row in bundle_data.asset_data %}
+| {{row.asset_display_name_1}} | {{row.change_size_readable}} | {{row.asset_size_readable}} | {{row.percentage_change_readable}} {{row.change_icon}} |{% endfor %}
+
+{% for row in bundle_data.asset_data %}
+{% if row.module_data %}**Files in** {{row.asset_display_name_2}}:
+{% for module in row.module_data %}
+- {{module.module_name}} → Total Size: **{{module.change_size_readable}}**
+{% endfor %}
+{% endif %}
+{% endfor %}
+{% endif %}
+{% if bundle_data.app_routes_data %}#### App Routes Affected:
+
+| App Route | Size Change | Total Size | Change (%) |
+| --------- | ----------- | ---------- | ---------- |{% for row in bundle_data.app_routes_data %}
+| {{row.route_name}} | {{row.change_size_readable}} | {{row.route_size}} | {{row.percentage_change_readable}} {{row.change_icon}} |{% endfor %}
+
+{% endif %}
+
+{% endfor %}
\ No newline at end of file
diff --git a/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/upgrade_comment.md b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/upgrade_comment.md
new file mode 100644
index 0000000000..bfb8aa52bc
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/templates/bundle_analysis_notify/upgrade_comment.md
@@ -0,0 +1,8 @@
+The author of this PR, {{author_username}}, {% if is_saas %}is not an activated member of this organization on Codecov.{% else %}is not activated in your Codecov Self-Hosted installation.{% endif %}
+Please [activate this user]({{activation_link}}) to display this PR comment.
+Bundle data is still being uploaded to {{ is_saas|yesno:"Codecov,your instance of Codecov"}} for purposes of overall calculations.
+{% if is_saas %}
+Please don't hesitate to email us at support@codecov.io with any questions.
+{% else %}
+Please contact your Codecov On-Premises installation administrator with any questions.
+{% endif %}
\ No newline at end of file
diff --git a/apps/worker/services/bundle_analysis/notify/messages/tests/__init__.py b/apps/worker/services/bundle_analysis/notify/messages/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/bundle_analysis/notify/messages/tests/test_comment.py b/apps/worker/services/bundle_analysis/notify/messages/tests/test_comment.py
new file mode 100644
index 0000000000..8a54a54dab
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/tests/test_comment.py
@@ -0,0 +1,259 @@
+from textwrap import dedent
+from unittest.mock import MagicMock
+
+import pytest
+from mock import AsyncMock
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.torngit.exceptions import TorngitClientError
+from shared.typings.torngit import TorngitInstanceData
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from database.tests.factories.core import PullFactory
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_enriched_pull_setting_up_mocks,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts.comment import (
+ BundleAnalysisPRCommentContextBuilder,
+ BundleAnalysisPRCommentNotificationContext,
+)
+from services.bundle_analysis.notify.messages.comment import (
+ BundleAnalysisCommentMarkdownStrategy,
+)
+from services.bundle_analysis.notify.types import NotificationUserConfig
+from services.notification.notifiers.base import NotificationResult
+from tests.helpers import mock_all_plans_and_tiers
+
+
+class TestCommentMesage:
+ @pytest.mark.django_db
+ def test_build_message_from_samples(self, dbsession, mocker, mock_storage):
+ mock_all_plans_and_tiers()
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=2
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=1
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = BundleAnalysisPRCommentContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ context = builder.build_context().get_result()
+ message = BundleAnalysisCommentMarkdownStrategy().build_message(context)
+ assert message.startswith(
+ dedent("""\
+ ## [Bundle](https://app.codecov.io/gh/{owner}/{repo}/pull/{pullid}?dropdown=bundle) Report
+
+ Changes will decrease total bundle size by 372.56kB (-48.89%) :arrow_down:. This is within the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold :white_check_mark:
+
+ Detailed changes
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | @codecov/sveltekit-plugin-esm | 1.1kB | 188 bytes (20.68%) :arrow_up: |
+ | @codecov/rollup-plugin-esm | 1.32kB | -1.01kB (-43.37%) :arrow_down: |
+ | @codecov/bundler-plugin-core-esm | 8.2kB | -30.02kB (-78.55%) :arrow_down: |
+ | @codecov/bundler-plugin-core-cjs | 43.32kB | 611 bytes (1.43%) :arrow_up: |
+ | @codecov/example-next-app-server-cjs | (removed) | -342.32kB (-100.0%) :arrow_down: |
+
+
+ """).format(
+ pullid=enriched_pull.database_pull.pullid,
+ owner=head_commit.repository.owner.username,
+ repo=head_commit.repository.name,
+ )
+ )
+
+ def _setup_send_message_tests(
+ self, dbsession, mocker, torngit_ghapp_data, bundle_analysis_commentid
+ ):
+ fake_repo_provider = MagicMock(
+ name="fake_repo_provider",
+ data=TorngitInstanceData(installation=torngit_ghapp_data),
+ post_comment=AsyncMock(),
+ edit_comment=AsyncMock(),
+ )
+ fake_repo_provider.post_comment.return_value = {"id": 1000}
+ fake_repo_provider.edit_comment.return_value = {"id": 1000}
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=fake_repo_provider,
+ )
+ head_commit, _ = get_commit_pair(dbsession)
+ context = BundleAnalysisPRCommentNotificationContext(
+ head_commit, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ mock_pull = MagicMock(
+ name="fake_pull",
+ database_pull=MagicMock(
+ name="fake_database_pull",
+ bundle_analysis_commentid=bundle_analysis_commentid,
+ pullid=12,
+ ),
+ )
+ context.__dict__["pull"] = mock_pull
+ context.__dict__["user_config"] = NotificationUserConfig(
+ warning_threshold=BundleThreshold("absolute", 0),
+ required_changes_threshold=BundleThreshold("absolute", 0),
+ required_changes=False,
+ status_level="informational",
+ )
+ mock_comparison = MagicMock(name="fake_bundle_analysis_comparison")
+ context.__dict__["bundle_analysis_comparison"] = mock_comparison
+ message = "carefully crafted message"
+ return (fake_repo_provider, mock_pull, context, message)
+
+ @pytest.mark.parametrize(
+ "torngit_ghapp_data",
+ [
+ pytest.param(None, id="no_app_used"),
+ pytest.param(
+ {
+ "installation_id": 123,
+ "id": 12,
+ "app_id": 12300,
+ "pem_path": "some_path",
+ },
+ id="some_app_used",
+ ),
+ ],
+ )
+ def test_send_message_no_exising_comment(
+ self, dbsession, mocker, torngit_ghapp_data
+ ):
+ fake_repo_provider, mock_pull, context, message = (
+ self._setup_send_message_tests(
+ dbsession, mocker, torngit_ghapp_data, bundle_analysis_commentid=None
+ )
+ )
+ strategy = BundleAnalysisCommentMarkdownStrategy()
+ result = strategy.send_message(context, message)
+ expected_app = torngit_ghapp_data.get("id") if torngit_ghapp_data else None
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=expected_app,
+ )
+ fake_repo_provider.post_comment.assert_called_with(12, message)
+ fake_repo_provider.edit_message.assert_not_called()
+ assert mock_pull.database_pull.bundle_analysis_commentid == 1000
+
+ @pytest.mark.parametrize(
+ "torngit_ghapp_data",
+ [
+ pytest.param(None, id="no_app_used"),
+ pytest.param(
+ {
+ "installation_id": 123,
+ "id": 12,
+ "app_id": 12300,
+ "pem_path": "some_path",
+ },
+ id="some_app_used",
+ ),
+ ],
+ )
+ def test_send_message_exising_comment(self, dbsession, mocker, torngit_ghapp_data):
+ fake_repo_provider, _, context, message = self._setup_send_message_tests(
+ dbsession, mocker, torngit_ghapp_data, bundle_analysis_commentid=1000
+ )
+ strategy = BundleAnalysisCommentMarkdownStrategy()
+ result = strategy.send_message(context, message)
+ expected_app = torngit_ghapp_data.get("id") if torngit_ghapp_data else None
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=expected_app,
+ )
+ fake_repo_provider.edit_comment.assert_called_with(12, 1000, message)
+ fake_repo_provider.post_comment.assert_not_called()
+
+ def test_send_message_fail(self, dbsession, mocker):
+ fake_repo_provider, _, context, message = self._setup_send_message_tests(
+ dbsession, mocker, None, bundle_analysis_commentid=None
+ )
+ fake_repo_provider.post_comment.side_effect = TorngitClientError()
+ context.__dict__["commit_report"] = MagicMock(
+ name="fake_commit_report", external_id="some_UUID4"
+ )
+ strategy = BundleAnalysisCommentMarkdownStrategy()
+ result = strategy.send_message(context, message)
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="TorngitClientError",
+ )
+
+
+class TestUpgradeMessage:
+ def test_build_upgrade_message_cloud(self, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ context = BundleAnalysisPRCommentNotificationContext(
+ head_commit, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ context.should_use_upgrade_comment = True
+ context.pull = MagicMock(
+ name="fake_pull",
+ database_pull=PullFactory(),
+ provider_pull={"author": {"username": "PR_author_username"}},
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.messages.comment.requires_license",
+ return_value=False,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.messages.comment.get_members_url",
+ return_value="http://members_url",
+ )
+ strategy = BundleAnalysisCommentMarkdownStrategy()
+ result = strategy.build_message(context)
+ assert result == dedent("""\
+ The author of this PR, PR_author_username, is not an activated member of this organization on Codecov.
+ Please [activate this user](http://members_url) to display this PR comment.
+ Bundle data is still being uploaded to Codecov for purposes of overall calculations.
+
+ Please don't hesitate to email us at support@codecov.io with any questions.
+ """)
+
+ def test_build_upgrade_message_self_hosted(self, dbsession, mocker):
+ head_commit, _ = get_commit_pair(dbsession)
+ context = BundleAnalysisPRCommentNotificationContext(
+ head_commit, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ context.should_use_upgrade_comment = True
+ context.pull = MagicMock(
+ name="fake_pull",
+ database_pull=PullFactory(),
+ provider_pull={"author": {"username": "PR_author_username"}},
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.messages.comment.requires_license",
+ return_value=True,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.messages.comment.get_members_url",
+ return_value="http://members_url",
+ )
+ strategy = BundleAnalysisCommentMarkdownStrategy()
+ result = strategy.build_message(context)
+ assert result == dedent("""\
+ The author of this PR, PR_author_username, is not activated in your Codecov Self-Hosted installation.
+ Please [activate this user](http://members_url) to display this PR comment.
+ Bundle data is still being uploaded to your instance of Codecov for purposes of overall calculations.
+
+ Please contact your Codecov On-Premises installation administrator with any questions.
+ """)
diff --git a/apps/worker/services/bundle_analysis/notify/messages/tests/test_commit_status.py b/apps/worker/services/bundle_analysis/notify/messages/tests/test_commit_status.py
new file mode 100644
index 0000000000..e1799b5a73
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/messages/tests/test_commit_status.py
@@ -0,0 +1,310 @@
+from unittest.mock import MagicMock
+
+import pytest
+from mock import AsyncMock
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.torngit.exceptions import TorngitClientError
+from shared.typings.torngit import TorngitInstanceData
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from database.tests.factories.core import PullFactory
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_enriched_pull_setting_up_mocks,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts.commit_status import (
+ CommitStatusNotificationContext,
+ CommitStatusNotificationContextBuilder,
+)
+from services.bundle_analysis.notify.messages.commit_status import (
+ CommitStatusMessageStrategy,
+)
+from services.notification.notifiers.base import NotificationResult
+from services.seats import SeatActivationInfo, ShouldActivateSeat
+from tests.helpers import mock_all_plans_and_tiers
+
+
+class FakeRedis(object):
+ """
+ This is a fake, very rudimentary redis implementation to ease the managing
+ of mocking `set`, `get`.
+ """
+
+ def __init__(self) -> None:
+ self.inner_dict = {}
+
+ def set(self, key, ttl, value):
+ self.inner_dict[key] = value
+
+ def get(self, key):
+ if key in self.inner_dict:
+ return self.inner_dict[key]
+ return None
+
+
+@pytest.fixture
+def mock_cache(mocker):
+ fake_cache = mocker.MagicMock(name="fake_cache")
+ fake_cache.get_backend.return_value = FakeRedis()
+ mocker.patch(
+ "services.bundle_analysis.notify.messages.commit_status.cache", fake_cache
+ )
+ return fake_cache
+
+
+class TestCommitStatusMessage:
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ mock_all_plans_and_tiers()
+
+ @pytest.mark.parametrize(
+ "user_config, expected",
+ [
+ pytest.param(
+ PATCH_CENTRIC_DEFAULT_CONFIG,
+ "Bundle change: -48.89% (Threshold: 5.0%)",
+ id="default_config",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 500000},
+ },
+ "Bundle change: -372.56kB (Threshold: 500.0kB)",
+ id="success_absolute_threshold",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 300000},
+ },
+ "Bundle change: -372.56kB (Threshold: 300.0kB)",
+ id="warning_absolute_threshold",
+ ),
+ ],
+ )
+ @pytest.mark.django_db
+ def test_build_message_from_samples_negative_changes(
+ self, user_config, expected, dbsession, mocker, mock_storage
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=2
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=1
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict(user_config)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ context = builder.build_context().get_result()
+ message = CommitStatusMessageStrategy().build_message(context)
+ assert message == expected
+
+ @pytest.mark.parametrize(
+ "user_config, expected",
+ [
+ pytest.param(
+ PATCH_CENTRIC_DEFAULT_CONFIG,
+ "Passed with Warnings - Bundle change: 95.64% (Threshold: 5.0%)",
+ id="default_config",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 500000},
+ },
+ "Bundle change: 372.56kB (Threshold: 500.0kB)",
+ id="success_absolute_threshold",
+ ),
+ pytest.param(
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {"warning_threshold": 300000},
+ },
+ "Passed with Warnings - Bundle change: 372.56kB (Threshold: 300.0kB)",
+ id="warning_absolute_threshold",
+ ),
+ ],
+ )
+ @pytest.mark.django_db
+ def test_build_message_from_samples(
+ self, user_config, expected, dbsession, mocker, mock_storage
+ ):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=1
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=2
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ user_yaml = UserYaml.from_dict(user_config)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+
+ context = builder.build_context().get_result()
+ message = CommitStatusMessageStrategy().build_message(context)
+ assert message == expected
+
+ @pytest.mark.django_db
+ def _setup_send_message_tests(
+ self, dbsession, mocker, torngit_ghapp_data, mock_storage
+ ):
+ fake_repo_provider = MagicMock(
+ name="fake_repo_provider",
+ data=TorngitInstanceData(installation=torngit_ghapp_data),
+ set_commit_status=AsyncMock(),
+ )
+ fake_repo_provider.set_commit_status.return_value = {"id": 1000}
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ return_value=fake_repo_provider,
+ )
+
+ head_commit, base_commit = get_commit_pair(dbsession)
+ head_commit.parent_commit_id = base_commit.commitid
+ dbsession.add_all([head_commit, base_commit])
+ repository = head_commit.repository
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ dbsession.add_all([head_commit_report, base_commit_report])
+ save_mock_bundle_analysis_report(
+ repository, head_commit_report, mock_storage, sample_report_number=1
+ )
+ save_mock_bundle_analysis_report(
+ repository, base_commit_report, mock_storage, sample_report_number=2
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.fetch_and_update_pull_request_information_from_commit",
+ return_value=None,
+ )
+ mocker.patch(
+ "services.bundle_analysis.notify.contexts.commit_status.determine_seat_activation",
+ return_value=SeatActivationInfo(
+ should_activate_seat=ShouldActivateSeat.NO_ACTIVATE
+ ),
+ )
+ user_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ builder = CommitStatusNotificationContextBuilder().initialize(
+ head_commit, user_yaml, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ return (
+ fake_repo_provider,
+ builder.build_context().get_result(),
+ "Passed with Warnings - Bundle change: 95.64% (Threshold: 5.0%)",
+ )
+
+ @pytest.mark.django_db
+ @pytest.mark.parametrize(
+ "torngit_ghapp_data",
+ [
+ pytest.param(None, id="no_app_used"),
+ pytest.param(
+ {
+ "installation_id": 123,
+ "id": 12,
+ "app_id": 12300,
+ "pem_path": "some_path",
+ },
+ id="some_app_used",
+ ),
+ ],
+ )
+ def test_send_message_success(
+ self, dbsession, mocker, torngit_ghapp_data, mock_storage, mock_cache
+ ):
+ fake_repo_provider, context, message = self._setup_send_message_tests(
+ dbsession, mocker, torngit_ghapp_data, mock_storage
+ )
+ strategy = CommitStatusMessageStrategy()
+ result = strategy.send_message(context, message)
+ fake_repo_provider.set_commit_status.assert_called_with(
+ context.commit.commitid,
+ "success",
+ "codecov/bundles",
+ message,
+ context.commit_status_url,
+ )
+ expected_app = torngit_ghapp_data.get("id") if torngit_ghapp_data else None
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=expected_app,
+ )
+ # Side effect of sending message is updating the cache
+ assert mock_cache.get_backend().get(strategy._cache_key(context)) == message
+
+ @pytest.mark.django_db
+ def test_send_message_fail(self, dbsession, mocker, mock_storage):
+ fake_repo_provider, context, message = self._setup_send_message_tests(
+ dbsession, mocker, None, mock_storage
+ )
+ fake_repo_provider.set_commit_status.side_effect = TorngitClientError()
+ strategy = CommitStatusMessageStrategy()
+ result = strategy.send_message(context, message)
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="TorngitClientError",
+ )
+
+ @pytest.mark.django_db
+ def test_skip_payload_unchanged(self, dbsession, mocker, mock_storage, mock_cache):
+ fake_repo_provider, context, message = self._setup_send_message_tests(
+ dbsession, mocker, None, mock_storage
+ )
+ strategy = CommitStatusMessageStrategy()
+ mock_cache.get_backend().set(strategy._cache_key(context), 600, message)
+ result = strategy.send_message(context, message)
+ fake_repo_provider.set_commit_status.assert_not_called()
+ assert result == NotificationResult(
+ notification_attempted=False,
+ notification_successful=False,
+ explanation="payload_unchanged",
+ )
+ # Side effect of sending message is updating the cache
+ assert strategy.send_message(context, message) == NotificationResult(
+ notification_attempted=False,
+ notification_successful=False,
+ explanation="payload_unchanged",
+ )
+
+
+class TestCommitStatusUpgradeMessage:
+ def test_build_upgrade_message(self, dbsession):
+ head_commit, _ = get_commit_pair(dbsession)
+ context = CommitStatusNotificationContext(
+ head_commit, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ )
+ context.should_use_upgrade_comment = True
+ context.pull = MagicMock(
+ name="fake_pull",
+ database_pull=PullFactory(),
+ provider_pull={"author": {"username": "PR_author_username"}},
+ )
+ message = CommitStatusMessageStrategy().build_message(context)
+ assert (
+ message
+ == "Please activate user PR_author_username to display a detailed status check"
+ )
diff --git a/apps/worker/services/bundle_analysis/notify/tests/__init__.py b/apps/worker/services/bundle_analysis/notify/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/bundle_analysis/notify/tests/samples/sample_1.sqlite b/apps/worker/services/bundle_analysis/notify/tests/samples/sample_1.sqlite
new file mode 100644
index 0000000000..c67ee5d00c
Binary files /dev/null and b/apps/worker/services/bundle_analysis/notify/tests/samples/sample_1.sqlite differ
diff --git a/apps/worker/services/bundle_analysis/notify/tests/samples/sample_2.sqlite b/apps/worker/services/bundle_analysis/notify/tests/samples/sample_2.sqlite
new file mode 100644
index 0000000000..a36d35b0fd
Binary files /dev/null and b/apps/worker/services/bundle_analysis/notify/tests/samples/sample_2.sqlite differ
diff --git a/apps/worker/services/bundle_analysis/notify/tests/test_helpers.py b/apps/worker/services/bundle_analysis/notify/tests/test_helpers.py
new file mode 100644
index 0000000000..64a132f109
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/tests/test_helpers.py
@@ -0,0 +1,182 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.validation.types import BundleThreshold
+from shared.yaml import UserYaml
+
+from database.models.core import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+ Owner,
+)
+from database.tests.factories.core import OwnerFactory
+from services.bundle_analysis.notify.helpers import (
+ bytes_readable,
+ get_github_app_used,
+ get_notification_types_configured,
+ is_bundle_comparison_change_within_configured_threshold,
+ to_BundleThreshold,
+)
+from services.bundle_analysis.notify.types import NotificationType
+
+
+@pytest.mark.parametrize(
+ "input, expected",
+ [
+ pytest.param(0, "0 bytes"),
+ pytest.param(123, "123 bytes"),
+ pytest.param(1000, "1.0kB"),
+ pytest.param(1500, "1.5kB"),
+ pytest.param(1000000, "1.0MB"),
+ pytest.param(1500010, "1.5MB"),
+ pytest.param(1e9, "1.0GB"),
+ pytest.param(1230000000, "1.23GB"),
+ ],
+)
+def test_bytes_readable(input, expected):
+ assert bytes_readable(input) == expected
+
+
+@pytest.fixture
+def github_owner_no_apps(dbsession) -> Owner:
+ owner = OwnerFactory(service="github")
+ dbsession.add(owner)
+ dbsession.commit()
+ assert owner.github_app_installations == []
+ return owner
+
+
+@pytest.fixture
+def github_owner_with_apps(dbsession) -> Owner:
+ owner = OwnerFactory(service="github")
+ ghapp = GithubAppInstallation(
+ ownerid=owner.ownerid,
+ owner=owner,
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=1234,
+ )
+ dbsession.add_all([owner, ghapp])
+ dbsession.commit()
+ assert owner.github_app_installations == [ghapp]
+ return owner
+
+
+@pytest.fixture
+def gitlab_owner(dbsession) -> Owner:
+ owner = OwnerFactory(service="gitlab")
+ dbsession.add(owner)
+ dbsession.commit()
+ return owner
+
+
+@pytest.mark.parametrize(
+ "config, owner_fixture, expected",
+ [
+ pytest.param(
+ {"comment": False, "bundle_analysis": {"status": False}},
+ "github_owner_no_apps",
+ (),
+ id="no_notification_configured",
+ ),
+ # The default site configuration puts the `comment` as a dict
+ pytest.param(
+ {"comment": {"require_bundle_changes": False}},
+ "github_owner_no_apps",
+ (NotificationType.PR_COMMENT, NotificationType.COMMIT_STATUS),
+ id="default_values_github_no_apps",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": False}},
+ "github_owner_with_apps",
+ (NotificationType.PR_COMMENT, NotificationType.GITHUB_COMMIT_CHECK),
+ id="default_values_github_with_apps",
+ ),
+ pytest.param(
+ {"comment": {"require_bundle_changes": False}},
+ "gitlab_owner",
+ (NotificationType.PR_COMMENT, NotificationType.COMMIT_STATUS),
+ id="default_values_gitlab",
+ ),
+ pytest.param(
+ {"comment": False, "bundle_analysis": {"status": True}},
+ "gitlab_owner",
+ (NotificationType.COMMIT_STATUS,),
+ id="just_commit_status",
+ ),
+ pytest.param(
+ {
+ "comment": {"require_bundle_changes": False},
+ "bundle_analysis": {"status": False},
+ },
+ "gitlab_owner",
+ (NotificationType.PR_COMMENT,),
+ id="just_pr_comment",
+ ),
+ ],
+)
+def test_get_configuration_types_configured(config, owner_fixture, expected, request):
+ owner = request.getfixturevalue(owner_fixture)
+ yaml = UserYaml.from_dict(config)
+ assert get_notification_types_configured(yaml, owner) == expected
+
+
+@pytest.mark.parametrize(
+ "torngit, expected",
+ [
+ pytest.param(None, None, id="no_torngit"),
+ pytest.param(
+ MagicMock(data={"installation": None}), None, id="torngit_no_installation"
+ ),
+ pytest.param(
+ MagicMock(data={"installation": {"id": 12}}),
+ 12,
+ id="torngit_with_installation",
+ ),
+ ],
+)
+def test_get_github_app_used(torngit, expected):
+ assert get_github_app_used(torngit) == expected
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ (100, BundleThreshold("absolute", 100)),
+ (0, BundleThreshold("absolute", 0)),
+ (14.5, BundleThreshold("percentage", 14.5)),
+ (["percentage", 14.5], BundleThreshold("percentage", 14.5)),
+ (["absolute", 1000], BundleThreshold("absolute", 1000)),
+ (BundleThreshold("absolute", 1000), BundleThreshold("absolute", 1000)),
+ (("absolute", 1000), BundleThreshold("absolute", 1000)),
+ ],
+)
+def test_to_BundleThreshold(value, expected):
+ assert to_BundleThreshold(value) == expected
+
+
+@pytest.mark.parametrize("value", ["value", [1, 2, 3], None])
+def test_to_BundleThreshold_raises(value):
+ with pytest.raises(TypeError):
+ to_BundleThreshold(value)
+
+
+@pytest.mark.parametrize(
+ "threshold, expected",
+ [
+ (BundleThreshold("absolute", 10001), True),
+ (BundleThreshold("absolute", 10000), True),
+ (BundleThreshold("absolute", 9999), False),
+ (BundleThreshold("percentage", 13.0), True),
+ (BundleThreshold("percentage", 12.5), True),
+ (BundleThreshold("percentage", 12.0), False),
+ ],
+)
+def test_is_bundle_change_within_bundle_threshold(threshold, expected):
+ comparison = MagicMock(
+ name="fake_comparison", total_size_delta=10000, percentage_delta=12.5
+ )
+ assert comparison.total_size_delta == 10000
+ assert (
+ is_bundle_comparison_change_within_configured_threshold(comparison, threshold)
+ == expected
+ )
diff --git a/apps/worker/services/bundle_analysis/notify/tests/test_notify_service.py b/apps/worker/services/bundle_analysis/notify/tests/test_notify_service.py
new file mode 100644
index 0000000000..6a1155e116
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/tests/test_notify_service.py
@@ -0,0 +1,311 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.yaml import UserYaml
+
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME
+from database.tests.factories.core import CommitFactory
+from services.bundle_analysis.notify import (
+ BundleAnalysisNotifyReturn,
+ BundleAnalysisNotifyService,
+ NotificationSuccess,
+)
+from services.bundle_analysis.notify.conftest import (
+ get_commit_pair,
+ get_enriched_pull_setting_up_mocks,
+ get_report_pair,
+ save_mock_bundle_analysis_report,
+)
+from services.bundle_analysis.notify.contexts import (
+ BaseBundleAnalysisNotificationContext,
+ NotificationContextBuildError,
+)
+from services.bundle_analysis.notify.contexts.comment import (
+ BundleAnalysisPRCommentNotificationContext,
+)
+from services.bundle_analysis.notify.messages.comment import (
+ BundleAnalysisCommentMarkdownStrategy,
+)
+from services.bundle_analysis.notify.types import NotificationType
+from services.notification.notifiers.base import NotificationResult
+from tests.helpers import mock_all_plans_and_tiers
+
+
+def override_comment_builder_and_message_strategy(mocker):
+ mock_comment_builder = MagicMock(name="fake_builder")
+ mock_comment_builder.get_result.return_value = "D. Context"
+ mock_comment_builder.build_context.return_value = mock_comment_builder
+ mock_comment_builder.initialize_from_context.return_value = mock_comment_builder
+ mock_comment_builder = mocker.patch(
+ "services.bundle_analysis.notify.BundleAnalysisPRCommentContextBuilder",
+ return_value=mock_comment_builder,
+ )
+ mock_markdown_strategy = MagicMock(name="fake_markdown_strategy")
+ mock_markdown_strategy = mocker.patch(
+ "services.bundle_analysis.notify.BundleAnalysisCommentMarkdownStrategy",
+ return_value=mock_markdown_strategy,
+ )
+ mock_comment_builder.return_value.get_result.return_value = MagicMock(
+ name="fake_context", notification_type=NotificationType.PR_COMMENT
+ )
+ mock_markdown_strategy.build_message.return_value = "D. Message"
+ mock_markdown_strategy.send_message.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=None,
+ )
+ return (mock_comment_builder, mock_markdown_strategy)
+
+
+def override_commit_status_builder_and_message_strategy(mocker):
+ mock_commit_status_builder = MagicMock(name="fake_commit_status_builder")
+ mock_commit_status_builder.get_result.return_value = "D. Context"
+ mock_commit_status_builder.build_context.return_value = mock_commit_status_builder
+ mock_commit_status_builder.initialize_from_context.return_value = (
+ mock_commit_status_builder
+ )
+ mock_commit_status_builder = mocker.patch(
+ "services.bundle_analysis.notify.CommitStatusNotificationContextBuilder",
+ return_value=mock_commit_status_builder,
+ )
+ commit_status_message_strategy = MagicMock(name="fake_markdown_strategy")
+ commit_status_message_strategy = mocker.patch(
+ "services.bundle_analysis.notify.CommitStatusMessageStrategy",
+ return_value=commit_status_message_strategy,
+ )
+ mock_commit_status_builder.return_value.get_result.return_value = MagicMock(
+ name="fake_context", notification_type=NotificationType.COMMIT_STATUS
+ )
+ commit_status_message_strategy.build_message.return_value = "D. Message"
+ commit_status_message_strategy.send_message.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ github_app_used=None,
+ )
+ return (mock_commit_status_builder, commit_status_message_strategy)
+
+
+@pytest.fixture
+def mock_base_context():
+ context_requirements = (
+ CommitFactory(),
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ )
+ context = BaseBundleAnalysisNotificationContext(*context_requirements)
+ context.commit_report = MagicMock(name="fake_CommitReport")
+ context.bundle_analysis_report = MagicMock(name="fake_BundleAnalysisReport")
+ return context
+
+
+class TestCreateContextForNotification:
+ def test_build_base_context(self, mocker, dbsession, mock_storage):
+ head_commit, base_commit = get_commit_pair(dbsession)
+ head_commit_report, _ = get_report_pair(dbsession, (head_commit, base_commit))
+ save_mock_bundle_analysis_report(
+ head_commit.repository,
+ head_commit_report,
+ mock_storage,
+ sample_report_number=1,
+ )
+ service = BundleAnalysisNotifyService(
+ head_commit, UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ )
+ base_context = service.build_base_context()
+ assert base_context.commit_report == head_commit_report
+ assert base_context.bundle_analysis_report.session_count() == 19
+
+ @pytest.mark.django_db
+ def test_create_context_success(self, dbsession, mock_storage, mocker):
+ mock_all_plans_and_tiers()
+ current_yaml = UserYaml.from_dict(PATCH_CENTRIC_DEFAULT_CONFIG)
+ head_commit, base_commit = get_commit_pair(dbsession)
+ head_commit_report, base_commit_report = get_report_pair(
+ dbsession, (head_commit, base_commit)
+ )
+ save_mock_bundle_analysis_report(
+ head_commit.repository,
+ head_commit_report,
+ mock_storage,
+ sample_report_number=1,
+ )
+ save_mock_bundle_analysis_report(
+ head_commit.repository,
+ base_commit_report,
+ mock_storage,
+ sample_report_number=2,
+ )
+ enriched_pull = get_enriched_pull_setting_up_mocks(
+ dbsession, mocker, (head_commit, base_commit)
+ )
+ service = BundleAnalysisNotifyService(head_commit, current_yaml)
+ result = service.create_context_for_notification(
+ BaseBundleAnalysisNotificationContext(
+ head_commit, GITHUB_APP_INSTALLATION_DEFAULT_NAME
+ ),
+ NotificationType.PR_COMMENT,
+ )
+ assert result is not None
+ assert isinstance(
+ result.notification_context, BundleAnalysisPRCommentNotificationContext
+ )
+ assert isinstance(
+ result.message_strategy, BundleAnalysisCommentMarkdownStrategy
+ )
+ context = result.notification_context
+ assert context.commit_report == head_commit_report
+ assert context.bundle_analysis_report.session_count() == 19
+ assert context.pull == enriched_pull
+ assert (
+ context.bundle_analysis_comparison.base_report_key
+ == base_commit_report.external_id
+ )
+ assert (
+ context.bundle_analysis_comparison.head_report_key
+ == head_commit_report.external_id
+ )
+
+ def test_create_contexts_unknown_notification(self, mock_base_context):
+ current_yaml = UserYaml.from_dict({})
+ service = BundleAnalysisNotifyService(mock_base_context.commit, current_yaml)
+ assert (
+ service.create_context_for_notification(
+ mock_base_context, "unknown_notification_type"
+ )
+ is None
+ )
+
+ def test_create_context_for_notification_build_fails(
+ self, mocker, mock_base_context
+ ):
+ mock_comment_builder = MagicMock(name="fake_builder")
+ mock_comment_builder.initialize_from_context.return_value = mock_comment_builder
+ mock_comment_builder.build_context.side_effect = NotificationContextBuildError(
+ "mock_failed_step"
+ )
+ current_yaml = UserYaml.from_dict({})
+ mock_comment_builder = mocker.patch(
+ "services.bundle_analysis.notify.BundleAnalysisPRCommentContextBuilder",
+ return_value=mock_comment_builder,
+ )
+ service = BundleAnalysisNotifyService(mock_base_context.commit, current_yaml)
+ assert (
+ service.create_context_for_notification(
+ mock_base_context, NotificationType.PR_COMMENT
+ )
+ is None
+ )
+
+
+class TestBundleAnalysisNotifyService:
+ def test_skip_all_notification_base_context_failed(
+ self, mocker, dbsession, mock_storage, caplog
+ ):
+ head_commit, _ = get_commit_pair(dbsession)
+ service = BundleAnalysisNotifyService(
+ head_commit,
+ UserYaml.from_dict({"comment": {"require_bundle_changes": False}}),
+ )
+ result = service.notify()
+ warning_logs = [
+ record for record in caplog.records if record.levelname == "WARNING"
+ ]
+ assert any(
+ warning.message == "Failed to build NotificationContext"
+ for warning in warning_logs
+ )
+ assert any(
+ warning.message
+ == "Skipping ALL notifications because there's no base context"
+ for warning in warning_logs
+ )
+ assert result == BundleAnalysisNotifyReturn(
+ notifications_configured=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ notifications_attempted=tuple(),
+ notifications_successful=tuple(),
+ )
+
+ @pytest.mark.parametrize(
+ "current_yaml, expected_configured_count, expected_success_count",
+ [
+ pytest.param(
+ {
+ "comment": {"require_bundle_changes": False},
+ "bundle_analysis": {"status": "informational"},
+ },
+ 2,
+ 2,
+ id="comment_and_status",
+ ),
+ pytest.param(
+ {
+ "comment": {"require_bundle_changes": False},
+ "bundle_analysis": {"status": False},
+ },
+ 1,
+ 1,
+ id="only_comment_sent",
+ ),
+ pytest.param(
+ {
+ "comment": False,
+ },
+ 1,
+ 1,
+ id="only_commit_status",
+ ),
+ ],
+ )
+ def test_notify(
+ self,
+ current_yaml,
+ expected_configured_count,
+ expected_success_count,
+ mocker,
+ mock_base_context,
+ ):
+ override_comment_builder_and_message_strategy(mocker)
+ override_commit_status_builder_and_message_strategy(mocker)
+
+ mocker.patch.object(
+ BundleAnalysisNotifyService,
+ "build_base_context",
+ return_value=mock_base_context,
+ )
+ current_yaml = UserYaml.from_dict(current_yaml)
+ mock_base_context.current_yaml = current_yaml
+ service = BundleAnalysisNotifyService(mock_base_context.commit, current_yaml)
+ result = service.notify()
+ assert len(result.notifications_configured) == expected_configured_count
+ assert len(result.notifications_successful) == expected_success_count
+
+ @pytest.mark.parametrize(
+ "result, success_value",
+ [
+ (
+ BundleAnalysisNotifyReturn([], [], []),
+ NotificationSuccess.NOTHING_TO_NOTIFY,
+ ),
+ (
+ BundleAnalysisNotifyReturn(
+ [NotificationType.COMMIT_STATUS],
+ [NotificationType.COMMIT_STATUS],
+ [NotificationType.COMMIT_STATUS],
+ ),
+ NotificationSuccess.FULL_SUCCESS,
+ ),
+ (
+ BundleAnalysisNotifyReturn(
+ [NotificationType.COMMIT_STATUS, NotificationType.PR_COMMENT],
+ [NotificationType.COMMIT_STATUS, NotificationType.PR_COMMENT],
+ [NotificationType.COMMIT_STATUS],
+ ),
+ NotificationSuccess.PARTIAL_SUCCESS,
+ ),
+ ],
+ )
+ def test_to_NotificationSuccess(self, result, success_value):
+ assert result.to_NotificationSuccess() == success_value
diff --git a/apps/worker/services/bundle_analysis/notify/types.py b/apps/worker/services/bundle_analysis/notify/types.py
new file mode 100644
index 0000000000..ac7de4ef12
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/notify/types.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+from enum import Enum
+from typing import Literal
+
+from shared.validation.types import BundleThreshold
+
+
+class NotificationType(Enum):
+ PR_COMMENT = "pr_comment"
+ COMMIT_STATUS = "commit_status"
+ # See docs on the difference between COMMIT_STATUS and GITHUB_COMMIT_CHECK
+ # https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#types-of-status-checks-on-github
+ GITHUB_COMMIT_CHECK = "github_commit_check"
+
+
+class NotificationSuccess(Enum):
+ ALL_ERRORED = "all_processing_results_errored"
+ NOTHING_TO_NOTIFY = "nothing_to_notify"
+ FULL_SUCCESS = "full_success"
+ PARTIAL_SUCCESS = "partial_success"
+
+
+@dataclass
+class NotificationUserConfig:
+ warning_threshold: BundleThreshold
+ status_level: bool | Literal["informational"]
+ required_changes: bool | Literal["bundle_increase"]
+ required_changes_threshold: BundleThreshold
diff --git a/apps/worker/services/bundle_analysis/report.py b/apps/worker/services/bundle_analysis/report.py
new file mode 100644
index 0000000000..3766da7aa7
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/report.py
@@ -0,0 +1,475 @@
+import logging
+import os
+import tempfile
+from dataclasses import dataclass
+from typing import Any, Dict, Optional
+
+import sentry_sdk
+import shared.storage
+from shared.bundle_analysis import BundleAnalysisReport, BundleAnalysisReportLoader
+from shared.bundle_analysis.models import AssetType, MetadataKey
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.django_apps.bundle_analysis.models import CacheConfig
+from shared.django_apps.bundle_analysis.service.bundle_analysis import (
+ BundleAnalysisCacheConfigService,
+)
+from shared.metrics import Counter
+from shared.reports.enums import UploadState, UploadType
+from shared.storage.exceptions import FileNotInStorageError, PutRequestRateLimitError
+from shared.utils.sessions import SessionType
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.orm import Session
+
+from database.enums import ReportType
+from database.models.core import Commit
+from database.models.reports import CommitReport, Upload, UploadError
+from database.models.timeseries import Measurement, MeasurementName
+from services.archive import ArchiveService
+from services.report import BaseReportService
+from services.timeseries import repository_datasets_query
+
+log = logging.getLogger(__name__)
+
+
+BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER = Counter(
+ "bundle_analysis_report_processor_runs",
+ "Number of times a BA report processor was run and with what result",
+ [
+ "result",
+ "plugin_name",
+ "error_type",
+ ],
+)
+
+
+@dataclass
+class ProcessingError:
+ code: str
+ params: Dict[str, Any]
+ is_retryable: bool = False
+
+ def as_dict(self):
+ return {"code": self.code, "params": self.params}
+
+
+@dataclass
+class ProcessingResult:
+ upload: Upload
+ commit: Commit
+ bundle_report: Optional[BundleAnalysisReport] = None
+ previous_bundle_report: Optional[BundleAnalysisReport] = None
+ session_id: Optional[int] = None
+ bundle_name: Optional[str] = None
+ error: Optional[ProcessingError] = None
+
+ def as_dict(self):
+ return {
+ "upload_id": self.upload.id_,
+ "session_id": self.session_id,
+ "bundle_name": self.bundle_name,
+ "error": self.error.as_dict() if self.error else None,
+ }
+
+ def update_upload(self, carriedforward: Optional[bool] = False) -> None:
+ """
+ Updates this result's `Upload` record with information from
+ this result.
+ """
+ db_session = self.upload.get_db_session()
+
+ if self.error:
+ self.commit.state = "error"
+ self.upload.state = "error"
+ self.upload.state_id = UploadState.ERROR.db_id
+
+ upload_error = UploadError(
+ upload_id=self.upload.id_,
+ error_code=self.error.code,
+ error_params=self.error.params,
+ )
+ db_session.add(upload_error)
+ else:
+ assert self.bundle_report is not None
+ self.commit.state = "complete"
+ self.upload.state = "processed"
+ self.upload.state_id = UploadState.PROCESSED.db_id
+ self.upload.order_number = self.session_id
+
+ if carriedforward:
+ self.upload.upload_type = SessionType.carriedforward.value
+ self.upload_type_id = UploadType.CARRIEDFORWARD.db_id
+
+ BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER.labels(
+ result="upload_error" if self.error else "processed",
+ plugin_name="n/a",
+ error_type=self.error.code if self.error else "n/a",
+ ).inc()
+ db_session.flush()
+
+
+class BundleAnalysisReportService(BaseReportService):
+ def initialize_and_save_report(
+ self, commit: Commit, report_code: str | None = None
+ ) -> CommitReport:
+ db_session = commit.get_db_session()
+
+ commit_report = (
+ db_session.query(CommitReport)
+ .filter_by(
+ commit_id=commit.id_,
+ code=report_code,
+ report_type=ReportType.BUNDLE_ANALYSIS.value,
+ )
+ .first()
+ )
+ if not commit_report:
+ commit_report = CommitReport(
+ commit_id=commit.id_,
+ code=report_code,
+ report_type=ReportType.BUNDLE_ANALYSIS.value,
+ )
+ db_session.add(commit_report)
+ db_session.flush()
+ return commit_report
+
+ def _get_parent_commit(
+ self,
+ db_session: Session,
+ head_commit: Commit,
+ head_bundle_report: Optional[BundleAnalysisReport],
+ ) -> Optional[Commit]:
+ """
+ There's two ways to retrieve parent commit of the head commit (in order of priority):
+ 1. Get the commitSha from head commit bundle report (stored in Metadata during ingestion)
+ 2. Get the head commit.parent from the DB
+ """
+ commitid = (
+ head_bundle_report
+ and head_bundle_report.metadata().get(MetadataKey.COMPARE_SHA)
+ ) or head_commit.parent_commit_id
+
+ return (
+ db_session.query(Commit)
+ .filter_by(
+ commitid=commitid,
+ repository=head_commit.repository,
+ )
+ .first()
+ )
+
+ @sentry_sdk.trace
+ def _previous_bundle_analysis_report(
+ self,
+ bundle_loader: BundleAnalysisReportLoader,
+ commit: Commit,
+ head_bundle_report: BundleAnalysisReport | None,
+ ) -> BundleAnalysisReport | None:
+ """
+ Helper function to fetch the parent commit's BAR for the purpose of matching previous bundle's
+ Assets to the current one being parsed.
+ """
+ db_session = commit.get_db_session()
+
+ parent_commit = self._get_parent_commit(
+ db_session=db_session,
+ head_commit=commit,
+ head_bundle_report=head_bundle_report,
+ )
+ if parent_commit is None:
+ return None
+
+ parent_commit_report = (
+ db_session.query(CommitReport)
+ .filter_by(
+ commit_id=parent_commit.id_,
+ report_type=ReportType.BUNDLE_ANALYSIS.value,
+ )
+ .first()
+ )
+ if parent_commit_report is None:
+ return None
+
+ return bundle_loader.load(parent_commit_report.external_id)
+
+ @sentry_sdk.trace
+ def _attempt_init_from_previous_report(
+ self,
+ commit: Commit,
+ bundle_loader: BundleAnalysisReportLoader,
+ ) -> BundleAnalysisReport:
+ """Attempts to carry over parent bundle analysis report if current commit doesn't have a report.
+ Fallback to creating a fresh bundle analysis report if there is no previous report to carry over.
+ """
+ # load a new copy of the previous bundle report into temp file
+ bundle_report = self._previous_bundle_analysis_report(
+ bundle_loader, commit, head_bundle_report=None
+ )
+ if bundle_report:
+ # query which bundle names has caching turned on
+ bundles_to_be_cached = CacheConfig.objects.filter(
+ is_caching=True,
+ repo_id=commit.repoid,
+ ).values_list("bundle_name", flat=True)
+
+ # For each bundle:
+ # if caching is on then update bundle.is_cached property to true
+ # if caching is off then delete that bundle from the report
+ update_fields = {}
+ for bundle in bundle_report.bundle_reports():
+ if bundle.name in bundles_to_be_cached:
+ update_fields[bundle.name] = True
+ else:
+ bundle_report.delete_bundle_by_name(bundle.name)
+ if update_fields:
+ bundle_report.update_is_cached(update_fields)
+ return bundle_report
+ # fallback to create a fresh bundle analysis report if there is no previous report to carry over
+ return BundleAnalysisReport()
+
+ @sentry_sdk.trace
+ def process_upload(
+ self, commit: Commit, upload: Upload, compare_sha: str | None = None
+ ) -> ProcessingResult:
+ """
+ Download and parse the data associated with the given upload and
+ merge the results into a bundle report.
+ """
+ commit_report: CommitReport = upload.report
+ repo_hash = ArchiveService.get_archive_hash(commit_report.commit.repository)
+ storage_service = shared.storage.get_appropriate_storage_service(commit.repoid)
+ bundle_loader = BundleAnalysisReportLoader(storage_service, repo_hash)
+
+ # fetch existing bundle report from storage
+ bundle_report = bundle_loader.load(commit_report.external_id)
+ if bundle_report is None:
+ bundle_report = self._attempt_init_from_previous_report(
+ commit, bundle_loader
+ )
+
+ # download raw upload data to local tempfile
+ _, local_path = tempfile.mkstemp()
+ try:
+ session_id, prev_bar, bundle_name = None, None, None
+ if upload.storage_path != "":
+ with open(local_path, "wb") as f:
+ storage_service.read_file(
+ get_bucket_name(), upload.storage_path, file_obj=f
+ )
+
+ # load the downloaded data into the bundle report
+ session_id, bundle_name = bundle_report.ingest(local_path, compare_sha)
+
+ # Retrieve previous commit's BAR and associate past Assets
+ prev_bar = self._previous_bundle_analysis_report(
+ bundle_loader, commit, head_bundle_report=bundle_report
+ )
+ if prev_bar:
+ bundle_report.associate_previous_assets(prev_bar)
+
+ # Turn on caching option by default for all new bundles only for default branch
+ if commit.branch == commit.repository.branch:
+ for bundle in bundle_report.bundle_reports():
+ BundleAnalysisCacheConfigService.create_if_not_exists(
+ commit.repoid, bundle.name
+ )
+
+ # save the bundle report back to storage
+ bundle_loader.save(bundle_report, commit_report.external_id)
+ except FileNotInStorageError as e:
+ BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER.labels(
+ result="file_not_in_storage",
+ plugin_name="n/a",
+ error_type=type(e).__name__,
+ ).inc()
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ error=ProcessingError(
+ code="file_not_in_storage",
+ params={"location": upload.storage_path},
+ is_retryable=True,
+ ),
+ )
+ except PutRequestRateLimitError as e:
+ plugin_name = getattr(e, "bundle_analysis_plugin_name", "unknown")
+ BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER.labels(
+ result="rate_limit_error",
+ plugin_name=plugin_name,
+ error_type=type(e).__name__,
+ ).inc()
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ error=ProcessingError(
+ code="rate_limit_error",
+ params={"location": upload.storage_path},
+ is_retryable=True,
+ ),
+ )
+ except Exception as e:
+ # Metrics to count number of parsing errors of bundle files by plugins
+ plugin_name = getattr(e, "bundle_analysis_plugin_name", "unknown")
+ error_type = type(e).__name__
+ BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER.labels(
+ result="parser_error",
+ plugin_name=plugin_name,
+ error_type=error_type,
+ ).inc()
+ log.error(
+ "Unable to parse upload for bundle analysis",
+ exc_info=True,
+ extra=dict(
+ repoid=commit.repoid,
+ commit=commit.commitid,
+ error_type=error_type,
+ ),
+ )
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ error=ProcessingError(
+ code="parser_error",
+ params={
+ "location": upload.storage_path,
+ "plugin_name": plugin_name,
+ },
+ is_retryable=False,
+ ),
+ )
+ finally:
+ os.remove(local_path)
+
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ bundle_report=bundle_report,
+ previous_bundle_report=prev_bar,
+ session_id=session_id,
+ bundle_name=bundle_name,
+ )
+
+ def _save_to_timeseries(
+ self,
+ db_session: Session,
+ commit: Commit,
+ name: str,
+ measurable_id: str,
+ value: float,
+ ):
+ command = postgresql.insert(Measurement.__table__).values(
+ name=name,
+ owner_id=commit.repository.ownerid,
+ repo_id=commit.repoid,
+ measurable_id=measurable_id,
+ branch=commit.branch,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ value=value,
+ )
+ command = command.on_conflict_do_update(
+ index_elements=[
+ Measurement.name,
+ Measurement.owner_id,
+ Measurement.repo_id,
+ Measurement.measurable_id,
+ Measurement.commit_sha,
+ Measurement.timestamp,
+ ],
+ set_=dict(
+ branch=command.excluded.branch,
+ value=command.excluded.value,
+ ),
+ )
+ db_session.execute(command)
+ db_session.flush()
+
+ @sentry_sdk.trace
+ def save_measurements(
+ self, commit: Commit, upload: Upload, bundle_name: str
+ ) -> ProcessingResult:
+ """
+ Save timeseries measurements for this bundle analysis report
+ """
+ try:
+ commit_report: CommitReport = upload.report
+ repo_hash = ArchiveService.get_archive_hash(commit_report.commit.repository)
+ storage_service = shared.storage.get_appropriate_storage_service(
+ commit.repoid
+ )
+ bundle_loader = BundleAnalysisReportLoader(storage_service, repo_hash)
+
+ # fetch existing bundle report from storage
+ bundle_analysis_report = bundle_loader.load(commit_report.external_id)
+
+ dataset_names = [
+ dataset.name for dataset in repository_datasets_query(commit.repository)
+ ]
+
+ db_session = commit.get_db_session()
+ bundle_report = bundle_analysis_report.bundle_report(bundle_name)
+ if bundle_report:
+ # For overall bundle size
+ if MeasurementName.bundle_analysis_report_size.value in dataset_names:
+ self._save_to_timeseries(
+ db_session,
+ commit,
+ MeasurementName.bundle_analysis_report_size.value,
+ bundle_report.name,
+ bundle_report.total_size(),
+ )
+
+ # For individual javascript associated assets using UUID
+ if MeasurementName.bundle_analysis_asset_size.value in dataset_names:
+ for asset in bundle_report.asset_reports():
+ if asset.asset_type == AssetType.JAVASCRIPT:
+ self._save_to_timeseries(
+ db_session,
+ commit,
+ MeasurementName.bundle_analysis_asset_size.value,
+ asset.uuid,
+ asset.size,
+ )
+
+ # For asset types sizes
+ asset_type_map = {
+ MeasurementName.bundle_analysis_font_size: AssetType.FONT,
+ MeasurementName.bundle_analysis_image_size: AssetType.IMAGE,
+ MeasurementName.bundle_analysis_stylesheet_size: AssetType.STYLESHEET,
+ MeasurementName.bundle_analysis_javascript_size: AssetType.JAVASCRIPT,
+ }
+ for measurement_name, asset_type in asset_type_map.items():
+ if measurement_name.value in dataset_names:
+ total_size = 0
+ for asset in bundle_report.asset_reports():
+ if asset.asset_type == asset_type:
+ total_size += asset.size
+ self._save_to_timeseries(
+ db_session,
+ commit,
+ measurement_name.value,
+ bundle_report.name,
+ total_size,
+ )
+
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ )
+ except Exception as e:
+ BUNDLE_ANALYSIS_REPORT_PROCESSOR_COUNTER.labels(
+ result="parser_error",
+ plugin_name="n/a",
+ error_type=type(e).__name__,
+ ).inc()
+ return ProcessingResult(
+ upload=upload,
+ commit=commit,
+ error=ProcessingError(
+ code="measurement_save_error",
+ params={
+ "location": upload.storage_path,
+ "repository": commit.repository.repoid,
+ },
+ is_retryable=False,
+ ),
+ )
diff --git a/apps/worker/services/bundle_analysis/tests/__init__.py b/apps/worker/services/bundle_analysis/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/bundle_analysis/tests/test_bundle_analysis.py b/apps/worker/services/bundle_analysis/tests/test_bundle_analysis.py
new file mode 100644
index 0000000000..d98349c1f1
--- /dev/null
+++ b/apps/worker/services/bundle_analysis/tests/test_bundle_analysis.py
@@ -0,0 +1,1594 @@
+from textwrap import dedent
+from typing import Dict, List
+from unittest.mock import PropertyMock
+
+import pytest
+from shared.bundle_analysis.comparison import (
+ AssetChange,
+ AssetComparison,
+ BundleChange,
+ RouteChange,
+)
+from shared.bundle_analysis.models import AssetType
+from shared.bundle_analysis.storage import get_bucket_name
+from shared.config import PATCH_CENTRIC_DEFAULT_CONFIG
+from shared.yaml import UserYaml
+
+from database.enums import ReportType
+from database.models import CommitReport, MeasurementName
+from database.tests.factories import CommitFactory, PullFactory, UploadFactory
+from database.tests.factories.timeseries import DatasetFactory, Measurement
+from services.archive import ArchiveService
+from services.bundle_analysis.notify import (
+ BundleAnalysisNotifyReturn,
+ BundleAnalysisNotifyService,
+)
+from services.bundle_analysis.notify.types import NotificationType
+from services.bundle_analysis.report import (
+ BundleAnalysisReportService,
+ ProcessingResult,
+)
+from services.repository import EnrichedPull
+from services.urls import get_bundle_analysis_pull_url
+from tests.helpers import mock_all_plans_and_tiers
+
+
+class MockBundleReport:
+ def __init__(self, name):
+ self.name = name
+
+ def total_size(self):
+ return 123456
+
+ def is_cached(self):
+ return self.name.startswith("cached")
+
+ def asset_reports(self):
+ return []
+
+
+def hook_mock_repo_provider(mocker, mock_repo_provider):
+ USING_GET_REPO_PROVIDER = [
+ "services.bundle_analysis.notify.contexts.get_repo_provider_service",
+ ]
+ for usage in USING_GET_REPO_PROVIDER:
+ mocker.patch(
+ usage,
+ return_value=mock_repo_provider,
+ )
+
+
+def hook_mock_pull(mocker, mock_pull):
+ USING_MOCK_PULL = [
+ "services.bundle_analysis.notify.contexts.comment.fetch_and_update_pull_request_information_from_commit",
+ "services.bundle_analysis.notify.contexts.commit_status.fetch_and_update_pull_request_information_from_commit",
+ ]
+ for usage in USING_MOCK_PULL:
+ mocker.patch(usage, return_value=mock_pull)
+
+
+@pytest.mark.asyncio
+async def test_bundle_analysis_save_measurements_report_size(
+ dbsession, mocker, mock_storage
+):
+ storage_path = (
+ "v1/repos/testing/ed1bdd67-8fd2-4cdb-ac9e-39b99e4a3892/bundle_report.sqlite"
+ )
+ mock_storage.write_file(get_bucket_name(), storage_path, "test-content")
+
+ commit = CommitFactory()
+ dbsession.add(commit)
+ dbsession.commit()
+
+ commit_report = CommitReport(
+ commit=commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(commit_report)
+ dbsession.commit()
+
+ upload = UploadFactory.create(storage_path=storage_path, report=commit_report)
+ dbsession.add(upload)
+ dbsession.commit()
+
+ dataset = DatasetFactory.create(
+ name=MeasurementName.bundle_analysis_report_size.value,
+ repository_id=commit.repository.repoid,
+ )
+ dbsession.add(dataset)
+ dbsession.commit()
+
+ class MockBundleReport:
+ def __init__(self, bundle_name, size):
+ self.bundle_name = bundle_name
+ self.size = size
+
+ @property
+ def name(self):
+ return self.bundle_name
+
+ def total_size(self):
+ return self.size
+
+ class MockBundleAnalysisReport:
+ def bundle_report(self, bundle_name):
+ return MockBundleReport("BundleA", 1111)
+
+ mocker.patch(
+ "shared.bundle_analysis.BundleAnalysisReportLoader.load",
+ return_value=MockBundleAnalysisReport(),
+ )
+
+ report_service = BundleAnalysisReportService(UserYaml.from_dict({}))
+ result: ProcessingResult = report_service.save_measurements(
+ commit, upload, "BundleA"
+ )
+
+ assert result.error is None
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_report_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleA",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 1111
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_report_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleB",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 0
+
+
+@pytest.mark.asyncio
+async def test_bundle_analysis_save_measurements_asset_size(
+ dbsession, mocker, mock_storage
+):
+ storage_path = (
+ "v1/repos/testing/ed1bdd67-8fd2-4cdb-ac9e-39b99e4a3892/bundle_report.sqlite"
+ )
+ mock_storage.write_file(get_bucket_name(), storage_path, "test-content")
+
+ commit = CommitFactory()
+ dbsession.add(commit)
+ dbsession.commit()
+
+ commit_report = CommitReport(
+ commit=commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(commit_report)
+ dbsession.commit()
+
+ upload = UploadFactory.create(storage_path=storage_path, report=commit_report)
+ dbsession.add(upload)
+ dbsession.commit()
+
+ dataset = DatasetFactory.create(
+ name=MeasurementName.bundle_analysis_asset_size.value,
+ repository_id=commit.repository.repoid,
+ )
+ dbsession.add(dataset)
+ dbsession.commit()
+
+ class MockAssetReport:
+ def __init__(self, mock_uuid, mock_size, mock_type):
+ self.mock_uuid = mock_uuid
+ self.mock_size = mock_size
+ self.mock_type = mock_type
+
+ @property
+ def uuid(self):
+ return self.mock_uuid
+
+ @property
+ def size(self):
+ return self.mock_size
+
+ @property
+ def asset_type(self):
+ return self.mock_type
+
+ class MockBundleReport:
+ def __init__(self, bundle_name, size):
+ self.bundle_name = bundle_name
+ self.size = size
+
+ @property
+ def name(self):
+ return self.bundle_name
+
+ def total_size(self):
+ return self.size
+
+ def asset_reports(self):
+ return [
+ MockAssetReport("UUID1", 123, AssetType.JAVASCRIPT),
+ MockAssetReport("UUID2", 321, AssetType.JAVASCRIPT),
+ ]
+
+ class MockBundleAnalysisReport:
+ def bundle_report(self, bundle_name):
+ return MockBundleReport("BundleA", 1111)
+
+ mocker.patch(
+ "shared.bundle_analysis.BundleAnalysisReportLoader.load",
+ return_value=MockBundleAnalysisReport(),
+ )
+
+ report_service = BundleAnalysisReportService(UserYaml.from_dict({}))
+ result: ProcessingResult = report_service.save_measurements(
+ commit, upload, "BundleA"
+ )
+
+ assert result.error is None
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_asset_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="UUID1",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 123
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_asset_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="UUID2",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 321
+
+
+@pytest.mark.asyncio
+async def test_bundle_analysis_save_measurements_asset_type_sizes(
+ dbsession, mocker, mock_storage
+):
+ storage_path = (
+ "v1/repos/testing/ed1bdd67-8fd2-4cdb-ac9e-39b99e4a3892/bundle_report.sqlite"
+ )
+ mock_storage.write_file(get_bucket_name(), storage_path, "test-content")
+
+ commit = CommitFactory()
+ dbsession.add(commit)
+ dbsession.commit()
+
+ commit_report = CommitReport(
+ commit=commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(commit_report)
+ dbsession.commit()
+
+ upload = UploadFactory.create(storage_path=storage_path, report=commit_report)
+ dbsession.add(upload)
+ dbsession.commit()
+
+ measurements_datasets = [
+ MeasurementName.bundle_analysis_stylesheet_size,
+ MeasurementName.bundle_analysis_font_size,
+ MeasurementName.bundle_analysis_image_size,
+ MeasurementName.bundle_analysis_javascript_size,
+ ]
+ for measurement in measurements_datasets:
+ dataset = DatasetFactory.create(
+ name=measurement.value,
+ repository_id=commit.repository.repoid,
+ )
+ dbsession.add(dataset)
+ dbsession.commit()
+
+ class MockAssetReport:
+ def __init__(self, mock_uuid, mock_size, mock_type):
+ self.mock_uuid = mock_uuid
+ self.mock_size = mock_size
+ self.mock_type = mock_type
+
+ @property
+ def uuid(self):
+ return self.mock_uuid
+
+ @property
+ def size(self):
+ return self.mock_size
+
+ @property
+ def asset_type(self):
+ return self.mock_type
+
+ class MockBundleReport:
+ def __init__(self, bundle_name, size):
+ self.bundle_name = bundle_name
+ self.size = size
+
+ @property
+ def name(self):
+ return self.bundle_name
+
+ def total_size(self):
+ return self.size
+
+ def asset_reports(self):
+ return [
+ MockAssetReport("UUID1", 111, AssetType.JAVASCRIPT),
+ MockAssetReport("UUID2", 222, AssetType.FONT),
+ MockAssetReport("UUID3", 333, AssetType.IMAGE),
+ MockAssetReport("UUID4", 444, AssetType.STYLESHEET),
+ ]
+
+ class MockBundleAnalysisReport:
+ def bundle_report(self, bundle_name):
+ return MockBundleReport("BundleA", 1111)
+
+ mocker.patch(
+ "shared.bundle_analysis.BundleAnalysisReportLoader.load",
+ return_value=MockBundleAnalysisReport(),
+ )
+
+ report_service = BundleAnalysisReportService(UserYaml.from_dict({}))
+ result: ProcessingResult = report_service.save_measurements(
+ commit, upload, "BundleA"
+ )
+
+ assert result.error is None
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_javascript_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleA",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 111
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_font_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleA",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 222
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_image_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleA",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 333
+
+ measurements = (
+ dbsession.query(Measurement)
+ .filter_by(
+ name=MeasurementName.bundle_analysis_stylesheet_size.value,
+ commit_sha=commit.commitid,
+ timestamp=commit.timestamp,
+ measurable_id="BundleA",
+ )
+ .all()
+ )
+
+ assert len(measurements) == 1
+ assert measurements[0].value == 444
+
+
+@pytest.mark.asyncio
+async def test_bundle_analysis_save_measurements_error(dbsession, mocker, mock_storage):
+ storage_path = (
+ "v1/repos/testing/ed1bdd67-8fd2-4cdb-ac9e-39b99e4a3892/bundle_report.sqlite"
+ )
+ mock_storage.write_file(get_bucket_name(), storage_path, "test-content")
+
+ commit = CommitFactory()
+ dbsession.add(commit)
+ dbsession.commit()
+
+ commit_report = CommitReport(
+ commit=commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(commit_report)
+ dbsession.commit()
+
+ upload = UploadFactory.create(storage_path=storage_path, report=commit_report)
+ dbsession.add(upload)
+ dbsession.commit()
+
+ dataset = DatasetFactory.create(
+ name=MeasurementName.bundle_analysis_asset_size.value,
+ repository_id=commit.repository.repoid,
+ )
+ dbsession.add(dataset)
+ dbsession.commit()
+
+ mocker.patch(
+ "shared.bundle_analysis.BundleAnalysisReportLoader.load",
+ return_value=None,
+ )
+
+ report_service = BundleAnalysisReportService(UserYaml.from_dict({}))
+ result: ProcessingResult = report_service.save_measurements(
+ commit, upload, "BundleA"
+ )
+
+ assert result.error is not None
+
+
+@pytest.mark.parametrize(
+ "bundle_changes, percent_change, user_config, expected_message",
+ [
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="added-bundle",
+ change_type=BundleChange.ChangeType.ADDED,
+ size_delta=12345,
+ percentage_delta=5.56,
+ ),
+ BundleChange(
+ bundle_name="changed-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=3456,
+ percentage_delta=0.35,
+ ),
+ BundleChange(
+ bundle_name="removed-bundle",
+ change_type=BundleChange.ChangeType.REMOVED,
+ size_delta=-1234,
+ percentage_delta=-1.23,
+ ),
+ ],
+ 5.56,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 14.57kB (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | added-bundle | 123.46kB | 12.35kB (5.56%) :arrow_up::warning: |
+ | changed-bundle | 123.46kB | 3.46kB (0.35%) :arrow_up: |
+ | removed-bundle | (removed) | -1.23kB (-1.23%) :arrow_down: |
+ """),
+ id="comment_increase_size_warning",
+ ),
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="added-bundle",
+ change_type=BundleChange.ChangeType.ADDED,
+ size_delta=12345,
+ percentage_delta=5.56,
+ ),
+ BundleChange(
+ bundle_name="changed-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=3456,
+ percentage_delta=2.56,
+ ),
+ BundleChange(
+ bundle_name="removed-bundle",
+ change_type=BundleChange.ChangeType.REMOVED,
+ size_delta=-1234,
+ percentage_delta=-100.0,
+ ),
+ ],
+ 5.56,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": True,
+ "warning_threshold": ["absolute", 10000],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ :x: Check failed: changes will increase total bundle size by 14.57kB (5.56%) :arrow_up:, **exceeding** the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 10.0kB.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | added-bundle | 123.46kB | 12.35kB (5.56%) :arrow_up::x: |
+ | changed-bundle | 123.46kB | 3.46kB (2.56%) :arrow_up: |
+ | removed-bundle | (removed) | -1.23kB (-100.0%) :arrow_down: |
+ """),
+ id="comment_increase_size_error",
+ ),
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="added-bundle",
+ change_type=BundleChange.ChangeType.ADDED,
+ size_delta=12345,
+ percentage_delta=2.56,
+ ),
+ BundleChange(
+ bundle_name="cached-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=3456,
+ percentage_delta=2.56,
+ ),
+ BundleChange(
+ bundle_name="removed-bundle",
+ change_type=BundleChange.ChangeType.REMOVED,
+ size_delta=-1234,
+ percentage_delta=2.56,
+ ),
+ ],
+ 3.46,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 14.57kB (3.46%) :arrow_up:. This is within the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold :white_check_mark:
+
+ Detailed changes
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | added-bundle | 123.46kB | 12.35kB (2.56%) :arrow_up: |
+ | cached-bundle* | 123.46kB | 3.46kB (2.56%) :arrow_up: |
+ | removed-bundle | (removed) | -1.23kB (2.56%) :arrow_down: |
+
+
+
+ ℹ️ *Bundle size includes cached data from a previous commit
+
+ """),
+ id="comment_increase_size_cached_values",
+ ),
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="test-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=-3456,
+ percentage_delta=-2.56,
+ ),
+ ],
+ -0.52,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will decrease total bundle size by 3.46kB (-0.52%) :arrow_down:. This is within the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold :white_check_mark:
+
+ Detailed changes
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | test-bundle | 123.46kB | -3.46kB (-2.56%) :arrow_down: |
+
+
+ """),
+ id="comment_decrease_size",
+ ),
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="test-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=0,
+ percentage_delta=0.0,
+ ),
+ ],
+ 0,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Bundle size has no change :white_check_mark:
+
+
+ """),
+ id="comment_no_change",
+ ),
+ pytest.param(
+ [
+ BundleChange(
+ bundle_name="added-bundle",
+ change_type=BundleChange.ChangeType.ADDED,
+ size_delta=12345,
+ percentage_delta=5.56,
+ ),
+ BundleChange(
+ bundle_name="changed-bundle",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=3456,
+ percentage_delta=0.35,
+ ),
+ BundleChange(
+ bundle_name="removed-bundle",
+ change_type=BundleChange.ChangeType.REMOVED,
+ size_delta=-1234,
+ percentage_delta=-1.23,
+ ),
+ ],
+ 5.56,
+ {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ },
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 14.57kB (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | added-bundle | 123.46kB | 12.35kB (5.56%) :arrow_up::warning: |
+ | changed-bundle | 123.46kB | 3.46kB (0.35%) :arrow_up: |
+ | removed-bundle | (removed) | -1.23kB (-1.23%) :arrow_down: |
+ """),
+ id="comparison_by_file_path",
+ ),
+ ],
+)
+@pytest.mark.django_db
+def test_bundle_analysis_notify_bundle_summary(
+ bundle_changes: list[BundleChange],
+ percent_change: float,
+ user_config: dict,
+ expected_message: str,
+ dbsession,
+ mocker,
+ mock_storage,
+ mock_repo_provider,
+):
+ mock_all_plans_and_tiers()
+ hook_mock_repo_provider(mocker, mock_repo_provider)
+ base_commit = CommitFactory()
+ dbsession.add(base_commit)
+ base_commit_report = CommitReport(
+ commit=base_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(base_commit_report)
+
+ head_commit = CommitFactory(repository=base_commit.repository)
+ dbsession.add(head_commit)
+ head_commit_report = CommitReport(
+ commit=head_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(head_commit_report)
+
+ pull = PullFactory(
+ repository=base_commit.repository,
+ head=head_commit.commitid,
+ base=base_commit.commitid,
+ compared_to=base_commit.commitid,
+ )
+ dbsession.add(pull)
+ dbsession.commit()
+
+ notifier = BundleAnalysisNotifyService(head_commit, UserYaml.from_dict(user_config))
+
+ repo_key = ArchiveService.get_archive_hash(base_commit.repository)
+ mock_storage.write_file(
+ get_bucket_name(),
+ f"v1/repos/{repo_key}/{base_commit_report.external_id}/bundle_report.sqlite",
+ "test-content",
+ )
+ mock_storage.write_file(
+ get_bucket_name(),
+ f"v1/repos/{repo_key}/{head_commit_report.external_id}/bundle_report.sqlite",
+ "test-content",
+ )
+
+ mocker.patch("shared.bundle_analysis.report.BundleAnalysisReport._setup")
+
+ mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.bundle_changes",
+ return_value=bundle_changes,
+ )
+ mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.bundle_routes_changes",
+ return_value={},
+ )
+ mock_percentage = mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.percentage_delta",
+ new_callable=PropertyMock,
+ )
+ mock_percentage.return_value = percent_change
+
+ mocker.patch(
+ "shared.bundle_analysis.report.BundleAnalysisReport.bundle_report",
+ side_effect=lambda name: MockBundleReport(name),
+ )
+
+ hook_mock_pull(
+ mocker,
+ EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ ),
+ )
+
+ mock_check_compare_sha = mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison._check_compare_sha"
+ )
+ mock_check_compare_sha.return_value = None
+
+ expected_message = expected_message.replace(
+ "URL", get_bundle_analysis_pull_url(pull=pull)
+ )
+
+ mock_repo_provider.post_comment.return_value = {"id": "test-comment-id"}
+
+ success = notifier.notify()
+ assert success == BundleAnalysisNotifyReturn(
+ notifications_configured=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ notifications_attempted=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ notifications_successful=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ )
+
+ assert pull.bundle_analysis_commentid is not None
+ mock_repo_provider.post_comment.assert_called_once_with(
+ pull.pullid, expected_message
+ )
+
+ success = notifier.notify()
+ assert success == BundleAnalysisNotifyReturn(
+ notifications_configured=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ notifications_attempted=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ notifications_successful=(
+ NotificationType.PR_COMMENT,
+ NotificationType.COMMIT_STATUS,
+ ),
+ )
+
+ assert pull.bundle_analysis_commentid is not None
+ mock_repo_provider.edit_comment.assert_called_once_with(
+ pull.pullid, "test-comment-id", expected_message
+ )
+
+
+class MockModuleReport:
+ def __init__(self, a, b):
+ self._name = a
+ self._size = b
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def size(self) -> int:
+ return self._size
+
+
+class MockAssetComparison:
+ def __init__(self, a, b) -> None:
+ self.asset = a
+ self.modules = [MockModuleReport(item[0], item[1]) for item in b]
+
+ def asset_change(self):
+ return self.asset
+
+ def contributing_modules(self, pr_changed_files):
+ return self.modules
+
+
+@pytest.mark.parametrize(
+ "bundle_changes, route_changes, asset_comparisons, expected_message",
+ [
+ pytest.param(
+ # Bundle Changes
+ [],
+ # Route Changes
+ {},
+ # Asset Comparisons
+ [],
+ # Expected message
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Bundle size has no change :white_check_mark:
+
+
+ """),
+ id="no_bundle_change_at_all",
+ ),
+ pytest.param(
+ # Bundle Changes
+ [
+ BundleChange(
+ bundle_name="test-no-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=0,
+ percentage_delta=0.0,
+ ),
+ BundleChange(
+ bundle_name="test-with-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=100,
+ percentage_delta=1.0,
+ ),
+ ],
+ # Route Changes
+ {
+ "test-with-change": [],
+ },
+ # Asset Comparisons
+ [
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=1000,
+ size_head=1200,
+ size_delta=200,
+ asset_name="this-is-a-warning",
+ percentage_delta=20.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.ADDED,
+ size_base=0,
+ size_head=10000,
+ size_delta=10000,
+ asset_name="this-is-added",
+ percentage_delta=100.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.REMOVED,
+ size_base=20000,
+ size_head=0,
+ size_delta=-20000,
+ asset_name="this-is-removed",
+ percentage_delta=-100.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=100000,
+ size_head=101000,
+ size_delta=1000,
+ asset_name="this-is-a-small-change",
+ percentage_delta=1.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=5000,
+ size_head=5000,
+ size_delta=0,
+ asset_name="this-is-no-change",
+ percentage_delta=0.0,
+ ),
+ [],
+ ),
+ ],
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 100 bytes (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | test-with-change | 123.46kB | 100 bytes (1.0%) :arrow_up: |
+
+ ### Affected Assets, Files, and Routes:
+
+
+ view changes for bundle: test-with-change
+
+ #### **Assets Changed:**
+ | Asset Name | Size Change | Total Size | Change (%) |
+ | ---------- | ----------- | ---------- | ---------- |
+ | ```this-is-a-warning``` | 200 bytes | 1.2kB | 20.0% :warning: |
+ | **```this-is-added```** _(New)_ | 10.0kB | 10.0kB | 100.0% :rocket: |
+ | ~~**```this-is-removed```**~~ _(Deleted)_ | -20.0kB | 0 bytes | -100.0% :wastebasket: |
+ | ```this-is-a-small-change``` | 1.0kB | 101.0kB | 1.0% |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """),
+ id="bundle_with_assets_change_only",
+ ),
+ pytest.param(
+ # Bundle Changes
+ [
+ BundleChange(
+ bundle_name="test-no-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=0,
+ percentage_delta=0.0,
+ ),
+ BundleChange(
+ bundle_name="test-with-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=100,
+ percentage_delta=1.0,
+ ),
+ ],
+ # Route Changes
+ {
+ "test-with-change": [],
+ },
+ # Asset Comparisons
+ [
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=1000,
+ size_head=1200,
+ size_delta=200,
+ asset_name="this-is-a-warning",
+ percentage_delta=20.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.ADDED,
+ size_base=0,
+ size_head=10000,
+ size_delta=10000,
+ asset_name="this-is-added",
+ percentage_delta=100.0,
+ ),
+ [("abc/def/ghi/file1.ts", 1000)],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.REMOVED,
+ size_base=20000,
+ size_head=0,
+ size_delta=-20000,
+ asset_name="this-is-removed",
+ percentage_delta=-100.0,
+ ),
+ [("abc/def/ghi/file1.ts", 1000), ("abc/def/ghi/file2.ts", 2000)],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=100000,
+ size_head=101000,
+ size_delta=1000,
+ asset_name="this-is-a-small-change",
+ percentage_delta=1.0,
+ ),
+ [
+ ("abc/def/ghi/file1.ts", 1000),
+ ("abc/def/ghi/file2.ts", 2000),
+ ("abc/def/ghi/file3.ts", 3000),
+ ],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=5000,
+ size_head=5000,
+ size_delta=0,
+ asset_name="this-is-no-change",
+ percentage_delta=0.0,
+ ),
+ [
+ ("abc/def/ghi/file1.ts", 1000),
+ ("abc/def/ghi/file2.ts", 2000),
+ ("abc/def/ghi/file3.ts", 3000),
+ ("abc/def/ghi/file4.ts", 4000),
+ ],
+ ),
+ ],
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 100 bytes (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | test-with-change | 123.46kB | 100 bytes (1.0%) :arrow_up: |
+
+ ### Affected Assets, Files, and Routes:
+
+
+ view changes for bundle: test-with-change
+
+ #### **Assets Changed:**
+ | Asset Name | Size Change | Total Size | Change (%) |
+ | ---------- | ----------- | ---------- | ---------- |
+ | ```this-is-a-warning``` | 200 bytes | 1.2kB | 20.0% :warning: |
+ | **```this-is-added```** _(New)_ | 10.0kB | 10.0kB | 100.0% :rocket: |
+ | ~~**```this-is-removed```**~~ _(Deleted)_ | -20.0kB | 0 bytes | -100.0% :wastebasket: |
+ | ```this-is-a-small-change``` | 1.0kB | 101.0kB | 1.0% |
+
+
+
+
+ **Files in** **```this-is-added```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+
+
+ **Files in** **```this-is-removed```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+ - ```abc/def/ghi/file2.ts``` → Total Size: **2.0kB**
+
+
+
+ **Files in** **```this-is-a-small-change```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+ - ```abc/def/ghi/file2.ts``` → Total Size: **2.0kB**
+
+ - ```abc/def/ghi/file3.ts``` → Total Size: **3.0kB**
+
+
+
+
+
+
+ """),
+ id="bundle_with_assets_change_and_module_list",
+ ),
+ pytest.param(
+ # Bundle Changes
+ [
+ BundleChange(
+ bundle_name="test-no-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=0,
+ percentage_delta=0.0,
+ ),
+ BundleChange(
+ bundle_name="test-with-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=100,
+ percentage_delta=1.0,
+ ),
+ ],
+ # Route Changes
+ {
+ "test-with-change": [
+ RouteChange(
+ route_name="/users",
+ change_type=RouteChange.ChangeType.ADDED,
+ size_delta=1000,
+ size_base=0,
+ size_head=1000,
+ percentage_delta=100,
+ ),
+ RouteChange(
+ route_name="/faq",
+ change_type=RouteChange.ChangeType.REMOVED,
+ size_delta=-5000,
+ size_base=5000,
+ size_head=0,
+ percentage_delta=-100.0,
+ ),
+ RouteChange(
+ route_name="/big-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=10000,
+ size_base=20000,
+ size_head=30000,
+ percentage_delta=50.0,
+ ),
+ RouteChange(
+ route_name="/no-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=0,
+ size_base=999999,
+ size_head=999999,
+ percentage_delta=0,
+ ),
+ RouteChange(
+ route_name="/small-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=1000,
+ size_base=100000,
+ size_head=101000,
+ percentage_delta=1.0,
+ ),
+ ],
+ },
+ # Asset Comparisons
+ [
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=1000,
+ size_head=1200,
+ size_delta=200,
+ asset_name="this-is-a-warning",
+ percentage_delta=20.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.ADDED,
+ size_base=0,
+ size_head=10000,
+ size_delta=10000,
+ asset_name="this-is-added",
+ percentage_delta=100.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.REMOVED,
+ size_base=20000,
+ size_head=0,
+ size_delta=-20000,
+ asset_name="this-is-removed",
+ percentage_delta=-100.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=100000,
+ size_head=101000,
+ size_delta=1000,
+ asset_name="this-is-a-small-change",
+ percentage_delta=1.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=5000,
+ size_head=5000,
+ size_delta=0,
+ asset_name="this-is-no-change",
+ percentage_delta=0.0,
+ ),
+ [],
+ ),
+ ],
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 100 bytes (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | test-with-change | 123.46kB | 100 bytes (1.0%) :arrow_up: |
+
+ ### Affected Assets, Files, and Routes:
+
+
+ view changes for bundle: test-with-change
+
+ #### **Assets Changed:**
+ | Asset Name | Size Change | Total Size | Change (%) |
+ | ---------- | ----------- | ---------- | ---------- |
+ | ```this-is-a-warning``` | 200 bytes | 1.2kB | 20.0% :warning: |
+ | **```this-is-added```** _(New)_ | 10.0kB | 10.0kB | 100.0% :rocket: |
+ | ~~**```this-is-removed```**~~ _(Deleted)_ | -20.0kB | 0 bytes | -100.0% :wastebasket: |
+ | ```this-is-a-small-change``` | 1.0kB | 101.0kB | 1.0% |
+
+
+
+
+
+
+
+
+
+
+
+ #### App Routes Affected:
+
+ | App Route | Size Change | Total Size | Change (%) |
+ | --------- | ----------- | ---------- | ---------- |
+ | **/users** _(New)_ | 1.0kB | 1.0kB | 100% :rocket: |
+ | ~~**/faq**~~ _(Deleted)_ | -5.0kB | 0 bytes | -100.0% :wastebasket: |
+ | /big-change | 10.0kB | 30.0kB | 50.0% :warning: |
+ | /small-change | 1.0kB | 101.0kB | 1.0% |
+
+
+
+ """),
+ id="bundle_with_assets_change_and_routes",
+ ),
+ pytest.param(
+ # Bundle Changes
+ [
+ BundleChange(
+ bundle_name="test-no-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=0,
+ percentage_delta=0.0,
+ ),
+ BundleChange(
+ bundle_name="test-with-change",
+ change_type=BundleChange.ChangeType.CHANGED,
+ size_delta=100,
+ percentage_delta=1.0,
+ ),
+ ],
+ # Route Changes
+ {
+ "test-with-change": [
+ RouteChange(
+ route_name="/users",
+ change_type=RouteChange.ChangeType.ADDED,
+ size_delta=1000,
+ size_base=0,
+ size_head=1000,
+ percentage_delta=100,
+ ),
+ RouteChange(
+ route_name="/faq",
+ change_type=RouteChange.ChangeType.REMOVED,
+ size_delta=-5000,
+ size_base=5000,
+ size_head=0,
+ percentage_delta=-100.0,
+ ),
+ RouteChange(
+ route_name="/big-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=10000,
+ size_base=20000,
+ size_head=30000,
+ percentage_delta=50.0,
+ ),
+ RouteChange(
+ route_name="/no-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=0,
+ size_base=999999,
+ size_head=999999,
+ percentage_delta=0,
+ ),
+ RouteChange(
+ route_name="/small-change",
+ change_type=RouteChange.ChangeType.CHANGED,
+ size_delta=1000,
+ size_base=100000,
+ size_head=101000,
+ percentage_delta=1.0,
+ ),
+ ],
+ },
+ # Asset Comparisons
+ [
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=1000,
+ size_head=1200,
+ size_delta=200,
+ asset_name="this-is-a-warning",
+ percentage_delta=20.0,
+ ),
+ [],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.ADDED,
+ size_base=0,
+ size_head=10000,
+ size_delta=10000,
+ asset_name="this-is-added",
+ percentage_delta=100.0,
+ ),
+ [("abc/def/ghi/file1.ts", 1000)],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.REMOVED,
+ size_base=20000,
+ size_head=0,
+ size_delta=-20000,
+ asset_name="this-is-removed",
+ percentage_delta=-100.0,
+ ),
+ [("abc/def/ghi/file1.ts", 1000), ("abc/def/ghi/file2.ts", 2000)],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=100000,
+ size_head=101000,
+ size_delta=1000,
+ asset_name="this-is-a-small-change",
+ percentage_delta=1.0,
+ ),
+ [
+ ("abc/def/ghi/file1.ts", 1000),
+ ("abc/def/ghi/file2.ts", 2000),
+ ("abc/def/ghi/file3.ts", 3000),
+ ],
+ ),
+ MockAssetComparison(
+ AssetChange(
+ change_type=AssetChange.ChangeType.CHANGED,
+ size_base=5000,
+ size_head=5000,
+ size_delta=0,
+ asset_name="this-is-no-change",
+ percentage_delta=0.0,
+ ),
+ [
+ ("abc/def/ghi/file1.ts", 1000),
+ ("abc/def/ghi/file2.ts", 2000),
+ ("abc/def/ghi/file3.ts", 3000),
+ ("abc/def/ghi/file4.ts", 4000),
+ ],
+ ),
+ ],
+ dedent("""\
+ ## [Bundle](URL) Report
+
+ Changes will increase total bundle size by 100 bytes (5.56%) :arrow_up::warning:, exceeding the [configured](https://docs.codecov.com/docs/javascript-bundle-analysis#main-features) threshold of 5%.
+
+ | Bundle name | Size | Change |
+ | ----------- | ---- | ------ |
+ | test-with-change | 123.46kB | 100 bytes (1.0%) :arrow_up: |
+
+ ### Affected Assets, Files, and Routes:
+
+
+ view changes for bundle: test-with-change
+
+ #### **Assets Changed:**
+ | Asset Name | Size Change | Total Size | Change (%) |
+ | ---------- | ----------- | ---------- | ---------- |
+ | ```this-is-a-warning``` | 200 bytes | 1.2kB | 20.0% :warning: |
+ | **```this-is-added```** _(New)_ | 10.0kB | 10.0kB | 100.0% :rocket: |
+ | ~~**```this-is-removed```**~~ _(Deleted)_ | -20.0kB | 0 bytes | -100.0% :wastebasket: |
+ | ```this-is-a-small-change``` | 1.0kB | 101.0kB | 1.0% |
+
+
+
+
+ **Files in** **```this-is-added```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+
+
+ **Files in** **```this-is-removed```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+ - ```abc/def/ghi/file2.ts``` → Total Size: **2.0kB**
+
+
+
+ **Files in** **```this-is-a-small-change```**:
+
+ - ```abc/def/ghi/file1.ts``` → Total Size: **1.0kB**
+
+ - ```abc/def/ghi/file2.ts``` → Total Size: **2.0kB**
+
+ - ```abc/def/ghi/file3.ts``` → Total Size: **3.0kB**
+
+
+
+
+ #### App Routes Affected:
+
+ | App Route | Size Change | Total Size | Change (%) |
+ | --------- | ----------- | ---------- | ---------- |
+ | **/users** _(New)_ | 1.0kB | 1.0kB | 100% :rocket: |
+ | ~~**/faq**~~ _(Deleted)_ | -5.0kB | 0 bytes | -100.0% :wastebasket: |
+ | /big-change | 10.0kB | 30.0kB | 50.0% :warning: |
+ | /small-change | 1.0kB | 101.0kB | 1.0% |
+
+
+
+ """),
+ id="bundle_with_assets_change_and_module_list_and_routes",
+ ),
+ ],
+)
+@pytest.mark.django_db
+def test_bundle_analysis_notify_individual_bundle_data(
+ bundle_changes: list[BundleChange],
+ route_changes: Dict[str, List[RouteChange]],
+ asset_comparisons: List[AssetComparison],
+ expected_message: str,
+ dbsession,
+ mocker,
+ mock_storage,
+ mock_repo_provider,
+):
+ mock_all_plans_and_tiers()
+ percent_change = 5.56
+ user_config = {
+ **PATCH_CENTRIC_DEFAULT_CONFIG,
+ "bundle_analysis": {
+ "status": "informational",
+ "warning_threshold": ["percentage", 5.0],
+ },
+ }
+ hook_mock_repo_provider(mocker, mock_repo_provider)
+ base_commit = CommitFactory()
+ dbsession.add(base_commit)
+ base_commit_report = CommitReport(
+ commit=base_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(base_commit_report)
+
+ head_commit = CommitFactory(repository=base_commit.repository)
+ dbsession.add(head_commit)
+ head_commit_report = CommitReport(
+ commit=head_commit, report_type=ReportType.BUNDLE_ANALYSIS.value
+ )
+ dbsession.add(head_commit_report)
+
+ pull = PullFactory(
+ repository=base_commit.repository,
+ head=head_commit.commitid,
+ base=base_commit.commitid,
+ compared_to=base_commit.commitid,
+ )
+ dbsession.add(pull)
+ dbsession.commit()
+
+ notifier = BundleAnalysisNotifyService(head_commit, UserYaml.from_dict(user_config))
+
+ repo_key = ArchiveService.get_archive_hash(base_commit.repository)
+ mock_storage.write_file(
+ get_bucket_name(),
+ f"v1/repos/{repo_key}/{base_commit_report.external_id}/bundle_report.sqlite",
+ "test-content",
+ )
+ mock_storage.write_file(
+ get_bucket_name(),
+ f"v1/repos/{repo_key}/{head_commit_report.external_id}/bundle_report.sqlite",
+ "test-content",
+ )
+
+ mocker.patch("shared.bundle_analysis.report.BundleAnalysisReport._setup")
+
+ mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.bundle_changes",
+ return_value=bundle_changes,
+ )
+ mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.bundle_routes_changes",
+ return_value=route_changes,
+ )
+ mocker.patch(
+ "shared.bundle_analysis.comparison.BundleComparison.asset_comparisons",
+ return_value=asset_comparisons,
+ )
+ mock_percentage = mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison.percentage_delta",
+ new_callable=PropertyMock,
+ )
+ mock_percentage.return_value = percent_change
+
+ mocker.patch(
+ "shared.bundle_analysis.report.BundleAnalysisReport.bundle_report",
+ side_effect=lambda name: MockBundleReport(name),
+ )
+
+ hook_mock_pull(
+ mocker,
+ EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ ),
+ )
+
+ mock_check_compare_sha = mocker.patch(
+ "shared.bundle_analysis.comparison.BundleAnalysisComparison._check_compare_sha"
+ )
+ mock_check_compare_sha.return_value = None
+
+ expected_message = expected_message.replace(
+ "URL", get_bundle_analysis_pull_url(pull=pull)
+ )
+
+ mock_repo_provider.post_comment.return_value = {"id": "test-comment-id"}
+ notifier.notify()
+
+ mock_repo_provider.post_comment.assert_called_once_with(
+ pull.pullid, expected_message
+ )
diff --git a/apps/worker/services/cleanup/__init__.py b/apps/worker/services/cleanup/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/cleanup/cleanup.py b/apps/worker/services/cleanup/cleanup.py
new file mode 100644
index 0000000000..546303b4b7
--- /dev/null
+++ b/apps/worker/services/cleanup/cleanup.py
@@ -0,0 +1,71 @@
+import contextlib
+import logging
+
+from django.db.models.query import QuerySet
+
+from services.cleanup.models import MANUAL_CLEANUP
+from services.cleanup.relations import build_relation_graph
+from services.cleanup.utils import (
+ CleanupContext,
+ CleanupResult,
+ CleanupSummary,
+ cleanup_context,
+)
+
+log = logging.getLogger(__name__)
+
+
+def run_cleanup(
+ query: QuerySet,
+ context: CleanupContext | None = None,
+) -> CleanupSummary:
+ """
+ Cleans up all the models and storage files reachable from the given `QuerySet`.
+
+ This deletes all database models in topological sort order, and also removes
+ all the files in storage for any of the models in the relationship graph.
+
+ Returns the number of models and files being cleaned up in total, and per-Model.
+ """
+ models_to_cleanup = build_relation_graph(query)
+
+ summary = {}
+ cleaned_models = 0
+ cleaned_files = 0
+
+ cm = contextlib.nullcontext(context) if context else cleanup_context()
+ with cm as context:
+ for relation in models_to_cleanup:
+ model = relation.model
+ result = CleanupResult(0)
+
+ for query in relation.querysets:
+ # This is needed so that the correct connection is chosen for the
+ # `_raw_delete` queries, as otherwise it might chose a readonly connection.
+ query._for_write = True
+
+ manual_cleanup = MANUAL_CLEANUP.get(model)
+ if manual_cleanup is not None:
+ query_result = manual_cleanup(context, query)
+ else:
+ query_result = CleanupResult(query._raw_delete(query.db))
+
+ result.cleaned_models += query_result.cleaned_models
+ result.cleaned_files += query_result.cleaned_files
+
+ if result.cleaned_models > 0 or result.cleaned_files > 0:
+ summary[model] = result
+
+ log.info(
+ f"Finished cleaning up `{model.__name__}`",
+ extra={
+ "cleaned_models": result.cleaned_models,
+ "cleaned_files": result.cleaned_files,
+ },
+ )
+
+ cleaned_models += result.cleaned_models
+ cleaned_files += result.cleaned_files
+
+ totals = CleanupResult(cleaned_models, cleaned_files)
+ return CleanupSummary(totals, summary)
diff --git a/apps/worker/services/cleanup/models.py b/apps/worker/services/cleanup/models.py
new file mode 100644
index 0000000000..dcbd9233c6
--- /dev/null
+++ b/apps/worker/services/cleanup/models.py
@@ -0,0 +1,257 @@
+import dataclasses
+from collections import defaultdict
+from collections.abc import Callable
+from functools import partial
+
+import sentry_sdk
+from django.db.models import Model, Q, QuerySet
+from shared.bundle_analysis import StoragePaths
+from shared.django_apps.compare.models import CommitComparison
+from shared.django_apps.core.models import Commit, Pull, Repository
+from shared.django_apps.reports.models import (
+ CommitReport,
+ DailyTestRollup,
+ TestInstance,
+)
+from shared.django_apps.reports.models import ReportSession as Upload
+from shared.django_apps.staticanalysis.models import StaticAnalysisSingleFileSnapshot
+from shared.django_apps.timeseries.models import Dataset, Measurement
+from shared.storage.exceptions import FileNotInStorageError
+from shared.timeseries.helpers import is_timeseries_enabled
+from shared.utils.sessions import SessionType
+
+from services.archive import ArchiveService, MinioEndpoints
+from services.cleanup.relations import reverse_filter
+from services.cleanup.utils import CleanupContext, CleanupResult
+
+MANUAL_QUERY_CHUNKSIZE = 1_000
+DELETE_FILES_BATCHSIZE = 50
+
+
+@sentry_sdk.trace
+def cleanup_files_batched(
+ context: CleanupContext, buckets_paths: dict[str, list[str]]
+) -> int:
+ def delete_file(bucket_path: tuple[str, str]) -> bool:
+ try:
+ return context.storage.delete_file(bucket_path[0], bucket_path[1])
+ except FileNotInStorageError:
+ return False
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ return False
+
+ iter = ((bucket, path) for bucket, paths in buckets_paths.items() for path in paths)
+ results = context.threadpool.map(delete_file, iter)
+ return sum(results)
+
+
+@sentry_sdk.trace
+def cleanup_with_storage_field(
+ path_field: str,
+ context: CleanupContext,
+ query: QuerySet,
+) -> CleanupResult:
+ cleaned_files = 0
+
+ # delete `None` `path_field`s right away
+ cleaned_models = query.filter(**{f"{path_field}__isnull": True})._raw_delete(
+ query.db
+ )
+
+ # delete all those files from storage in chunks
+ storage_query = query.filter(**{f"{path_field}__isnull": False}).values_list(
+ "pk", path_field
+ )
+ while True:
+ storage_results = dict(storage_query[:MANUAL_QUERY_CHUNKSIZE])
+ if len(storage_results) == 0:
+ break
+
+ cleaned_files += cleanup_files_batched(
+ context, {context.default_bucket: list(storage_results.values())}
+ )
+ # go through `query.object` here, to avoid some duplicated subqueries
+ cleaned_models += query.model.objects.filter(
+ pk__in=storage_results.keys()
+ )._raw_delete(query.db)
+
+ return CleanupResult(cleaned_models, cleaned_files)
+
+
+def cleanup_archivefield(
+ field_name: str, context: CleanupContext, query: QuerySet
+) -> CleanupResult:
+ model_field_name = f"_{field_name}_storage_path"
+
+ return cleanup_with_storage_field(model_field_name, context, query)
+
+
+# This has all the `Repository` fields needed by `get_archive_hash`
+@dataclasses.dataclass
+class FakeRepository:
+ repoid: int
+ service: str
+ service_id: str
+
+
+@sentry_sdk.trace
+def cleanup_commitreport(context: CleanupContext, query: QuerySet) -> CleanupResult:
+ coverage_reports = query.values_list(
+ "pk",
+ "report_type",
+ "code",
+ "external_id",
+ "commit__commitid",
+ "commit__repository__repoid",
+ "commit__repository__author__service",
+ "commit__repository__service_id",
+ )
+
+ cleaned_models = 0
+ cleaned_files = 0
+ repo_hashes: dict[int, str] = {}
+
+ while True:
+ reports = list(coverage_reports[:MANUAL_QUERY_CHUNKSIZE])
+ if len(reports) == 0:
+ break
+
+ buckets_paths: dict[str, list[str]] = defaultdict(list)
+ for (
+ _pk,
+ report_type,
+ report_code,
+ external_id,
+ commit_sha,
+ repoid,
+ repo_service,
+ repo_service_id,
+ ) in reports:
+ if repoid not in repo_hashes:
+ fake_repo = FakeRepository(
+ repoid=repoid, service=repo_service, service_id=repo_service_id
+ )
+ repo_hashes[repoid] = ArchiveService.get_archive_hash(fake_repo)
+ repo_hash = repo_hashes[repoid]
+
+ # depending on the `report_type`, we have:
+ # - a `chunks` file for coverage
+ # - a `bundle_report.sqlite` for BA
+ if report_type == "bundle_analysis":
+ path = StoragePaths.bundle_report.path(
+ repo_key=repo_hash, report_key=external_id
+ )
+ buckets_paths[context.bundleanalysis_bucket].append(path)
+ elif report_type == "test_results":
+ # TA has cached rollups, but those are based on `Branch`
+ pass
+ else:
+ chunks_file_name = report_code if report_code is not None else "chunks"
+ path = MinioEndpoints.chunks.get_path(
+ version="v4",
+ repo_hash=repo_hash,
+ commitid=commit_sha,
+ chunks_file_name=chunks_file_name,
+ )
+ buckets_paths[context.default_bucket].append(path)
+
+ cleaned_files += cleanup_files_batched(context, buckets_paths)
+ cleaned_models += query.model.objects.filter(
+ pk__in=(r[0] for r in reports)
+ )._raw_delete(query.db)
+
+ return CleanupResult(cleaned_models, cleaned_files)
+
+
+@sentry_sdk.trace
+def cleanup_upload(context: CleanupContext, query: QuerySet) -> CleanupResult:
+ cleaned_files = 0
+
+ # delete `None` `storage_path`s or carryforwarded right away,
+ # as those duplicate their parents `storage_path`.
+ cleaned_models = query.filter(
+ Q(storage_path__isnull=True) | Q(upload_type=SessionType.carriedforward.value)
+ )._raw_delete(query.db)
+
+ # delete all those files from storage in chunks
+ upload_query = query.filter(storage_path__isnull=False).values_list(
+ "pk", "report__report_type", "storage_path"
+ )
+ while True:
+ uploads = list(upload_query[:MANUAL_QUERY_CHUNKSIZE])
+ if len(uploads) == 0:
+ break
+
+ buckets_paths: dict[str, list[str]] = defaultdict(list)
+ for _pk, report_type, storage_path in uploads:
+ if report_type == "bundle_analysis":
+ buckets_paths[context.bundleanalysis_bucket].append(storage_path)
+ else:
+ buckets_paths[context.default_bucket].append(storage_path)
+
+ cleaned_files += cleanup_files_batched(context, buckets_paths)
+ cleaned_models += query.model.objects.filter(
+ pk__in=(u[0] for u in uploads)
+ )._raw_delete(query.db)
+
+ return CleanupResult(cleaned_models, cleaned_files)
+
+
+def cleanup_repository(context: CleanupContext, query: QuerySet) -> CleanupResult:
+ # The equivalent of `SET NULL`:
+ Repository.objects.filter(fork__in=query).update(fork=None)
+
+ # Cleans up all the `timeseries` stuff:
+ if is_timeseries_enabled():
+ by_owner: dict[int, list[int]] = defaultdict(list)
+ all_repo_ids: list[int] = []
+ for owner_id, repo_id in query.values_list("author_id", "repoid"):
+ by_owner[owner_id].append(repo_id)
+ all_repo_ids.append(repo_id)
+
+ datasets = Dataset.objects.filter(repository_id__in=all_repo_ids)
+ datasets._for_write = True
+ datasets._raw_delete(datasets.db)
+ for owner_id, repo_ids in by_owner.items():
+ measurements = Measurement.objects.filter(
+ owner_id=owner_id,
+ repo_id__in=repo_ids,
+ )
+ measurements._for_write = True
+ measurements._raw_delete(measurements.db)
+
+ return CleanupResult(query._raw_delete(query.db))
+
+
+def unroll_subquery(context: CleanupContext, query: QuerySet) -> CleanupResult:
+ reversed_query = reverse_filter(query)
+ if not reversed_query:
+ return CleanupResult(query._raw_delete(query.db))
+ field, subquery = reversed_query
+
+ cleaned_models = 0
+ for parent in subquery:
+ cleaned_models += query.model.objects.filter(**{field: parent.pk})._raw_delete(
+ query.db
+ )
+
+ return CleanupResult(cleaned_models)
+
+
+# All the models that need custom python code for deletions so a bulk `DELETE` query does not work.
+MANUAL_CLEANUP: dict[
+ type[Model], Callable[[CleanupContext, QuerySet], CleanupResult]
+] = {
+ Commit: partial(cleanup_archivefield, "report"),
+ Pull: partial(cleanup_archivefield, "flare"),
+ CommitReport: cleanup_commitreport,
+ Upload: cleanup_upload,
+ CommitComparison: partial(cleanup_with_storage_field, "report_storage_path"),
+ StaticAnalysisSingleFileSnapshot: partial(
+ cleanup_with_storage_field, "content_location"
+ ),
+ Repository: cleanup_repository,
+ TestInstance: unroll_subquery,
+ DailyTestRollup: unroll_subquery,
+}
diff --git a/apps/worker/services/cleanup/owner.py b/apps/worker/services/cleanup/owner.py
new file mode 100644
index 0000000000..1620685e90
--- /dev/null
+++ b/apps/worker/services/cleanup/owner.py
@@ -0,0 +1,58 @@
+import logging
+
+from django.db import transaction
+from django.db.models import Q
+from shared.django_apps.codecov_auth.models import Owner, OwnerProfile
+from shared.django_apps.core.models import Commit, Pull, Repository
+
+from services.cleanup.cleanup import run_cleanup
+from services.cleanup.utils import CleanupSummary
+
+log = logging.getLogger(__name__)
+
+CLEAR_ARRAY_FIELDS = ["plan_activated_users", "organizations", "admins"]
+
+
+def cleanup_owner(owner_id: int) -> CleanupSummary:
+ log.info("Started/Continuing Owner cleanup")
+
+ clear_owner_references(owner_id)
+ owner_query = Owner.objects.filter(ownerid=owner_id)
+ summary = run_cleanup(owner_query)
+
+ log.info("Owner cleanup finished", extra={"summary": summary})
+ return summary
+
+
+# TODO: maybe turn this into a `MANUAL_CLEANUP`?
+def clear_owner_references(owner_id: int):
+ """
+ This clears the `ownerid` from various DB arrays where it is being referenced.
+ """
+
+ OwnerProfile.objects.filter(default_org=owner_id).update(default_org=None)
+ Owner.objects.filter(bot=owner_id).update(bot=None)
+ Repository.objects.filter(bot=owner_id).update(bot=None)
+ Commit.objects.filter(author=owner_id).update(author=None)
+ Pull.objects.filter(author=owner_id).update(author=None)
+
+ # This uses a transaction / `select_for_update` to ensure consistency when
+ # modifying these `ArrayField`s in python.
+ # I don’t think we have such consistency anyplace else in the codebase, so
+ # if this is causing lock contention issues, its also fair to avoid this.
+ with transaction.atomic():
+ filter = Q()
+ for field in CLEAR_ARRAY_FIELDS:
+ filter = filter | Q(**{f"{field}__contains": [owner_id]})
+
+ owners_with_reference = Owner.objects.select_for_update().filter(filter)
+ for owner in owners_with_reference:
+ updated_fields = set()
+ for field in CLEAR_ARRAY_FIELDS:
+ array = getattr(owner, field)
+ if array:
+ updated_fields.add(field)
+ setattr(owner, field, [x for x in array if x != owner_id])
+
+ if updated_fields:
+ owner.save(update_fields=updated_fields)
diff --git a/apps/worker/services/cleanup/regular.py b/apps/worker/services/cleanup/regular.py
new file mode 100644
index 0000000000..37454c4220
--- /dev/null
+++ b/apps/worker/services/cleanup/regular.py
@@ -0,0 +1,35 @@
+import logging
+import random
+
+from services.cleanup.cleanup import run_cleanup
+from services.cleanup.utils import CleanupResult, CleanupSummary, cleanup_context
+
+log = logging.getLogger(__name__)
+
+
+def run_regular_cleanup() -> CleanupSummary:
+ log.info("Starting regular cleanup job")
+ complete_summary = CleanupSummary(CleanupResult(0), summary={})
+
+ # Usage of these model was removed, and we should clean up all its data before dropping the table for good.
+ cleanups_to_run = []
+
+ # as we expect this job to have frequent retries, and cleanup to take a long time,
+ # lets shuffle the various cleanups so that each one of those makes a little progress.
+ random.shuffle(cleanups_to_run)
+
+ with cleanup_context() as context:
+ for query in cleanups_to_run:
+ name = query.model.__name__
+ log.info(f"Cleaning up `{name}`")
+ summary = run_cleanup(query, context=context)
+ log.info(f"Cleaned up `{name}`", extra={"summary": summary})
+ complete_summary.add(summary)
+
+ # TODO:
+ # - cleanup old `ReportSession`s (aka `Upload`s)
+ # - cleanup `Commit`s that are `deleted`
+ # - figure out a way how we can first mark, and then fully delete `Branch`es
+
+ log.info("Regular cleanup finished")
+ return complete_summary
diff --git a/apps/worker/services/cleanup/relations.py b/apps/worker/services/cleanup/relations.py
new file mode 100644
index 0000000000..4e03a006aa
--- /dev/null
+++ b/apps/worker/services/cleanup/relations.py
@@ -0,0 +1,219 @@
+import dataclasses
+from collections import defaultdict
+from graphlib import TopologicalSorter
+
+from django.db.models import Model
+from django.db.models.expressions import Col, Expression
+from django.db.models.fields import Field
+from django.db.models.lookups import Exact, In
+from django.db.models.query import QuerySet
+from shared.django_apps.bundle_analysis.models import CacheConfig
+from shared.django_apps.codecov_auth.models import Owner, OwnerProfile
+from shared.django_apps.core.models import Commit, Pull, Repository
+from shared.django_apps.reports.models import DailyTestRollup, TestInstance
+from shared.django_apps.user_measurements.models import UserMeasurement
+
+# Relations referencing 0 through field 1 of model 2:
+IGNORE_RELATIONS: set[tuple[type[Model], str, type[Model]]] = {
+ (Owner, "default_org", OwnerProfile),
+ (Owner, "bot", Owner),
+ (Owner, "bot", Repository),
+ (Owner, "author", Commit),
+ (Owner, "author", Pull),
+ (Repository, "forkid", Repository),
+}
+
+# Relations which have no proper foreign key:
+UNDOCUMENTED_RELATIONS: list[tuple[type[Model], str, type[Model]]] = [
+ (Repository, "repoid", TestInstance),
+ (Repository, "repoid", DailyTestRollup),
+ (Commit, "commit_id", UserMeasurement),
+ (Owner, "owner_id", UserMeasurement),
+ (Repository, "repo_id", UserMeasurement),
+ (Repository, "repo_id", CacheConfig),
+ # TODO: `UserMeasurement` also has `upload_id`, should we register that as well?
+ # TODO: should we also include `SimpleMetric` here?
+]
+
+
+@dataclasses.dataclass
+class Node:
+ edges: dict[type[Model], list[str]] = dataclasses.field(
+ default_factory=lambda: defaultdict(list)
+ )
+ querysets: list[QuerySet] = dataclasses.field(default_factory=list)
+ depth: int = 9999
+
+
+@dataclasses.dataclass
+class ModelQueries:
+ model: type[Model]
+ querysets: list[QuerySet]
+
+
+def build_relation_graph(query: QuerySet) -> list[ModelQueries]:
+ """
+ This takes as input a django `QuerySet`, like `Repository.objects.filter(repoid=123)`.
+
+ It then walks the django relation graph, resolving all the models that have a relationship **to** the input model,
+ returning those models along with a `QuerySet` that allows either querying or deleting those models.
+
+ The returned list is in topological sorting order, so related models are always sorted before models they depend on.
+ """
+ nodes: dict[type[Model], Node] = defaultdict(Node)
+ graph: TopologicalSorter[type[Model]] = TopologicalSorter()
+
+ def process_relation(
+ model: type[Model], related_model_field: str, related_model: type[Model]
+ ):
+ if (model, related_model_field, related_model) in IGNORE_RELATIONS:
+ return
+
+ graph.add(model, related_model)
+ nodes[model].edges[related_model].append(related_model_field)
+
+ if related_model not in nodes:
+ process_model(related_model)
+
+ def process_model(model: type[Model]):
+ for (
+ referenced_model,
+ related_model_field,
+ related_model,
+ ) in UNDOCUMENTED_RELATIONS:
+ if referenced_model == model:
+ process_relation(model, related_model_field, related_model)
+
+ if not (meta := model._meta):
+ return
+
+ for field in meta.get_fields(include_hidden=True):
+ if not field.is_relation:
+ continue
+
+ if field.one_to_many or field.one_to_one:
+ # Most likely the reverse of a `ForeignKey`
+ #
+
+ if not hasattr(field, "field"):
+ # I believe this is the actual *forward* definition of a `OneToOne`
+ continue
+
+ # this should be the actual `ForeignKey` definition:
+ actual_field = field.field
+ if actual_field.model == model:
+ # this field goes from *this* model to another, but we are interested in the reverse actually
+ continue
+
+ related_model = actual_field.model
+ related_model_field = actual_field.name
+ process_relation(model, related_model_field, related_model)
+
+ elif field.many_to_many:
+ if not hasattr(field, "through"):
+ # we want to delete all related records on the join table
+ continue
+
+ related_model = field.through
+ join_meta = related_model._meta
+ for field in join_meta.get_fields(include_hidden=True):
+ if not field.is_relation or field.model != model:
+ continue
+
+ related_model_field = actual_field.name
+ process_relation(model, related_model_field, related_model)
+
+ graph.add(query.model)
+ process_model(query.model)
+
+ # the topological sort yields models in the order we want to run deletions
+ sorted_models = list(graph.static_order())
+
+ # but for actually building the querysets, we prefer the order from root to leafs
+ nodes[query.model].querysets = [query]
+ nodes[query.model].depth = 0
+ for model in reversed(sorted_models):
+ node = nodes[model]
+ depth = node.depth + 1
+ in_filters = [simplified_lookup(qs) for qs in node.querysets]
+
+ for related_model, related_fields in node.edges.items():
+ related_node = nodes[related_model]
+
+ if depth < related_node.depth:
+ related_node.depth = depth
+ queries_to_build = (
+ (field, in_filter)
+ for field in related_fields
+ for in_filter in in_filters
+ )
+ related_node.querysets = [
+ related_model.objects.filter(**{f"{field}__in": in_filter})
+ for field, in_filter in queries_to_build
+ ]
+
+ return [ModelQueries(model, nodes[model].querysets) for model in sorted_models]
+
+
+def simplified_lookup(queryset: QuerySet) -> QuerySet | list[int]:
+ """
+ This potentially simplifies simple primary key lookups.
+
+ The whole point of the `build_relation_graph` is to begin with a `QuerySet`,
+ and most of the time, those are simple lookups by primary key.
+
+ When chaining those to related objects, and we can detect that this is indeed
+ a simple primary key lookup, we can eliminate one level of subqueries by
+ returning the simplified lookup value.
+
+ This is hopefully slightly faster, as the DB will still do an index scan for
+ a subquery like `foreign_pk IN (SELECT pk FROM table WHERE pk=123)`.
+ In that case, the expression will be simplified to `foreign_pk IN (123)`.
+ """
+ if queryset.query.is_sliced:
+ return queryset
+
+ where = queryset.query.where
+ if len(where.children) != 1:
+ return queryset
+
+ condition = where.children[0]
+ if not isinstance(condition, Expression) or not isinstance(condition.lhs, Col):
+ return queryset
+
+ column = condition.lhs.target
+ if column.model == queryset.model and column.primary_key:
+ if isinstance(condition, Exact) and condition.rhs_is_direct_value():
+ return [condition.rhs]
+
+ # In theory, this does not necessarily need to be a "direct value",
+ # but it can also be a subquery. But lets be conservative here.
+ if isinstance(condition, In) and condition.rhs_is_direct_value():
+ return condition.rhs
+
+ return queryset
+
+
+def reverse_filter(queryset: QuerySet) -> None | tuple[str, QuerySet]:
+ if queryset.query.is_sliced:
+ return None
+
+ where = queryset.query.where
+ if len(where.children) != 1:
+ return None
+
+ condition = where.children[0]
+ if (
+ not isinstance(condition, In)
+ or not isinstance(condition.lhs, Col)
+ or not isinstance(condition.lhs.target, Field)
+ or condition.rhs_is_direct_value()
+ ):
+ return None
+
+ column = condition.lhs.target.name
+
+ query = condition.rhs
+ model = query.model
+
+ return (column, QuerySet(model=model, query=query))
diff --git a/apps/worker/services/cleanup/repository.py b/apps/worker/services/cleanup/repository.py
new file mode 100644
index 0000000000..c73eef5323
--- /dev/null
+++ b/apps/worker/services/cleanup/repository.py
@@ -0,0 +1,102 @@
+import logging
+from uuid import uuid4
+
+from django.db import DatabaseError, IntegrityError, transaction
+from shared.django_apps.codecov_auth.models import Owner
+from shared.django_apps.core.models import Repository
+
+from services.cleanup.cleanup import run_cleanup
+from services.cleanup.utils import CleanupResult, CleanupSummary
+
+log = logging.getLogger(__name__)
+
+
+def cleanup_repo(repo_id: int) -> CleanupSummary:
+ try:
+ cleanup_started, owner_id = start_repo_cleanup(repo_id)
+ except Repository.DoesNotExist:
+ log.warning("Repository does not exist / was already cleaned up")
+ return CleanupSummary(CleanupResult(0), {})
+ except (DatabaseError, IntegrityError) as _:
+ # `DatabaseError` means that the `SELECT FOR UPDATE NOWAIT` is currently locked.
+ # `IntegrityError` means that the `Owner.create` has a duplicated `service_id`.
+ log.warning("Cleanup could not be started because of conflicting job")
+ return CleanupSummary(CleanupResult(0), {})
+
+ if cleanup_started:
+ log.info("Started Repository cleanup")
+ else:
+ log.info("Continuing Repository cleanup")
+
+ repo_query = Repository.objects.filter(repoid=repo_id)
+ summary = run_cleanup(repo_query)
+
+ # Delete the created "Shadow Owner" using a `raw_delete`, as otherwise this
+ # would run some very slow and expensive `SET NULL` / `CASCADE` queries,
+ # which are unnecessary as this shadow owner does not have any relations
+ owner_query = Owner.objects.filter(ownerid=owner_id)
+ owner_query._for_write = True # Select the correct writable DB connection
+ owner_query._raw_delete(owner_query.db)
+
+ log.info("Repository cleanup finished", extra={"summary": summary})
+ return summary
+
+
+def start_repo_cleanup(repo_id: int) -> tuple[bool, int]:
+ """
+ Starts Repository deletion by marking the repository as `deleted`, and moving
+ it to a newly created "shadow Owner".
+
+ This newly created `Owner` only has a valid `service` and `service_id`,
+ which are the only required non-NULL fields without defaults, and is otherwise
+ completely empty.
+
+ The `ownerid` of this newly created owner is being returned along with a flag
+ indicating whether the repo cleanup was just started, or whether it is already
+ marked for deletion, and this function is being retried.
+ It is expected that repo cleanup is a slow process and might be done in more steps.
+ """
+ # Runs in a transaction as we do not want to leave leftover shadow owners in
+ # case anything goes wrong here.
+ with transaction.atomic():
+ (
+ repo_deleted,
+ owner_id,
+ owner_name,
+ owner_username,
+ owner_service,
+ owner_service_id,
+ ) = (
+ Repository.objects.select_for_update(nowait=True)
+ .values_list(
+ "deleted",
+ "author__ownerid",
+ "author__name",
+ "author__username",
+ "author__service",
+ "author__service_id",
+ )
+ .get(repoid=repo_id)
+ )
+
+ if repo_deleted and not owner_name and not owner_username:
+ return (False, owner_id)
+
+ # We mark the repository as "scheduled for deletion" by setting the `deleted`
+ # flag, moving it to a new shadow owner, and clearing some tokens.
+ shadow_owner = Owner.objects.create(
+ # `Owner` is unique across service/id, and both are non-NULL,
+ # so we cannot duplicate the values just like that, so lets change up the `service_id`
+ # a bit. We need the `Repository.service_id` for further `ArchiveService` deletions.
+ service=owner_service,
+ service_id=f"☠️{owner_service_id}☠️",
+ )
+ new_token = uuid4().hex
+ Repository.objects.filter(repoid=repo_id).update(
+ deleted=True,
+ author=shadow_owner,
+ upload_token=new_token,
+ image_token=new_token,
+ )
+
+ return (True, shadow_owner.ownerid)
diff --git a/apps/worker/services/cleanup/tests/__init__.py b/apps/worker/services/cleanup/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__owner.txt b/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__owner.txt
new file mode 100644
index 0000000000..95d38267a2
--- /dev/null
+++ b/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__owner.txt
@@ -0,0 +1,463 @@
+-- UserMeasurement
+DELETE
+FROM "user_measurements"
+WHERE "user_measurements"."owner_id" IN (%s);
+
+
+-- YamlHistory
+DELETE
+FROM "yaml_history"
+WHERE "yaml_history"."ownerid" IN (%s);
+DELETE
+FROM "yaml_history"
+WHERE "yaml_history"."author" IN (%s);
+
+
+-- CommitNotification
+DELETE
+FROM "commit_notifications"
+WHERE "commit_notifications"."gh_app_id" IN
+ (SELECT U0."id"
+ FROM "codecov_auth_githubappinstallation" U0
+ WHERE U0."owner_id" IN (%s));
+
+
+-- OwnerInstallationNameToUseForTask
+DELETE
+FROM "codecov_auth_ownerinstallationnametousefortask"
+WHERE "codecov_auth_ownerinstallationnametousefortask"."owner_id" IN (%s);
+
+
+-- OrganizationLevelToken
+DELETE
+FROM "codecov_auth_organizationleveltoken"
+WHERE "codecov_auth_organizationleveltoken"."ownerid" IN (%s);
+
+
+-- OwnerProfile
+DELETE
+FROM "codecov_auth_ownerprofile"
+WHERE "codecov_auth_ownerprofile"."owner_id" IN (%s);
+
+
+-- Session
+DELETE
+FROM "sessions"
+WHERE "sessions"."ownerid" IN (%s);
+
+
+-- UserToken
+DELETE
+FROM "codecov_auth_usertoken"
+WHERE "codecov_auth_usertoken"."ownerid" IN (%s);
+
+
+-- TestInstance
+DELETE
+FROM "reports_testinstance"
+WHERE "reports_testinstance"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- DailyTestRollup
+DELETE
+FROM "reports_dailytestrollups"
+WHERE "reports_dailytestrollups"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- CacheConfig
+DELETE
+FROM "bundle_analysis_cacheconfig"
+WHERE "bundle_analysis_cacheconfig"."repo_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- RepositoryToken
+DELETE
+FROM "codecov_auth_repositorytoken"
+WHERE "codecov_auth_repositorytoken"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- Branch
+DELETE
+FROM "branches"
+WHERE "branches"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- FlagComparison
+DELETE
+FROM "compare_flagcomparison"
+WHERE "compare_flagcomparison"."repositoryflag_id" IN
+ (SELECT V0."id"
+ FROM "reports_repositoryflag" V0
+ WHERE V0."repository_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- ComponentComparison
+DELETE
+FROM "compare_componentcomparison"
+WHERE "compare_componentcomparison"."commit_comparison_id" IN
+ (SELECT W0."id"
+ FROM "compare_commitcomparison" W0
+ WHERE W0."base_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+DELETE
+FROM "compare_componentcomparison"
+WHERE "compare_componentcomparison"."commit_comparison_id" IN
+ (SELECT W0."id"
+ FROM "compare_commitcomparison" W0
+ WHERE W0."compare_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- CommitError
+DELETE
+FROM "core_commiterror"
+WHERE "core_commiterror"."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- LabelAnalysisProcessingError
+DELETE
+FROM "labelanalysis_labelanalysisprocessingerror"
+WHERE "labelanalysis_labelanalysisprocessingerror"."label_analysis_request_id" IN
+ (SELECT W0."id"
+ FROM "labelanalysis_labelanalysisrequest" W0
+ WHERE W0."base_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+DELETE
+FROM "labelanalysis_labelanalysisprocessingerror"
+WHERE "labelanalysis_labelanalysisprocessingerror"."label_analysis_request_id" IN
+ (SELECT W0."id"
+ FROM "labelanalysis_labelanalysisrequest" W0
+ WHERE W0."head_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- ReportResults
+DELETE
+FROM "reports_reportresults"
+WHERE "reports_reportresults"."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- ReportLevelTotals
+DELETE
+FROM "reports_reportleveltotals"
+WHERE "reports_reportleveltotals"."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- UploadError
+DELETE
+FROM "reports_uploaderror"
+WHERE "reports_uploaderror"."upload_id" IN
+ (SELECT X0."id"
+ FROM "reports_upload" X0
+ WHERE X0."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)))));
+
+
+-- UploadFlagMembership
+DELETE
+FROM "reports_uploadflagmembership"
+WHERE "reports_uploadflagmembership"."flag_id" IN
+ (SELECT V0."id"
+ FROM "reports_repositoryflag" V0
+ WHERE V0."repository_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- UploadLevelTotals
+DELETE
+FROM "reports_uploadleveltotals"
+WHERE "reports_uploadleveltotals"."upload_id" IN
+ (SELECT X0."id"
+ FROM "reports_upload" X0
+ WHERE X0."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)))));
+
+
+-- TestResultReportTotals
+DELETE
+FROM "reports_testresultreporttotals"
+WHERE "reports_testresultreporttotals"."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- StaticAnalysisSuiteFilepath
+DELETE
+FROM "staticanalysis_staticanalysissuitefilepath"
+WHERE "staticanalysis_staticanalysissuitefilepath"."file_snapshot_id" IN
+ (SELECT V0."id"
+ FROM "staticanalysis_staticanalysissinglefilesnapshot" V0
+ WHERE V0."repository_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- Pull
+DELETE
+FROM "pulls"
+WHERE "pulls"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- TestFlagBridge
+DELETE
+FROM "reports_test_results_flag_bridge"
+WHERE "reports_test_results_flag_bridge"."test_id" IN
+ (SELECT V0."id"
+ FROM "reports_test" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- Flake
+DELETE
+FROM "reports_flake"
+WHERE "reports_flake"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- LastCacheRollupDate
+DELETE
+FROM "reports_lastrollupdate"
+WHERE "reports_lastrollupdate"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- GithubAppInstallation
+DELETE
+FROM "codecov_auth_githubappinstallation"
+WHERE "codecov_auth_githubappinstallation"."owner_id" IN (%s);
+
+
+-- CommitComparison
+DELETE
+FROM "compare_commitcomparison"
+WHERE "compare_commitcomparison"."base_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+DELETE
+FROM "compare_commitcomparison"
+WHERE "compare_commitcomparison"."compare_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- LabelAnalysisRequest
+DELETE
+FROM "labelanalysis_labelanalysisrequest"
+WHERE "labelanalysis_labelanalysisrequest"."base_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+DELETE
+FROM "labelanalysis_labelanalysisrequest"
+WHERE "labelanalysis_labelanalysisrequest"."head_commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- ReportSession
+DELETE
+FROM "reports_upload"
+WHERE "reports_upload"."report_id" IN
+ (SELECT W0."id"
+ FROM "reports_commitreport" W0
+ WHERE W0."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s))));
+
+
+-- StaticAnalysisSuite
+DELETE
+FROM "staticanalysis_staticanalysissuite"
+WHERE "staticanalysis_staticanalysissuite"."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- StaticAnalysisSingleFileSnapshot
+DELETE
+FROM "staticanalysis_staticanalysissinglefilesnapshot"
+WHERE "staticanalysis_staticanalysissinglefilesnapshot"."repository_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- RepositoryFlag
+DELETE
+FROM "reports_repositoryflag"
+WHERE "reports_repositoryflag"."repository_id" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- Test
+DELETE
+FROM "reports_test"
+WHERE "reports_test"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- ReducedError
+DELETE
+FROM "reports_reducederror"
+WHERE "reports_reducederror"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- CommitReport
+DELETE
+FROM "reports_commitreport"
+WHERE "reports_commitreport"."commit_id" IN
+ (SELECT V0."id"
+ FROM "commits" V0
+ WHERE V0."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s)));
+
+
+-- Commit
+DELETE
+FROM "commits"
+WHERE "commits"."repoid" IN
+ (SELECT U0."repoid"
+ FROM "repos" U0
+ WHERE U0."ownerid" IN (%s));
+
+
+-- Repository
+DELETE
+FROM "repos"
+WHERE "repos"."ownerid" IN (%s);
+
+
+-- Owner
+DELETE
+FROM "owners"
+WHERE "owners"."ownerid" = %s;
diff --git a/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__repository.txt b/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__repository.txt
new file mode 100644
index 0000000000..4ee2d7b146
--- /dev/null
+++ b/apps/worker/services/cleanup/tests/snapshots/relations__builds_delete_queries__repository.txt
@@ -0,0 +1,310 @@
+-- TestInstance
+DELETE
+FROM "reports_testinstance"
+WHERE "reports_testinstance"."repoid" IN (%s);
+
+
+-- DailyTestRollup
+DELETE
+FROM "reports_dailytestrollups"
+WHERE "reports_dailytestrollups"."repoid" IN (%s);
+
+
+-- UserMeasurement
+DELETE
+FROM "user_measurements"
+WHERE "user_measurements"."repo_id" IN (%s);
+
+
+-- CacheConfig
+DELETE
+FROM "bundle_analysis_cacheconfig"
+WHERE "bundle_analysis_cacheconfig"."repo_id" IN (%s);
+
+
+-- RepositoryToken
+DELETE
+FROM "codecov_auth_repositorytoken"
+WHERE "codecov_auth_repositorytoken"."repoid" IN (%s);
+
+
+-- Branch
+DELETE
+FROM "branches"
+WHERE "branches"."repoid" IN (%s);
+
+
+-- FlagComparison
+DELETE
+FROM "compare_flagcomparison"
+WHERE "compare_flagcomparison"."repositoryflag_id" IN
+ (SELECT U0."id"
+ FROM "reports_repositoryflag" U0
+ WHERE U0."repository_id" IN (%s));
+
+
+-- ComponentComparison
+DELETE
+FROM "compare_componentcomparison"
+WHERE "compare_componentcomparison"."commit_comparison_id" IN
+ (SELECT V0."id"
+ FROM "compare_commitcomparison" V0
+ WHERE V0."base_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+DELETE
+FROM "compare_componentcomparison"
+WHERE "compare_componentcomparison"."commit_comparison_id" IN
+ (SELECT V0."id"
+ FROM "compare_commitcomparison" V0
+ WHERE V0."compare_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- CommitNotification
+DELETE
+FROM "commit_notifications"
+WHERE "commit_notifications"."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- CommitError
+DELETE
+FROM "core_commiterror"
+WHERE "core_commiterror"."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- LabelAnalysisProcessingError
+DELETE
+FROM "labelanalysis_labelanalysisprocessingerror"
+WHERE "labelanalysis_labelanalysisprocessingerror"."label_analysis_request_id" IN
+ (SELECT V0."id"
+ FROM "labelanalysis_labelanalysisrequest" V0
+ WHERE V0."base_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+DELETE
+FROM "labelanalysis_labelanalysisprocessingerror"
+WHERE "labelanalysis_labelanalysisprocessingerror"."label_analysis_request_id" IN
+ (SELECT V0."id"
+ FROM "labelanalysis_labelanalysisrequest" V0
+ WHERE V0."head_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- ReportResults
+DELETE
+FROM "reports_reportresults"
+WHERE "reports_reportresults"."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- ReportLevelTotals
+DELETE
+FROM "reports_reportleveltotals"
+WHERE "reports_reportleveltotals"."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- UploadError
+DELETE
+FROM "reports_uploaderror"
+WHERE "reports_uploaderror"."upload_id" IN
+ (SELECT W0."id"
+ FROM "reports_upload" W0
+ WHERE W0."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s))));
+
+
+-- UploadFlagMembership
+DELETE
+FROM "reports_uploadflagmembership"
+WHERE "reports_uploadflagmembership"."flag_id" IN
+ (SELECT U0."id"
+ FROM "reports_repositoryflag" U0
+ WHERE U0."repository_id" IN (%s));
+
+
+-- UploadLevelTotals
+DELETE
+FROM "reports_uploadleveltotals"
+WHERE "reports_uploadleveltotals"."upload_id" IN
+ (SELECT W0."id"
+ FROM "reports_upload" W0
+ WHERE W0."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s))));
+
+
+-- TestResultReportTotals
+DELETE
+FROM "reports_testresultreporttotals"
+WHERE "reports_testresultreporttotals"."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- StaticAnalysisSuiteFilepath
+DELETE
+FROM "staticanalysis_staticanalysissuitefilepath"
+WHERE "staticanalysis_staticanalysissuitefilepath"."file_snapshot_id" IN
+ (SELECT U0."id"
+ FROM "staticanalysis_staticanalysissinglefilesnapshot" U0
+ WHERE U0."repository_id" IN (%s));
+
+
+-- Pull
+DELETE
+FROM "pulls"
+WHERE "pulls"."repoid" IN (%s);
+
+
+-- TestFlagBridge
+DELETE
+FROM "reports_test_results_flag_bridge"
+WHERE "reports_test_results_flag_bridge"."test_id" IN
+ (SELECT U0."id"
+ FROM "reports_test" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- Flake
+DELETE
+FROM "reports_flake"
+WHERE "reports_flake"."repoid" IN (%s);
+
+
+-- LastCacheRollupDate
+DELETE
+FROM "reports_lastrollupdate"
+WHERE "reports_lastrollupdate"."repoid" IN (%s);
+
+
+-- CommitComparison
+DELETE
+FROM "compare_commitcomparison"
+WHERE "compare_commitcomparison"."base_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+DELETE
+FROM "compare_commitcomparison"
+WHERE "compare_commitcomparison"."compare_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- LabelAnalysisRequest
+DELETE
+FROM "labelanalysis_labelanalysisrequest"
+WHERE "labelanalysis_labelanalysisrequest"."base_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+DELETE
+FROM "labelanalysis_labelanalysisrequest"
+WHERE "labelanalysis_labelanalysisrequest"."head_commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- ReportSession
+DELETE
+FROM "reports_upload"
+WHERE "reports_upload"."report_id" IN
+ (SELECT V0."id"
+ FROM "reports_commitreport" V0
+ WHERE V0."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s)));
+
+
+-- StaticAnalysisSuite
+DELETE
+FROM "staticanalysis_staticanalysissuite"
+WHERE "staticanalysis_staticanalysissuite"."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- StaticAnalysisSingleFileSnapshot
+DELETE
+FROM "staticanalysis_staticanalysissinglefilesnapshot"
+WHERE "staticanalysis_staticanalysissinglefilesnapshot"."repository_id" IN (%s);
+
+
+-- RepositoryFlag
+DELETE
+FROM "reports_repositoryflag"
+WHERE "reports_repositoryflag"."repository_id" IN (%s);
+
+
+-- Test
+DELETE
+FROM "reports_test"
+WHERE "reports_test"."repoid" IN (%s);
+
+
+-- ReducedError
+DELETE
+FROM "reports_reducederror"
+WHERE "reports_reducederror"."repoid" IN (%s);
+
+
+-- CommitReport
+DELETE
+FROM "reports_commitreport"
+WHERE "reports_commitreport"."commit_id" IN
+ (SELECT U0."id"
+ FROM "commits" U0
+ WHERE U0."repoid" IN (%s));
+
+
+-- Commit
+DELETE
+FROM "commits"
+WHERE "commits"."repoid" IN (%s);
+
+
+-- Repository
+DELETE
+FROM "repos"
+WHERE "repos"."repoid" = %s;
diff --git a/apps/worker/services/cleanup/tests/snapshots/relations__leaf_table__leaf.txt b/apps/worker/services/cleanup/tests/snapshots/relations__leaf_table__leaf.txt
new file mode 100644
index 0000000000..2b323e9217
--- /dev/null
+++ b/apps/worker/services/cleanup/tests/snapshots/relations__leaf_table__leaf.txt
@@ -0,0 +1,3 @@
+-- UploadLevelTotals
+DELETE
+FROM "reports_uploadleveltotals";
diff --git a/apps/worker/services/cleanup/tests/test_regular_cleanup.py b/apps/worker/services/cleanup/tests/test_regular_cleanup.py
new file mode 100644
index 0000000000..da52c30a26
--- /dev/null
+++ b/apps/worker/services/cleanup/tests/test_regular_cleanup.py
@@ -0,0 +1,11 @@
+import pytest
+
+from services.cleanup.regular import run_regular_cleanup
+from services.cleanup.utils import CleanupResult, CleanupSummary
+
+
+@pytest.mark.django_db
+def test_runs_regular_cleanup():
+ summary = run_regular_cleanup()
+
+ assert summary == CleanupSummary(CleanupResult(0, 0), {})
diff --git a/apps/worker/services/cleanup/tests/test_relations.py b/apps/worker/services/cleanup/tests/test_relations.py
new file mode 100644
index 0000000000..865df7bff3
--- /dev/null
+++ b/apps/worker/services/cleanup/tests/test_relations.py
@@ -0,0 +1,99 @@
+import pytest
+import sqlparse
+from django.db.models.query import QuerySet
+from django.db.models.sql.subqueries import DeleteQuery
+from shared.django_apps.codecov_auth.models import Owner
+from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
+from shared.django_apps.core.models import Repository
+from shared.django_apps.core.tests.factories import RepositoryFactory
+from shared.django_apps.reports.models import TestInstance, UploadLevelTotals
+
+from services.cleanup.relations import (
+ build_relation_graph,
+ reverse_filter,
+ simplified_lookup,
+)
+
+
+def dump_delete_queries(queryset: QuerySet) -> str:
+ relations = build_relation_graph(queryset)
+
+ queries = ""
+ for relation in relations:
+ if queries:
+ queries += "\n\n"
+ queries += f"-- {relation.model.__name__}\n"
+
+ for query in relation.querysets:
+ compiler = query.query.chain(DeleteQuery).get_compiler(query.db)
+ sql, _params = compiler.as_sql()
+ sql = sqlparse.format(sql, reindent=True, keyword_case="upper")
+ queries += sql + ";\n"
+
+ return queries
+
+
+@pytest.mark.django_db
+def test_builds_delete_queries(snapshot):
+ repo = Repository.objects.filter(repoid=123)
+ org = Owner.objects.filter(ownerid=123)
+
+ # if you change any of the model relations, this snapshot will most likely change.
+ # in that case, feel free to update this using `pytest --insta update`.
+ assert dump_delete_queries(repo) == snapshot("repository.txt")
+ assert dump_delete_queries(org) == snapshot("owner.txt")
+
+
+@pytest.mark.django_db
+def test_can_simplify_queries():
+ repo = Repository.objects.filter(repoid=123)
+ assert simplified_lookup(repo) == [123]
+
+ repo = Repository.objects.filter(repoid__in=[123, 456])
+ assert simplified_lookup(repo) == [123, 456]
+
+ repo = Repository.objects.filter(fork=123)
+ assert simplified_lookup(repo) == repo
+
+ owner_repos = Repository.objects.filter(author=123)
+ repo = Repository.objects.filter(repoid__in=owner_repos)
+ # In theory, we could simplify this to forward directly to the `owner_repo`
+ # subquery, but that would open too many opportunities to properly test.
+ assert simplified_lookup(repo) == repo
+
+
+@pytest.mark.django_db
+def test_leaf_table(snapshot):
+ query = UploadLevelTotals.objects.all()
+ assert dump_delete_queries(query) == snapshot("leaf.txt")
+
+
+@pytest.mark.django_db
+def test_can_reverse_filter():
+ query = TestInstance.objects.filter(repoid=123)
+ assert reverse_filter(query) is None
+
+ query = TestInstance.objects.filter(repoid__in=[123, 234])
+ assert reverse_filter(query) is None
+
+ query = TestInstance.objects.filter(
+ repoid__in=Repository.objects.filter(author=123), branch="foo"
+ )
+ assert reverse_filter(query) is None
+
+ owner = OwnerFactory()
+ r1 = RepositoryFactory(author=owner)
+ r2 = RepositoryFactory(author=owner)
+ r3 = RepositoryFactory(author=owner)
+
+ filtered_qs = Repository.objects.filter(author=owner.ownerid)
+ query = TestInstance.objects.filter(repoid__in=filtered_qs)
+
+ column, reversed_query = reverse_filter(query)
+
+ assert column == "repoid"
+ # ideally, we would assert that the `QuerySet` itself is the same,
+ # but that is not really doable. but in essense we only care that it yields
+ # the same results, which it does:
+ assert set(reversed_query) == set(filtered_qs)
+ assert set(reversed_query) == {r1, r2, r3}
diff --git a/apps/worker/services/cleanup/utils.py b/apps/worker/services/cleanup/utils.py
new file mode 100644
index 0000000000..66192a32f9
--- /dev/null
+++ b/apps/worker/services/cleanup/utils.py
@@ -0,0 +1,58 @@
+import dataclasses
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+
+import shared.storage
+from django.db.models import Model
+from shared.config import get_config
+from shared.storage.base import BaseStorageService
+
+
+class CleanupContext:
+ threadpool: ThreadPoolExecutor
+ storage: BaseStorageService
+ default_bucket: str
+ bundleanalysis_bucket: str
+
+ def __init__(self):
+ self.threadpool = ThreadPoolExecutor()
+ self.storage = shared.storage.get_appropriate_storage_service()
+ self.default_bucket = get_config(
+ "services", "minio", "bucket", default="archive"
+ )
+ self.bundleanalysis_bucket = get_config(
+ "bundle_analysis", "bucket_name", default="bundle-analysis"
+ )
+
+
+@contextmanager
+def cleanup_context():
+ context = CleanupContext()
+ try:
+ yield context
+ finally:
+ context.threadpool.shutdown()
+
+
+@dataclasses.dataclass
+class CleanupResult:
+ cleaned_models: int
+ cleaned_files: int = 0
+
+
+@dataclasses.dataclass
+class CleanupSummary:
+ totals: CleanupResult
+ summary: dict[type[Model], CleanupResult]
+
+ def add(self, other: "CleanupSummary"):
+ self.totals.cleaned_models += other.totals.cleaned_models
+ self.totals.cleaned_files += other.totals.cleaned_files
+
+ for model, other_result in other.summary.items():
+ if model not in self.summary:
+ self.summary[model] = CleanupResult(0)
+ result = self.summary[model]
+
+ result.cleaned_models += other_result.cleaned_models
+ result.cleaned_files += other_result.cleaned_files
diff --git a/apps/worker/services/commit_status.py b/apps/worker/services/commit_status.py
new file mode 100644
index 0000000000..003ce10cb9
--- /dev/null
+++ b/apps/worker/services/commit_status.py
@@ -0,0 +1,81 @@
+from typing import List
+
+from shared.config import get_config
+
+from services.yaml import read_yaml_field
+
+
+def _ci_providers() -> List[str]:
+ providers = get_config("services", "ci_providers")
+ if not providers:
+ return []
+ elif isinstance(providers, list):
+ return providers
+ else:
+ return map(lambda p: p.strip(), providers.split(","))
+
+
+ENTERPRISE_DEFAULTS = set(filter(None, _ci_providers()))
+
+CI_CONTEXTS = set(
+ (
+ "ci",
+ "Codefresh",
+ "wercker",
+ "semaphoreci",
+ "pull request validator (cloudbees)",
+ "Taskcluster (pull_request)",
+ "continuous-integration",
+ "buildkite",
+ )
+)
+
+CI_DOMAINS = set(
+ ("jenkins", "codefresh", "bitbucket", "teamcity", "buildkite", "taskcluster")
+)
+
+CI_EXCLUDE = set(("styleci",))
+
+
+class RepositoryCIFilter(object):
+ def __init__(self, commit_yaml) -> None:
+ ci = read_yaml_field(commit_yaml, ("codecov", "ci")) or []
+ ci = set(ci) | ENTERPRISE_DEFAULTS
+ self.exclude = (
+ set(map(lambda a: a[1:], filter(lambda ci: ci[0] == "!", ci)) if ci else [])
+ | CI_EXCLUDE
+ )
+ self.include = (
+ set(filter(lambda ci: ci[0] != "!", ci) if ci else []) | CI_DOMAINS
+ )
+
+ def __call__(self, status) -> bool:
+ return self._filter(status)
+
+ def _filter(self, status) -> bool:
+ domain = ((status["url"] or "").split("/") + ["", "", ""])[2]
+ if domain:
+ # ignore.com in ('ignore.com',) || skip.domain.com in ('skip',)
+ if domain in self.exclude or set(domain.split(".")) & self.exclude:
+ return False
+
+ elif domain in self.include or set(domain.split(".")) & self.include:
+ return True
+
+ if status["context"]:
+ contexts = set(status["context"].split("/"))
+ if status["context"] in self.exclude or contexts & self.exclude:
+ return False
+
+ elif status["context"] in self.include or contexts & (
+ self.include | CI_CONTEXTS
+ ):
+ return True
+
+ elif (
+ "jenkins" in status["context"].lower() and "jenkins" not in self.exclude
+ ):
+ # url="", context="Jenkins2 - Build"
+ return True
+
+ return False
diff --git a/apps/worker/services/comparison/__init__.py b/apps/worker/services/comparison/__init__.py
new file mode 100644
index 0000000000..d7ece4dbd9
--- /dev/null
+++ b/apps/worker/services/comparison/__init__.py
@@ -0,0 +1,415 @@
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.reports.changes import run_comparison_using_rust
+from shared.reports.types import Change, ReportTotals
+from shared.torngit.base import TorngitBaseAdapter
+from shared.torngit.exceptions import TorngitClientGeneralError
+from shared.utils.sessions import SessionType
+
+from database.enums import CompareCommitState
+from database.models import CompareCommit
+from services.archive import ArchiveService
+from services.comparison.changes import get_changes
+from services.comparison.types import Comparison, FullCommit, ReportUploadedCount
+from services.repository import get_repo_provider_service
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class ComparisonContext(object):
+ """Extra information not necessarily related to coverage that may affect notifications"""
+
+ repository_service: TorngitBaseAdapter | None = None
+ all_tests_passed: bool | None = None
+ test_results_error: str | None = None
+ gh_app_installation_name: str | None = None
+ gh_is_using_codecov_commenter: bool = False
+ # GitLab has a "merge results pipeline" (see https://docs.gitlab.com/ee/ci/pipelines/merged_results_pipelines.html)
+ # This runs on an "internal" commit that is the merge from the PR HEAD and the target branch. This commit only exists in GitLab.
+ # We need to send commit statuses to this other commit, to guarantee that the check is not ignored.
+ # See https://docs.gitlab.com/ee/ci/pipelines/merged_results_pipelines.html#successful-merged-results-pipeline-overrides-a-failed-branch-pipeline
+ gitlab_extra_shas: set[str] | None = None
+
+
+NOT_RESOLVED: Any = object()
+
+
+class ComparisonProxy(object):
+ """The idea of this class is to produce a wrapper around Comparison with functionalities that
+ are useful to the notifications context.
+
+ What ComparisonProxy aims to do is to provide a bunch of common calculations (like
+ get_changes and get_diff) with a specific set of assumptions very fit for the
+ notification use-cases (like the one that we should use the head commit repository
+ to fetch data, or that there is even a repository we can fetch data from,
+ and that whatever information is fetched at first would not change).
+
+ This is not really meant for other places where a comparison might be used (
+ like when one really only needs the actual in-database and in-report information).
+ A pure Comparison should be used for those cases
+
+ Attributes:
+ comparison (Comparison): The original comparison we want to wrap and proxy
+ context (ComparisonContext | None): Other information not coverage-related that may affect notifications
+ """
+
+ def __init__(
+ self, comparison: Comparison, context: ComparisonContext | None = None
+ ):
+ self.comparison = comparison
+ self._repository_service = None
+ self._adjusted_base_diff = NOT_RESOLVED
+ self._original_base_diff = NOT_RESOLVED
+ self._patch_totals = NOT_RESOLVED
+ self._changes = NOT_RESOLVED
+ self._existing_statuses = None
+ self._behind_by = None
+ self._branch = None
+ self._archive_service = None
+ self.context = context or ComparisonContext()
+ self._cached_reports_uploaded_per_flag: list[ReportUploadedCount] | None = None
+
+ def get_archive_service(self):
+ if self._archive_service is None:
+ self._archive_service = ArchiveService(
+ self.comparison.project_coverage_base.commit.repository
+ )
+ return self._archive_service
+
+ def get_filtered_comparison(self, flags, path_patterns):
+ if not flags and not path_patterns:
+ return self
+ return FilteredComparison(self, flags=flags, path_patterns=path_patterns)
+
+ @property
+ def repository_service(self):
+ if self._repository_service is None:
+ if self.context.repository_service is not None:
+ self._repository_service = self.context.repository_service
+ else:
+ self._repository_service = get_repo_provider_service(
+ self.comparison.head.commit.repository,
+ installation_name_to_use=self.context.gh_app_installation_name,
+ )
+ return self._repository_service
+
+ def has_project_coverage_base_report(self):
+ return self.comparison.has_project_coverage_base_report()
+
+ def has_head_report(self):
+ return self.comparison.has_head_report()
+
+ @property
+ def head(self):
+ return self.comparison.head
+
+ @property
+ def project_coverage_base(self):
+ return self.comparison.project_coverage_base
+
+ @property
+ def enriched_pull(self):
+ return self.comparison.enriched_pull
+
+ @property
+ def pull(self):
+ return self.comparison.pull
+
+ def get_diff(self, use_original_base=False) -> dict | None:
+ head = self.comparison.head.commit
+ base = self.comparison.project_coverage_base.commit
+ patch_coverage_base_commitid = self.comparison.patch_coverage_base_commitid
+
+ # If the original and adjusted bases are the same commit, then if we
+ # already fetched the diff for one we can return it for the other.
+ bases_match = patch_coverage_base_commitid == (base.commitid if base else "")
+
+ populate_original_base_diff = use_original_base and (
+ self._original_base_diff is NOT_RESOLVED
+ )
+ populate_adjusted_base_diff = (not use_original_base) and (
+ self._adjusted_base_diff is NOT_RESOLVED
+ )
+ if populate_original_base_diff:
+ if bases_match and self._adjusted_base_diff is not NOT_RESOLVED:
+ self._original_base_diff = self._adjusted_base_diff
+ elif patch_coverage_base_commitid is not None:
+ pull_diff = async_to_sync(self.repository_service.get_compare)(
+ patch_coverage_base_commitid, head.commitid, with_commits=False
+ )
+ self._original_base_diff = pull_diff["diff"]
+ else:
+ return None
+ elif populate_adjusted_base_diff:
+ if bases_match and self._original_base_diff is not NOT_RESOLVED:
+ self._adjusted_base_diff = self._original_base_diff
+ elif base is not None:
+ pull_diff = async_to_sync(self.repository_service.get_compare)(
+ base.commitid, head.commitid, with_commits=False
+ )
+ self._adjusted_base_diff = pull_diff["diff"]
+ else:
+ return None
+
+ if use_original_base:
+ return self._original_base_diff
+ else:
+ return self._adjusted_base_diff
+
+ def get_changes(self) -> list[Change] | None:
+ if self._changes is NOT_RESOLVED:
+ diff = self.get_diff()
+ self._changes = get_changes(
+ self.comparison.project_coverage_base.report,
+ self.comparison.head.report,
+ diff,
+ )
+
+ return self._changes
+
+ @sentry_sdk.trace
+ def get_patch_totals(self) -> ReportTotals | None:
+ """Returns the patch coverage for the comparison.
+
+ Patch coverage refers to looking at the coverage in HEAD report filtered by the git diff HEAD..BASE.
+ """
+ if self._patch_totals is NOT_RESOLVED:
+ diff = self.get_diff(use_original_base=True)
+ self._patch_totals = self.head.report.apply_diff(diff)
+
+ return self._patch_totals
+
+ def get_behind_by(self):
+ if self._behind_by is None:
+ if not getattr(
+ self.comparison.project_coverage_base.commit, "commitid", None
+ ):
+ log.info(
+ "Comparison base commit does not have commitid, unable to get behind_by"
+ )
+ return None
+
+ provider_pull = self.comparison.enriched_pull.provider_pull
+ if provider_pull is None:
+ log.info(
+ "Comparison does not have provider pull request information, unable to get behind_by"
+ )
+ return None
+ branch_to_get = provider_pull["base"]["branch"]
+ if self._branch is None:
+ try:
+ branch_response = async_to_sync(self.repository_service.get_branch)(
+ branch_to_get
+ )
+ except TorngitClientGeneralError:
+ log.warning(
+ "Unable to fetch base branch from Git provider",
+ extra=dict(
+ branch=branch_to_get,
+ ),
+ )
+ return None
+ except KeyError:
+ log.warning(
+ "Error fetching base branch from Git provider",
+ extra=dict(
+ branch=branch_to_get,
+ ),
+ )
+ return None
+ self._branch = branch_response
+
+ distance = async_to_sync(self.repository_service.get_distance_in_commits)(
+ self._branch["sha"],
+ self.comparison.project_coverage_base.commit.commitid,
+ with_commits=False,
+ )
+ self._behind_by = distance["behind_by"]
+ self.enriched_pull.database_pull.behind_by = distance["behind_by"]
+ self.enriched_pull.database_pull.behind_by_commit = distance[
+ "behind_by_commit"
+ ]
+ return self._behind_by
+
+ def all_tests_passed(self) -> bool:
+ if self.context:
+ return self.context.all_tests_passed or False
+
+ return False
+
+ def test_results_error(self) -> str | None:
+ if self.context:
+ return self.context.test_results_error
+
+ return None
+
+ def get_existing_statuses(self):
+ if self._existing_statuses is None:
+ self._existing_statuses = async_to_sync(
+ self.repository_service.get_commit_statuses
+ )(self.head.commit.commitid)
+ return self._existing_statuses
+
+ @sentry_sdk.trace
+ def get_impacted_files(self) -> dict:
+ files_in_diff = self.get_diff()
+ return run_comparison_using_rust(
+ self.comparison.project_coverage_base.report,
+ self.comparison.head.report,
+ files_in_diff,
+ )
+
+ def get_reports_uploaded_count_per_flag(self) -> list[ReportUploadedCount]:
+ """This function counts how many reports (by flag) the BASE and HEAD commit have."""
+ if self._cached_reports_uploaded_per_flag:
+ # Reports may have many sessions, so it's useful to memoize this function
+ return self._cached_reports_uploaded_per_flag
+ if not self.has_head_report() or not self.has_project_coverage_base_report():
+ log.warning(
+ "Can't calculate diff in uploads. Missing some report",
+ extra=dict(
+ has_head_report=self.has_head_report(),
+ has_project_base_report=self.has_project_coverage_base_report(),
+ ),
+ )
+ return []
+ per_flag_dict: dict[str, ReportUploadedCount] = dict()
+ base_report = self.comparison.project_coverage_base.report
+ head_report = self.comparison.head.report
+ ops = [(base_report, "base_count"), (head_report, "head_count")]
+ for curr_report, curr_counter in ops:
+ for session in curr_report.sessions.values():
+ # We ignore carryforward sessions
+ # Because not all commits would upload all flags (potentially)
+ # But they are still carried forward
+ if session.session_type != SessionType.carriedforward:
+ # It's possible that an upload is done without flags.
+ # In this case we still want to count it in the count, but there's no flag associated to it
+ session_flags = session.flags or [""]
+ for flag in session_flags:
+ dict_value = per_flag_dict.get(flag)
+ if dict_value is None:
+ dict_value = ReportUploadedCount(
+ flag=flag, base_count=0, head_count=0
+ )
+ dict_value[curr_counter] += 1
+ per_flag_dict[flag] = dict_value
+ self._cached_reports_uploaded_per_flag = list(per_flag_dict.values())
+ return self._cached_reports_uploaded_per_flag
+
+ def get_reports_uploaded_count_per_flag_diff(self) -> list[ReportUploadedCount]:
+ """
+ Returns the difference, per flag, or reports uploaded in BASE and HEAD
+
+ ❗️ For a difference to be considered there must be at least 1 "uploaded" upload in both
+ BASE and HEAD (that is, if all reports for a flag are "carryforward" it's not considered a diff)
+ """
+ reports_per_flag = self.get_reports_uploaded_count_per_flag()
+
+ def is_valid_diff(obj: ReportUploadedCount):
+ if self.comparison.current_yaml:
+ flag_config = self.comparison.current_yaml.get_flag_configuration(
+ obj["flag"]
+ )
+ is_flag_carryforward = (
+ flag_config.get("carryforward", False)
+ if flag_config is not None
+ else False
+ )
+ else:
+ is_flag_carryforward = False
+ head_and_base_have_uploads = obj["base_count"] > 0 and obj["head_count"] > 0
+ head_or_base_have_uploads = obj["base_count"] > 0 or obj["head_count"] > 0
+ return (
+ (is_flag_carryforward and head_and_base_have_uploads)
+ or (not is_flag_carryforward and head_or_base_have_uploads)
+ ) and obj["base_count"] > obj["head_count"]
+
+ per_flag_diff = list(filter(is_valid_diff, reports_per_flag))
+ self._cached_reports_uploaded_per_flag = per_flag_diff
+ return per_flag_diff
+
+
+class FilteredComparison(object):
+ def __init__(self, real_comparison: ComparisonProxy, *, flags, path_patterns):
+ self.flags = flags
+ self.path_patterns = path_patterns
+ self.real_comparison = real_comparison
+ self._patch_totals = None
+ self._changes = None
+ self.project_coverage_base = FullCommit(
+ commit=real_comparison.project_coverage_base.commit,
+ report=(
+ real_comparison.project_coverage_base.report.filter(
+ flags=flags, paths=path_patterns
+ )
+ if self.has_project_coverage_base_report()
+ else None
+ ),
+ )
+ self.head = FullCommit(
+ commit=real_comparison.head.commit,
+ report=real_comparison.head.report.filter(flags=flags, paths=path_patterns),
+ )
+
+ def get_impacted_files(self) -> dict:
+ return self.real_comparison.get_impacted_files()
+
+ def get_diff(self, use_original_base=False):
+ return self.real_comparison.get_diff(use_original_base=use_original_base)
+
+ @sentry_sdk.trace
+ def get_patch_totals(self) -> ReportTotals | None:
+ """Returns the patch coverage for the comparison.
+
+ Patch coverage refers to looking at the coverage in HEAD report filtered by the git diff HEAD..BASE.
+ """
+ if self._patch_totals:
+ return self._patch_totals
+ diff = self.get_diff(use_original_base=True)
+ self._patch_totals = self.head.report.apply_diff(diff)
+ return self._patch_totals
+
+ def get_existing_statuses(self):
+ return self.real_comparison.get_existing_statuses()
+
+ def has_project_coverage_base_report(self):
+ return self.real_comparison.has_project_coverage_base_report()
+
+ @property
+ def enriched_pull(self):
+ return self.real_comparison.enriched_pull
+
+ def get_changes(self) -> list[Change] | None:
+ if self._changes is None:
+ diff = self.get_diff()
+ self._changes = get_changes(
+ self.project_coverage_base.report, self.head.report, diff
+ )
+ return self._changes
+
+ @property
+ def pull(self):
+ return self.real_comparison.pull
+
+
+def get_or_create_comparison(db_session, base_commit, compare_commit):
+ comparison = (
+ db_session.query(CompareCommit)
+ .filter_by(base_commit=base_commit, compare_commit=compare_commit)
+ .one_or_none()
+ )
+ if comparison is None:
+ comparison = CompareCommit(
+ base_commit=base_commit,
+ compare_commit=compare_commit,
+ state=CompareCommitState.pending.value,
+ )
+ db_session.add(comparison)
+ db_session.flush()
+ return comparison
diff --git a/apps/worker/services/comparison/changes.py b/apps/worker/services/comparison/changes.py
new file mode 100644
index 0000000000..c1232d2d6c
--- /dev/null
+++ b/apps/worker/services/comparison/changes.py
@@ -0,0 +1,308 @@
+import dataclasses
+import logging
+from collections import defaultdict
+from typing import Any, Iterator, Tuple, Union
+
+import sentry_sdk
+from shared.helpers.numeric import ratio
+from shared.reports.resources import Report
+from shared.reports.types import Change, ReportTotals
+from shared.utils.merge import line_type
+
+log = logging.getLogger(__name__)
+
+
+def diff_totals(base, head, absolute=None) -> Union[bool, None, ReportTotals]:
+ if head is None:
+ return False # file deleted
+
+ elif base is None:
+ return True # new file
+
+ elif base == head:
+ return None # same same
+
+ head_tuple = dataclasses.astuple(head)
+ base_tuple = dataclasses.astuple(base)
+
+ diff_tuple = [
+ (int(float(head_tuple[i] or 0)) - int(float(base_tuple[i] or 0)))
+ for i in (0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11)
+ ]
+ diff = ReportTotals(*diff_tuple)
+ if absolute and absolute.coverage is not None:
+ # ratio(before.hits + changed.hits, before.lines, changed.lines) - coverage before
+ # = actual coveage change
+ hits = absolute.hits + diff.hits
+ diff.coverage = float(
+ ratio(
+ hits,
+ (
+ hits
+ + absolute.misses
+ + diff.misses
+ + absolute.partials
+ + diff.partials
+ ),
+ )
+ ) - float(absolute.coverage)
+ else:
+ diff.coverage = float(head.coverage) - float(base.coverage)
+ return ReportTotals(*diff)
+
+
+def get_segment_offsets(segments) -> tuple[dict[int, Any], list[int], list[int]]:
+ offsets: dict[int, int] = defaultdict(int)
+ additions = []
+ removals = []
+ # loop through the segments
+ for seg in segments:
+ # get the starting line number
+ start = int(seg["header"][2]) or 1
+ offset_l = 0 # used to offset the segment line number (not real line numbers)
+ offset_r = 0 # used to offset the segment line number (not real line numbers)
+ base_start = (
+ int(seg["header"][0] if seg["header"][0] and seg["header"][0] != " " else 1)
+ or 1
+ )
+ starting_diff = start - base_start
+ # loop through all the lines
+ for ln, line in enumerate(seg["lines"], start=start):
+ l0 = line[0]
+ if l0 == "-":
+ removals.append(ln + offset_l - starting_diff)
+ offsets[ln + offset_r] += 1
+ offset_r -= 1
+
+ elif l0 == "+":
+ additions.append(ln + offset_r)
+ offsets[ln + offset_r] -= 1
+ offset_l -= 1
+ return dict([(k, v) for k, v in offsets.items() if v != 0]), additions, removals
+
+
+@sentry_sdk.trace
+def get_changes(
+ base_report: Report, head_report: Report, diff_json: dict[str, Any] | None
+) -> list[Change] | None:
+ """
+
+ Please bear with me because I didnt write the function, so what I know is from using it
+ and trying to unit testing it.
+
+ What this function does is calculate the "unexpected" changes on coverage between two reports.
+ Unexpected changes are changes that do NOT arise from the diff.
+ That means, for example, that:
+ - If you delete the file between BASE and HEAD, it is expected that the coverage from
+ that file will vanish on HEAD, so it does not show up on changes list
+ - Added files are also ignored on the changes list.
+ - The coverage changes that happen inside the git diff are also expected, so
+ they dont show up here
+ - Files that are not in the diff will show up here
+ - Files that are in the diff, but had the change happen outside the diff will show up
+ here
+ - Renaming the file will also be properly handled here such that, if a file is renamed,
+ we compare the `original_name` ReportFile in the base report and
+ the `new_name` ReportFile in the head report
+
+ For a better understanding of it, see the unit tests covering this function
+
+ Args:
+ base_report (Report): The report for the base commit
+ head_report (Report): The report for the head commit
+ diff_json (Mapping[str, Any]): The diff between the base and head commit as returned by torngit
+
+ Returns:
+ List[Change]: A list of unexpected changes between base_report and head_report
+ """
+ if base_report is None or head_report is None:
+ return None
+
+ changes = []
+ base_files = set(base_report.files)
+ head_files = set(head_report.files)
+ diff_keys = set(diff_json["files"].keys()) if diff_json else set()
+
+ # moved files
+ moved_files = (
+ set([d["before"] for k, d in diff_json["files"].items() if d.get("before")])
+ if diff_json
+ else set()
+ )
+ # deleted files
+ missing_files = base_files - head_files - diff_keys - moved_files
+ # added files
+ new_files = head_files - base_files - diff_keys - moved_files
+
+ # find modified !diff files
+ for _file in head_report:
+ filename = _file.name
+ # skip [new] + [missing]
+ if filename in missing_files or filename in new_files:
+ continue
+
+ diff = diff_json["files"].get(filename) if diff_json is not None else None
+ base_report_file = base_report.get(
+ (diff.get("before") or filename) if diff else filename
+ )
+ if not base_report_file:
+ if diff is None:
+ # Seems to only happen when there is a 'moved file' in a weird situation
+ log.info(
+ "File not in the diff, not in base, but still not a 'new_file'",
+ extra=dict(
+ diff_keys=sorted(diff_keys),
+ missing_filename=filename,
+ base_is_none=base_report_file is None,
+ moved_files=sorted(moved_files),
+ ),
+ )
+ new_files.add(filename)
+ continue
+ if diff.get("type") == "new":
+ # File is lacking at base, present at head
+ # Diff says it's because it's new
+ # This is expected
+ continue
+ r = get_segment_offsets(diff["segments"])
+ additions: set[int] = set(r[1])
+ if any(ln not in additions for ln, _ in _file.lines):
+ # file has new coverage lines that are not accounted by the diff
+ new_files.add(filename)
+ continue
+
+ lines = list(
+ iter_changed_lines(
+ base_report_file=base_report_file,
+ head_report_file=_file,
+ diff=diff,
+ yield_line_numbers=False,
+ )
+ )
+
+ if any(lines):
+ # only if there are any lines that changed
+ lines = zip(*lines)
+ changes.append(
+ Change(
+ path=filename,
+ in_diff=bool(diff),
+ old_path=diff.get("before") if diff else None,
+ totals=diff_totals(
+ get_totals_from_list(next(lines)),
+ get_totals_from_list(next(lines)),
+ base_report_file.totals,
+ ),
+ )
+ )
+ if diff_json:
+ vanished_base_files = {
+ d.get("before") or k: (k, d)
+ for (k, d) in diff_json["files"].items()
+ if head_report.get(k) is None
+ and base_report.get(d.get("before") or k) is not None
+ }
+ for possibly_deleted_filename, data in vanished_base_files.items():
+ head_name, diff = data
+ # these are files that are present on base, not present on head
+ # and are possibly accounted by the diff
+ # But to know that for sure we need to know that every line lost is accounted
+ # by the diff
+ if diff.get("type") != "deleted":
+ base_report_file = base_report.get(possibly_deleted_filename)
+ present_lines_on_base = set(x[0] for x in base_report_file.lines)
+ _, _, line_removals = get_segment_offsets(diff["segments"])
+ lines_unnaccounted_for = present_lines_on_base - set(line_removals)
+ if lines_unnaccounted_for:
+ changes.append(Change(path=head_name, deleted=True))
+
+ # [deleted] [~~diff~~] == missing reports
+ # left over deleted files
+ # this one is "bad" because coverage reports are missing entirely.
+ if missing_files:
+ changes.extend([Change(path=path, deleted=True) for path in missing_files])
+
+ # [new] [~~diff~~] == new reports
+ if new_files:
+ changes.extend([Change(path=path, new=True) for path in new_files])
+
+ return changes
+
+
+def get_totals_from_list(lst) -> ReportTotals:
+ """
+ takes list of coverage values and returns a
+ on the list
+ IN [1,0,"1/2"] => OUT ReportTotals(hits=1, misses=1, partials=1)
+ """
+ lst = list(map(line_type, lst))
+ return ReportTotals(hits=lst.count(0), misses=lst.count(1), partials=lst.count(2))
+
+
+def iter_changed_lines(
+ base_report_file, head_report_file, diff=None, yield_line_numbers=True
+) -> Iterator[Union[int, Tuple[Any, Any]]]:
+ """
+ streams line numbers that changed as integers > 0
+ """
+ if not diff or diff["type"] == "modified":
+ offsets, skip_lines, removed_lines = (
+ get_segment_offsets(diff["segments"]) if diff else (None, None, None)
+ )
+ base_ln = 0
+ base_report_file_eof = (
+ base_report_file.eof if base_report_file is not None else 0
+ )
+ for ln in range(
+ 1,
+ max(
+ (
+ base_report_file_eof,
+ base_report_file_eof
+ + len(skip_lines or [])
+ - len(removed_lines or []),
+ head_report_file.eof,
+ )
+ )
+ + 1,
+ ):
+ if offsets:
+ base_ln += 1
+ _offset = offsets.get(ln)
+ if _offset is not None:
+ base_ln += _offset
+
+ if not skip_lines or ln not in skip_lines:
+ base_line = (
+ base_report_file.get(base_ln or ln)
+ if base_report_file is not None
+ else None
+ )
+ head_line = head_report_file.get(ln)
+ # if a base line exist we can compare against
+ if base_line:
+ if head_line:
+ # we have a head line
+ if line_has_changed(base_line, head_line):
+ # unexpected: coverage data changed
+ yield (
+ ln
+ if yield_line_numbers
+ else (
+ base_line.coverage,
+ head_line.coverage,
+ )
+ )
+ # coverage data remains the same
+ else:
+ # unexpected: coverage data disappeared
+ yield ln if yield_line_numbers else (base_line.coverage, None)
+
+ elif head_line:
+ # unexpected: new coverage data
+ yield ln if yield_line_numbers else (None, head_line.coverage)
+
+
+def line_has_changed(before, after) -> bool:
+ # coverage changed
+ return line_type(before.coverage) != line_type(after.coverage)
diff --git a/apps/worker/services/comparison/conftest.py b/apps/worker/services/comparison/conftest.py
new file mode 100644
index 0000000000..c3d8d53e89
--- /dev/null
+++ b/apps/worker/services/comparison/conftest.py
@@ -0,0 +1,107 @@
+# TODO: Clean this
+
+import pytest
+from shared.reports.readonly import ReadOnlyReport
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from database.tests.factories import CommitFactory, PullFactory, RepositoryFactory
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison, FullCommit
+from services.repository import EnrichedPull
+
+
+def get_small_report(flags=None):
+ if flags is None:
+ flags = ["integration"]
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(11, 20))
+ )
+ first_file.append(3, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(51, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=flags))
+ return report
+
+
+@pytest.fixture
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["unit"]))
+ return report
+
+
+@pytest.fixture
+def sample_comparison(dbsession, request, sample_report):
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name, owner__service="github"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
diff --git a/apps/worker/services/comparison/tests/unit/__init__.py b/apps/worker/services/comparison/tests/unit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/comparison/tests/unit/test_behind_by.py b/apps/worker/services/comparison/tests/unit/test_behind_by.py
new file mode 100644
index 0000000000..d910da388e
--- /dev/null
+++ b/apps/worker/services/comparison/tests/unit/test_behind_by.py
@@ -0,0 +1,46 @@
+from shared.torngit.exceptions import TorngitClientGeneralError
+
+from services.comparison import ComparisonProxy
+
+
+class TestGetBehindBy(object):
+ def test_get_behind_by(self, mocker, mock_repo_provider):
+ comparison = ComparisonProxy(mocker.MagicMock())
+ comparison.comparison.enriched_pull.provider_pull = {"base": {"branch": "a"}}
+ mock_repo_provider.get_branches.return_value = [("a", "1")]
+ mock_repo_provider.get_distance_in_commits.return_value = {
+ "behind_by": 3,
+ "behind_by_commit": 123456,
+ }
+ mocker.patch(
+ "services.comparison.get_repo_provider_service",
+ return_value=mock_repo_provider,
+ )
+ res = comparison.get_behind_by()
+ assert res == 3
+
+ def test_get_behind_by_no_base_commit(self, mocker):
+ comparison = ComparisonProxy(mocker.MagicMock())
+ del comparison.comparison.project_coverage_base.commit.commitid
+ res = comparison.get_behind_by()
+ assert res is None
+
+ def test_get_behind_by_no_provider_pull(self, mocker):
+ comparison = ComparisonProxy(mocker.MagicMock())
+ comparison.comparison.enriched_pull.provider_pull = None
+ res = comparison.get_behind_by()
+ assert res is None
+
+ def test_get_behind_by_no_matching_branches(self, mocker, mock_repo_provider):
+ mock_repo_provider.get_branch.side_effect = TorngitClientGeneralError(
+ 404,
+ None,
+ "Branch not found",
+ )
+ mocker.patch(
+ "services.comparison.get_repo_provider_service",
+ return_value=mock_repo_provider,
+ )
+ comparison = ComparisonProxy(mocker.MagicMock())
+ res = comparison.get_behind_by()
+ assert res is None
diff --git a/apps/worker/services/comparison/tests/unit/test_changes.py b/apps/worker/services/comparison/tests/unit/test_changes.py
new file mode 100644
index 0000000000..d7db7fefa1
--- /dev/null
+++ b/apps/worker/services/comparison/tests/unit/test_changes.py
@@ -0,0 +1,459 @@
+import pytest
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportLine, ReportTotals
+
+from services.comparison.changes import (
+ Change,
+ diff_totals,
+ get_changes,
+ get_segment_offsets,
+)
+
+
+class TestDiffTotals(object):
+ @pytest.mark.parametrize(
+ "base, head, absolute, res",
+ [
+ (ReportTotals(lines=1), None, None, False),
+ (ReportTotals(lines=1), ReportTotals(lines=1), None, None),
+ (None, ReportTotals(lines=1), None, True),
+ (
+ ReportTotals(lines=1, coverage=1),
+ ReportTotals(lines=2, coverage=2),
+ None,
+ ReportTotals(lines=1, coverage=1),
+ ),
+ (
+ ReportTotals(lines=2, coverage=3),
+ ReportTotals(lines=1, coverage=4),
+ None,
+ ReportTotals(lines=-1, coverage=1),
+ ),
+ (
+ ReportTotals(lines=2, coverage=5),
+ ReportTotals(files=1, coverage=6),
+ None,
+ ReportTotals(files=1, lines=-2, coverage=1),
+ ),
+ (
+ ReportTotals(coverage=15),
+ ReportTotals(coverage=14),
+ None,
+ ReportTotals(coverage=-1),
+ ),
+ (
+ ReportTotals(coverage=15),
+ ReportTotals(coverage=14),
+ ReportTotals(coverage=None),
+ ReportTotals(coverage=-1),
+ ),
+ ],
+ )
+ def test_diff_totals(self, base, head, absolute, res):
+ assert diff_totals(base, head, absolute) == res
+
+
+@pytest.mark.parametrize(
+ "segments, result",
+ [
+ ([(["1", "0", "1", "0"], "-+ ")], ({}, [1], [1])),
+ ([(["1", "0", "1", "0"], "++ ")], ({1: -1, 2: -1}, [1, 2], [])),
+ ([(["1", "0", "1", "0"], "-- ")], ({1: 2}, [], [1, 2])),
+ (
+ [(["1", "0", "1", "0"], " ++ --+ ")],
+ ({2: -1, 3: -1, 5: 1}, [2, 3, 5], [3, 4]),
+ ),
+ ([(["5", "0", "7", "0"], " -+ + ")], ({10: -1}, [8, 10], [6])),
+ ([(["0", "0", "0", "0"], "--++")], ({1: 1, 2: -1}, [1, 2], [1, 2])),
+ ([([" ", "0", "0", "0"], "--++")], ({1: 1, 2: -1}, [1, 2], [1, 2])),
+ ([(["1", "0", "1", "0"], "-----+ ")], ({1: 4}, [1], [1, 2, 3, 4, 5])),
+ (
+ [(["1", "0", "1", "0"], "-+ -+ -+ -----++++ ")],
+ (
+ {8: -1, 9: -1, 10: -1, 7: 4},
+ [1, 3, 5, 7, 8, 9, 10],
+ [1, 3, 5, 7, 8, 9, 10, 11],
+ ),
+ ),
+ (
+ [(["5", "0", "1", "0"], " - - - + - ")],
+ ({2: 1, 3: 1, 4: 1, 5: -1, 7: 1}, [5], [6, 8, 10, 13]),
+ ),
+ ],
+)
+def test_get_segment_offsets(segments, result):
+ assert (
+ get_segment_offsets(
+ [dict(header=seg[0], lines=list(seg[1])) for seg in segments]
+ )
+ == result
+ )
+
+
+class TestChanges(object):
+ def test_get_changes_eof_case(self):
+ json_diff = {
+ "files": {
+ "filename.py": {
+ "before": None,
+ "segments": [
+ {"header": ["1", "1", "1", "20"], "lines": ["-"] + ["+"] * 20}
+ ],
+ "stats": {"added": 20, "removed": 1},
+ "type": "modified",
+ }
+ }
+ }
+ first_report = Report()
+ second_report = Report()
+ first_file = ReportFile("filename.py")
+ second_file = ReportFile("filename.py")
+ first_file.append(2, ReportLine.create(coverage=0))
+ second_file.append(2, ReportLine.create(coverage=0))
+ first_report.append(first_file)
+ second_report.append(second_file)
+ expected_result = [
+ Change(
+ "filename.py",
+ new=False,
+ deleted=False,
+ in_diff=True,
+ old_path=None,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=0,
+ misses=-1,
+ partials=0,
+ coverage=100.0,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ )
+ ]
+ res = get_changes(first_report, second_report, json_diff)
+ assert expected_result == res
+
+ def test_get_changes(self):
+ json_diff = {
+ "files": {
+ "modified.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["20", "8", "20", "8"],
+ "lines": [
+ " return k * k",
+ " ",
+ " ",
+ "-def k(l):",
+ "- return 2 * l",
+ "+def k(var):",
+ "+ return 2 * var",
+ " ",
+ " ",
+ " def sample_function():",
+ ],
+ }
+ ],
+ "stats": {"added": 2, "removed": 2},
+ "type": "modified",
+ },
+ "renamed.py": {
+ "before": "old_renamed.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "renamed_and_missing.py": {
+ "before": "old_renamed_and_missing.py",
+ "segments": [
+ {
+ "header": ["1", "1", "1", "1"],
+ "lines": ["- return 2 * l", "+ return 2 * var"],
+ }
+ ],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "renamed_with_changes.py": {
+ "before": "old_renamed_with_changes.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "removed_some_covered_lines.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["10", "3", "10", "1"],
+ "lines": ["-removed", "-r", " h"],
+ },
+ {"header": ["35", "3", "33", "1"], "lines": [" b", "-r", " a"]},
+ ],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "removed_all_covered_lines.py": {
+ "before": None,
+ "segments": [
+ {"header": ["10", "1", "10", "0"], "lines": ["-removed"]}
+ ],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "added.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["0", "0", "1", ""],
+ "lines": ["+This is an explanation"],
+ }
+ ],
+ "stats": {"added": 1, "removed": 0},
+ "type": "new",
+ },
+ "added_unnacounted.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["50", "0", "50", "1"],
+ "lines": ["+This is an explanation"],
+ }
+ ],
+ "stats": {"added": 1, "removed": 0},
+ "type": "modified",
+ },
+ "deleted.py": {
+ "before": None,
+ "stats": {"added": 0, "removed": 0},
+ "type": "deleted",
+ },
+ }
+ }
+
+ first_report = Report()
+ second_report = Report()
+ # DELETED FILE
+ first_deleted_file = ReportFile("deleted.py")
+ first_deleted_file.append(10, ReportLine.create(coverage=1))
+ first_deleted_file.append(12, ReportLine.create(coverage=0))
+ first_report.append(first_deleted_file)
+ # removed_some_covered_lines.py
+ second_deleted_file = ReportFile("removed_some_covered_lines.py")
+ second_deleted_file.append(10, ReportLine.create(coverage=0))
+ second_deleted_file.append(11, ReportLine.create(coverage=0))
+ second_deleted_file.append(12, ReportLine.create(coverage=0))
+ first_report.append(second_deleted_file)
+ # removed_all_covered_lines.py
+ third_deleted_file = ReportFile("removed_all_covered_lines.py")
+ third_deleted_file.append(10, ReportLine.create(coverage=0))
+ first_report.append(third_deleted_file)
+ # ADDED FILE
+ second_added_file = ReportFile("added.py")
+ second_added_file.append(99, ReportLine.create(coverage=1))
+ second_added_file.append(101, ReportLine.create(coverage=0))
+ second_report.append(second_added_file)
+ # ADDED FILE BUT UNNACOUNTED FOR
+ second_added_file_unnaccounted_for = ReportFile("added_unnacounted.py")
+ second_added_file_unnaccounted_for.append(99, ReportLine.create(coverage=1))
+ second_added_file_unnaccounted_for.append(101, ReportLine.create(coverage=0))
+ second_report.append(second_added_file_unnaccounted_for)
+ # MODIFIED FILE
+ first_modified_file = ReportFile("modified.py")
+ first_modified_file.append(17, ReportLine.create(coverage=1))
+ first_modified_file.append(18, ReportLine.create(coverage=1))
+ first_modified_file.append(19, ReportLine.create(coverage=1))
+ first_modified_file.append(20, ReportLine.create(coverage=0))
+ first_modified_file.append(21, ReportLine.create(coverage=1))
+ first_modified_file.append(22, ReportLine.create(coverage=1))
+ first_modified_file.append(23, ReportLine.create(coverage=1))
+ first_report.append(first_modified_file)
+ second_modified_file = ReportFile("modified.py")
+ second_modified_file.append(18, ReportLine.create(coverage=1))
+ second_modified_file.append(19, ReportLine.create(coverage=0))
+ second_modified_file.append(20, ReportLine.create(coverage=0))
+ second_modified_file.append(21, ReportLine.create(coverage=1))
+ second_modified_file.append(22, ReportLine.create(coverage=0))
+ second_modified_file.append(23, ReportLine.create(coverage=0))
+ second_report.append(second_modified_file)
+ # RENAMED WITHOUT CHANGES
+ first_renamed_without_changes_file = ReportFile("old_renamed.py")
+ first_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ first_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ first_report.append(first_renamed_without_changes_file)
+ second_renamed_without_changes_file = ReportFile("renamed.py")
+ second_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ second_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_without_changes_file)
+ # RENAMED WITH COVERAGE CHANGES FILE
+ first_renamed_file = ReportFile("old_renamed_with_changes.py")
+ first_renamed_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_file.append(3, ReportLine.create(coverage=1))
+ first_renamed_file.append(5, ReportLine.create(coverage=0))
+ first_renamed_file.append(8, ReportLine.create(coverage=1))
+ first_renamed_file.append(13, ReportLine.create(coverage=1))
+ first_report.append(first_renamed_file)
+ second_renamed_file = ReportFile("renamed_with_changes.py")
+ second_renamed_file.append(5, ReportLine.create(coverage=1))
+ second_renamed_file.append(8, ReportLine.create(coverage=0))
+ second_renamed_file.append(13, ReportLine.create(coverage=1))
+ second_renamed_file.append(21, ReportLine.create(coverage=1))
+ second_renamed_file.append(34, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_file)
+ # UNRELATED FILE
+ first_unrelated_file = ReportFile("unrelated.py")
+ first_unrelated_file.append(1, ReportLine.create(coverage=1))
+ first_unrelated_file.append(2, ReportLine.create(coverage=1))
+ first_unrelated_file.append(4, ReportLine.create(coverage=1))
+ first_unrelated_file.append(16, ReportLine.create(coverage=0))
+ first_unrelated_file.append(256, ReportLine.create(coverage=1))
+ first_unrelated_file.append(65556, ReportLine.create(coverage=1))
+ first_report.append(first_unrelated_file)
+ second_unrelated_file = ReportFile("unrelated.py")
+ second_unrelated_file.append(2, ReportLine.create(coverage=1))
+ second_unrelated_file.append(4, ReportLine.create(coverage=0))
+ second_unrelated_file.append(8, ReportLine.create(coverage=0))
+ second_unrelated_file.append(16, ReportLine.create(coverage=1))
+ second_unrelated_file.append(32, ReportLine.create(coverage=0))
+ second_report.append(second_unrelated_file)
+ # JUST VANISHED
+ first_old_renamed_and_missing = ReportFile("old_renamed_and_missing.py")
+ first_old_renamed_and_missing.append(1, ReportLine.create(coverage=1))
+ first_old_renamed_and_missing.append(2, ReportLine.create(coverage=1))
+ first_old_renamed_and_missing.append(3, ReportLine.create(coverage=0))
+ first_old_renamed_and_missing.append(8, ReportLine.create(coverage=0))
+ first_report.append(first_old_renamed_and_missing)
+ res = get_changes(first_report, second_report, json_diff)
+ expected_result = [
+ Change(
+ path="modified.py",
+ new=False,
+ deleted=False,
+ in_diff=True,
+ old_path=None,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-35.714290000000005,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(
+ path="renamed_with_changes.py",
+ new=False,
+ deleted=False,
+ in_diff=True,
+ old_path="old_renamed_with_changes.py",
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-1,
+ misses=1,
+ partials=0,
+ coverage=-20.0,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(
+ path="unrelated.py",
+ new=False,
+ deleted=False,
+ in_diff=False,
+ old_path=None,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(
+ path="added_unnacounted.py",
+ new=True,
+ deleted=False,
+ in_diff=None,
+ old_path=None,
+ totals=None,
+ ),
+ Change(path="removed_some_covered_lines.py", deleted=True),
+ Change(
+ path="renamed_and_missing.py",
+ new=False,
+ deleted=True,
+ in_diff=None,
+ old_path=None,
+ totals=None,
+ ),
+ ]
+ assert set([x.path for x in res]) == set([x.path for x in expected_result])
+ for individual_result, individual_expected_result in zip(
+ sorted(res, key=lambda x: x.path),
+ sorted(expected_result, key=lambda x: x.path),
+ ):
+ assert individual_result == individual_expected_result
+ assert sorted(res, key=lambda x: x.path) == sorted(
+ expected_result, key=lambda x: x.path
+ )
+
+ def test_get_changes_missing_file(self):
+ json_diff = {"files": {"a": {"before": "missing_file"}}}
+ first_report_file = ReportFile("missing_file")
+ first_report_file.append(18, ReportLine.create(coverage=1))
+ first_report = Report()
+ second_report = Report()
+ second_report.append(first_report_file)
+ res = get_changes(first_report, second_report, json_diff)
+ assert res == [
+ Change(
+ path="missing_file",
+ new=True,
+ deleted=False,
+ in_diff=None,
+ old_path=None,
+ totals=None,
+ )
+ ]
+
+ def test_get_changes_diff_with_no_before(self):
+ json_diff = {"files": {"file_on_base": {"type": "binary"}}}
+ first_report = Report()
+ second_report = Report()
+ res = get_changes(first_report, second_report, json_diff)
+ assert res == []
diff --git a/apps/worker/services/comparison/tests/unit/test_comparison_proxy.py b/apps/worker/services/comparison/tests/unit/test_comparison_proxy.py
new file mode 100644
index 0000000000..a9821ca6ed
--- /dev/null
+++ b/apps/worker/services/comparison/tests/unit/test_comparison_proxy.py
@@ -0,0 +1,142 @@
+from mock import call, patch
+
+from database.tests.factories import CommitFactory, PullFactory, RepositoryFactory
+from services.comparison import NOT_RESOLVED, ComparisonProxy
+from services.comparison.types import Comparison, FullCommit
+from services.repository import EnrichedPull
+
+
+def make_sample_comparison(adjusted_base=False):
+ repo = RepositoryFactory.create(owner__service="github")
+
+ head_commit = CommitFactory.create(repository=repo)
+ adjusted_base_commit = CommitFactory.create(repository=repo)
+
+ if adjusted_base:
+ # Just getting a random commitid, doesn't need to be in the db
+ patch_coverage_base_commitid = CommitFactory.create(repository=repo).commitid
+ else:
+ patch_coverage_base_commitid = adjusted_base_commit.commitid
+
+ pull = PullFactory.create(
+ repository=repo,
+ head=head_commit.commitid,
+ base=patch_coverage_base_commitid,
+ compared_to=adjusted_base_commit.commitid,
+ )
+
+ base_full_commit = FullCommit(commit=adjusted_base_commit, report=None)
+ head_full_commit = FullCommit(commit=head_commit, report=None)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=patch_coverage_base_commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={},
+ ),
+ ),
+ )
+
+
+class TestComparisonProxy(object):
+ compare_url = "https://api.github.com/repos/{}/compare/{}...{}"
+
+ @patch("shared.torngit.github.Github.get_compare")
+ def test_get_diff_adjusted_base(self, mock_get_compare):
+ comparison = make_sample_comparison(adjusted_base=True)
+ mock_get_compare.return_value = {"diff": "magic string"}
+ result = comparison.get_diff(use_original_base=False)
+
+ assert result == "magic string"
+ assert comparison._adjusted_base_diff == "magic string"
+ assert comparison._original_base_diff is NOT_RESOLVED
+ assert (
+ comparison.comparison.patch_coverage_base_commitid
+ != comparison.project_coverage_base.commit.commitid
+ )
+
+ assert mock_get_compare.call_args_list == [
+ call(
+ comparison.project_coverage_base.commit.commitid,
+ comparison.head.commit.commitid,
+ with_commits=False,
+ ),
+ ]
+
+ @patch("shared.torngit.github.Github.get_compare")
+ def test_get_diff_original_base(self, mock_get_compare):
+ comparison = make_sample_comparison(adjusted_base=True)
+ mock_get_compare.return_value = {"diff": "magic string"}
+ result = comparison.get_diff(use_original_base=True)
+
+ assert result == "magic string"
+ assert comparison._original_base_diff == "magic string"
+ assert comparison._adjusted_base_diff is NOT_RESOLVED
+ assert (
+ comparison.comparison.patch_coverage_base_commitid
+ != comparison.project_coverage_base.commit.commitid
+ )
+
+ assert mock_get_compare.call_args_list == [
+ call(
+ comparison.comparison.patch_coverage_base_commitid,
+ comparison.head.commit.commitid,
+ with_commits=False,
+ ),
+ ]
+
+ @patch("shared.torngit.github.Github.get_compare")
+ def test_get_diff_bases_match_original_base(self, mock_get_compare):
+ comparison = make_sample_comparison(adjusted_base=False)
+ mock_get_compare.return_value = {"diff": "magic string"}
+ result = comparison.get_diff(use_original_base=True)
+
+ assert result == "magic string"
+ assert comparison._original_base_diff == "magic string"
+ assert (
+ comparison.comparison.patch_coverage_base_commitid
+ == comparison.project_coverage_base.commit.commitid
+ )
+
+ # In this test case, the adjusted and original base commits are the
+ # same. If we get one, we should set the cache for the other.
+ adjusted_base_result = comparison.get_diff(use_original_base=False)
+ assert comparison._adjusted_base_diff == "magic string"
+
+ # Make sure we only called the Git provider API once
+ assert mock_get_compare.call_args_list == [
+ call(
+ comparison.comparison.patch_coverage_base_commitid,
+ comparison.head.commit.commitid,
+ with_commits=False,
+ ),
+ ]
+
+ @patch("shared.torngit.github.Github.get_compare")
+ def test_get_diff_bases_match_adjusted_base(self, mock_get_compare):
+ comparison = make_sample_comparison(adjusted_base=False)
+ mock_get_compare.return_value = {"diff": "magic string"}
+ result = comparison.get_diff(use_original_base=False)
+
+ assert result == "magic string"
+ assert comparison._adjusted_base_diff == "magic string"
+ assert (
+ comparison.comparison.patch_coverage_base_commitid
+ == comparison.project_coverage_base.commit.commitid
+ )
+
+ # In this test case, the adjusted and original base commits are the
+ # same. If we get one, we should set the cache for the other.
+ adjusted_base_result = comparison.get_diff(use_original_base=True)
+ assert comparison._adjusted_base_diff == "magic string"
+
+ # Make sure we only called the Git provider API once
+ assert mock_get_compare.call_args_list == [
+ call(
+ comparison.comparison.patch_coverage_base_commitid,
+ comparison.head.commit.commitid,
+ with_commits=False,
+ ),
+ ]
diff --git a/apps/worker/services/comparison/tests/unit/test_get_or_create_comparison.py b/apps/worker/services/comparison/tests/unit/test_get_or_create_comparison.py
new file mode 100644
index 0000000000..ba1f0571d6
--- /dev/null
+++ b/apps/worker/services/comparison/tests/unit/test_get_or_create_comparison.py
@@ -0,0 +1,29 @@
+from database.enums import CompareCommitState
+from database.tests.factories.core import CommitFactory, CompareCommitFactory
+from services.comparison import get_or_create_comparison
+
+
+class TestGetOrCreateComparison(object):
+ def test_get_or_create_existing_comparison(self, dbsession):
+ existing_comparison = CompareCommitFactory.create()
+ dbsession.add(existing_comparison)
+ dbsession.flush()
+
+ comparison = get_or_create_comparison(
+ dbsession,
+ existing_comparison.base_commit,
+ existing_comparison.compare_commit,
+ )
+ assert comparison == existing_comparison
+ assert comparison.state == CompareCommitState.pending.value
+ assert comparison.error is None
+
+ def test_get_or_create_new_comparison(self, dbsession):
+ base_commit = CommitFactory()
+ commit = CommitFactory()
+ dbsession.commit()
+ comparison = get_or_create_comparison(dbsession, base_commit, commit)
+ dbsession.flush()
+ assert comparison.state == CompareCommitState.pending.value
+ assert comparison.base_commit == base_commit
+ assert comparison.compare_commit == commit
diff --git a/apps/worker/services/comparison/tests/unit/test_reports_uploaded_count_diff.py b/apps/worker/services/comparison/tests/unit/test_reports_uploaded_count_diff.py
new file mode 100644
index 0000000000..a38b181f55
--- /dev/null
+++ b/apps/worker/services/comparison/tests/unit/test_reports_uploaded_count_diff.py
@@ -0,0 +1,143 @@
+from unittest.mock import MagicMock
+
+import pytest
+from shared.reports.resources import Report
+from shared.utils.sessions import Session, SessionType
+from shared.yaml import UserYaml
+
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison, FullCommit, ReportUploadedCount
+
+
+@pytest.mark.parametrize(
+ "head_sessions, base_sessions, expected_count, expected_diff",
+ [
+ (
+ {
+ 0: Session(
+ flags=["unit", "local"], session_type=SessionType.carriedforward
+ ),
+ 1: Session(flags=["integration"], session_type=SessionType.uploaded),
+ 2: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 3: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 4: Session(flags=["integration"], session_type=SessionType.uploaded),
+ 5: Session(flags=[], session_type=SessionType.uploaded),
+ },
+ {
+ 0: Session(
+ flags=["unit", "local"], session_type=SessionType.carriedforward
+ ),
+ 1: Session(
+ flags=["integration"], session_type=SessionType.carriedforward
+ ),
+ 2: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 3: Session(flags=["unit"], session_type=SessionType.uploaded),
+ },
+ [
+ ReportUploadedCount(flag="unit", base_count=2, head_count=2),
+ ReportUploadedCount(flag="integration", base_count=0, head_count=2),
+ ReportUploadedCount(flag="", base_count=0, head_count=1),
+ ],
+ [],
+ ),
+ (
+ {
+ 0: Session(
+ flags=["unit", "local"], session_type=SessionType.carriedforward
+ ),
+ 1: Session(flags=["integration"], session_type=SessionType.uploaded),
+ 2: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 3: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 4: Session(flags=["integration"], session_type=SessionType.uploaded),
+ 5: Session(flags=[""], session_type=SessionType.uploaded),
+ },
+ {
+ 0: Session(flags=["unit", "local"], session_type=SessionType.uploaded),
+ 1: Session(flags=["integration"], session_type=SessionType.uploaded),
+ 2: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 3: Session(flags=["unit"], session_type=SessionType.uploaded),
+ 4: Session(flags=["obscure_flag"], session_type=SessionType.uploaded),
+ },
+ [
+ ReportUploadedCount(flag="unit", base_count=3, head_count=2),
+ ReportUploadedCount(flag="local", base_count=1, head_count=0),
+ ReportUploadedCount(flag="integration", base_count=1, head_count=2),
+ ReportUploadedCount(flag="obscure_flag", base_count=1, head_count=0),
+ ReportUploadedCount(flag="", base_count=0, head_count=1),
+ ],
+ [
+ ReportUploadedCount(flag="unit", base_count=3, head_count=2),
+ ReportUploadedCount(flag="obscure_flag", base_count=1, head_count=0),
+ ],
+ ),
+ (
+ {0: Session(flags=[], session_type=SessionType.uploaded)},
+ {
+ 0: Session(flags=[], session_type=SessionType.uploaded),
+ 1: Session(flags=[], session_type=SessionType.uploaded),
+ },
+ [ReportUploadedCount(flag="", base_count=2, head_count=1)],
+ [ReportUploadedCount(flag="", base_count=2, head_count=1)],
+ ),
+ ],
+ ids=[
+ "flag_counts_no_diff",
+ "flag_count_yes_diff",
+ "diff_from_session_with_no_flags",
+ ],
+)
+def test_get_reports_uploaded_count_per_flag(
+ head_sessions, base_sessions, expected_count, expected_diff, mock_configuration
+):
+ head_report = Report()
+ head_report.sessions = head_sessions
+ base_report = Report()
+ base_report.sessions = base_sessions
+ comparison_proxy = ComparisonProxy(
+ comparison=Comparison(
+ head=FullCommit(report=head_report, commit=None),
+ project_coverage_base=FullCommit(report=base_report, commit=None),
+ patch_coverage_base_commitid=None,
+ enriched_pull=None,
+ current_yaml=UserYaml(
+ {
+ "flag_management": {
+ "default_rules": {"carryforward": True},
+ "individual_flags": [
+ {"name": "obscure_flag", "carryforward": False}
+ ],
+ }
+ }
+ ),
+ )
+ )
+ # Python Dicts preserve order, so we can actually test this equality
+ # See more https://stackoverflow.com/a/39537308
+ assert comparison_proxy.get_reports_uploaded_count_per_flag() == expected_count
+ assert comparison_proxy.get_reports_uploaded_count_per_flag_diff() == expected_diff
+
+
+def test_get_reports_uploaded_count_per_flag_cached():
+ comparison_proxy = ComparisonProxy(comparison=MagicMock(name="fake_comparison"))
+ comparison_proxy._cached_reports_uploaded_per_flag = (
+ "object_that_doesnt_have_this_shape"
+ )
+ assert (
+ comparison_proxy.get_reports_uploaded_count_per_flag()
+ == "object_that_doesnt_have_this_shape"
+ )
+
+
+def test_get_reports_uploaded_count_per_flag_diff_missing_report():
+ head_report = None
+ base_report = Report()
+ base_report.sessions = None
+ comparison_proxy = ComparisonProxy(
+ comparison=Comparison(
+ head=FullCommit(report=head_report, commit=None),
+ project_coverage_base=FullCommit(report=base_report, commit=None),
+ patch_coverage_base_commitid=None,
+ enriched_pull=None,
+ )
+ )
+ assert comparison_proxy.get_reports_uploaded_count_per_flag_diff() == []
diff --git a/apps/worker/services/comparison/types.py b/apps/worker/services/comparison/types.py
new file mode 100644
index 0000000000..7f3afefcd3
--- /dev/null
+++ b/apps/worker/services/comparison/types.py
@@ -0,0 +1,61 @@
+from dataclasses import dataclass
+from typing import Optional, TypedDict
+
+from shared.reports.readonly import ReadOnlyReport
+from shared.yaml import UserYaml
+
+from database.models import Commit
+from services.repository import EnrichedPull
+
+
+@dataclass
+class FullCommit(object):
+ commit: Commit
+ report: ReadOnlyReport
+
+
+class ReportUploadedCount(TypedDict):
+ flag: str
+ base_count: int
+ head_count: int
+
+
+@dataclass
+class Comparison(object):
+ head: FullCommit
+
+ # To see how a patch changes project coverage, we compare the branch head's
+ # report against the base's report, or if the base isn't in our database,
+ # the next-oldest commit that is. Be aware that this base commit may not be
+ # the true base that, for example, a PR is based on.
+ project_coverage_base: FullCommit
+
+ # Computing patch coverage doesn't require an old report to compare against,
+ # so doing the "next-oldest" adjustment described above is unnecessary and
+ # makes the results less correct. All it requires is a head report and the
+ # patch diff, and the original base's commit SHA is enough to get that.
+ patch_coverage_base_commitid: str
+
+ enriched_pull: EnrichedPull
+ current_yaml: Optional[UserYaml] = None
+
+ # FIXME: The functions down below would not make sense given that we assume
+ # a `FullCommit` and its `report` to always exist.
+ # Which they don't, so these checks do make sense, contrary to declared types.
+ # Similarly, we also expect an `EnrichedPull` to exist, which does not match
+ # the reality.
+
+ def has_project_coverage_base_report(self):
+ return bool(
+ self.project_coverage_base is not None
+ and self.project_coverage_base.report is not None
+ )
+
+ def has_head_report(self):
+ return bool(self.head is not None and self.head.report is not None)
+
+ @property
+ def pull(self):
+ if self.enriched_pull is None:
+ return None
+ return self.enriched_pull.database_pull
diff --git a/apps/worker/services/comparison_utils.py b/apps/worker/services/comparison_utils.py
new file mode 100644
index 0000000000..52d592f1a3
--- /dev/null
+++ b/apps/worker/services/comparison_utils.py
@@ -0,0 +1,37 @@
+import sentry_sdk
+from shared.reports.readonly import ReadOnlyReport
+
+from database.models import CompareCommit
+from services.comparison import ComparisonContext, ComparisonProxy
+from services.comparison.types import Comparison, FullCommit
+from services.report import ReportService
+
+
+@sentry_sdk.trace
+def get_comparison_proxy(
+ comparison: CompareCommit,
+ report_service: ReportService,
+):
+ compare_commit = comparison.compare_commit
+ base_commit = comparison.base_commit
+
+ base_report = report_service.get_existing_report_for_commit(
+ base_commit, report_class=ReadOnlyReport
+ )
+ compare_report = report_service.get_existing_report_for_commit(
+ compare_commit, report_class=ReadOnlyReport
+ )
+ # No access to the PR so we have to assume the base commit did not need
+ # to be adjusted.
+ patch_coverage_base_commitid = base_commit.commitid
+ return ComparisonProxy(
+ Comparison(
+ head=FullCommit(commit=compare_commit, report=compare_report),
+ project_coverage_base=FullCommit(commit=base_commit, report=base_report),
+ patch_coverage_base_commitid=patch_coverage_base_commitid,
+ enriched_pull=None,
+ ),
+ context=ComparisonContext(
+ gh_app_installation_name=report_service.gh_app_installation_name
+ ),
+ )
diff --git a/apps/worker/services/decoration.py b/apps/worker/services/decoration.py
new file mode 100644
index 0000000000..4aea8b79f3
--- /dev/null
+++ b/apps/worker/services/decoration.py
@@ -0,0 +1,170 @@
+import logging
+from dataclasses import dataclass
+
+from shared.config import get_config
+from shared.plan.service import PlanService
+from shared.upload.utils import query_monthly_coverage_measurements
+
+from database.enums import Decoration
+from database.models import Owner
+from services.license import requires_license
+from services.repository import EnrichedPull
+
+log = logging.getLogger(__name__)
+
+# For more context on PR decorations, see here:
+# https://codecovio.atlassian.net/wiki/spaces/ENG/pages/34603058/PR+based+Billing+Refactor
+
+
+BOT_USER_EMAILS = [
+ "dependabot[bot]@users.noreply.github.com",
+ "29139614+renovate[bot]@users.noreply.github.com",
+ "157164994+sentry-autofix[bot]@users.noreply.github.com",
+]
+BOT_USER_IDS = ["29139614", "157164994"] # renovate[bot] github, sentry-autofix[bot]
+USER_BASIC_LIMIT_UPLOAD = 250
+
+
+@dataclass
+class DecorationDetails(object):
+ decoration_type: Decoration
+ reason: str
+ should_attempt_author_auto_activation: bool = False
+ activation_org_ownerid: int | None = None
+ activation_author_ownerid: int | None = None
+
+
+def _is_bot_account(author: Owner) -> bool:
+ return author.email in BOT_USER_EMAILS or author.service_id in BOT_USER_IDS
+
+
+def determine_uploads_used(plan_service: PlanService) -> int:
+ # This query takes an absurdly long time to run and in some environments we
+ # would like to disable it
+ if not get_config("setup", "upload_throttling_enabled", default=True):
+ return 0
+
+ return query_monthly_coverage_measurements(plan_service=plan_service)
+
+
+def determine_decoration_details(
+ enriched_pull: EnrichedPull, empty_upload=None
+) -> DecorationDetails:
+ """
+ Determine the decoration details from pull information. We also check if the pull author needs to be activated
+
+ Returns:
+ DecorationDetails: the decoration type and reason along with whether auto-activation of the author should be attempted
+ """
+ if enriched_pull:
+ db_pull = enriched_pull.database_pull
+ provider_pull = enriched_pull.provider_pull
+
+ if not provider_pull:
+ return DecorationDetails(
+ decoration_type=Decoration.standard,
+ reason="Can't determine PR author - no pull info from provider",
+ )
+ if empty_upload == "pass":
+ return DecorationDetails(
+ decoration_type=Decoration.passing_empty_upload,
+ reason="Non testable files got changed.",
+ )
+
+ if empty_upload == "fail":
+ return DecorationDetails(
+ decoration_type=Decoration.failing_empty_upload,
+ reason="Testable files got changed.",
+ )
+
+ if empty_upload == "processing":
+ return DecorationDetails(
+ decoration_type=Decoration.processing_upload,
+ reason="Upload is still processing.",
+ )
+
+ if db_pull.repository.private is False:
+ # public repo or repo we aren't certain is private should be standard
+ return DecorationDetails(
+ decoration_type=Decoration.standard, reason="Public repo"
+ )
+
+ org = db_pull.repository.owner
+
+ db_session = db_pull.get_db_session()
+
+ # do not access plan directly - only through PlanService
+ org_plan = PlanService(current_org=org)
+ # use the org that has the plan - for GL this is the root_org rather than the repository.owner org
+ org = org_plan.current_org
+
+ if not org_plan.is_pr_billing_plan:
+ return DecorationDetails(
+ decoration_type=Decoration.standard, reason="Org not on PR plan"
+ )
+
+ pr_author = (
+ db_session.query(Owner)
+ .filter(
+ Owner.service == org.service,
+ Owner.service_id == provider_pull["author"]["id"],
+ )
+ .first()
+ )
+
+ if not pr_author:
+ log.info(
+ "PR author not found in database",
+ extra=dict(
+ author_service=org.service,
+ author_service_id=provider_pull["author"]["id"],
+ author_username=provider_pull["author"]["username"],
+ ),
+ )
+ return DecorationDetails(
+ decoration_type=Decoration.upgrade,
+ reason="PR author not found in database",
+ )
+
+ monthly_limit = org_plan.monthly_uploads_limit
+ if monthly_limit is not None:
+ uploads_used = determine_uploads_used(plan_service=org_plan)
+
+ if (
+ uploads_used >= org_plan.monthly_uploads_limit
+ and not requires_license()
+ ):
+ return DecorationDetails(
+ decoration_type=Decoration.upload_limit,
+ reason="Org has exceeded the upload limit",
+ )
+
+ if (
+ org.plan_activated_users is not None
+ and pr_author.ownerid in org.plan_activated_users
+ ):
+ return DecorationDetails(
+ decoration_type=Decoration.standard,
+ reason="User is currently activated",
+ )
+
+ if _is_bot_account(pr_author):
+ return DecorationDetails(
+ decoration_type=Decoration.standard,
+ reason="Bot user detected (does not need to be activated)",
+ )
+
+ if not org.plan_auto_activate:
+ return DecorationDetails(
+ decoration_type=Decoration.upgrade,
+ reason="User must be manually activated",
+ )
+ else:
+ return DecorationDetails(
+ decoration_type=Decoration.upgrade,
+ reason="User must be activated",
+ should_attempt_author_auto_activation=True,
+ activation_org_ownerid=org.ownerid,
+ activation_author_ownerid=pr_author.ownerid,
+ )
+ return DecorationDetails(decoration_type=Decoration.standard, reason="No pull")
diff --git a/apps/worker/services/encryption.py b/apps/worker/services/encryption.py
new file mode 100644
index 0000000000..8fc320865e
--- /dev/null
+++ b/apps/worker/services/encryption.py
@@ -0,0 +1,3 @@
+from shared.encryption.oauth import get_encryptor_from_configuration
+
+encryptor = get_encryptor_from_configuration()
diff --git a/apps/worker/services/failure_normalizer.py b/apps/worker/services/failure_normalizer.py
new file mode 100644
index 0000000000..3a92288440
--- /dev/null
+++ b/apps/worker/services/failure_normalizer.py
@@ -0,0 +1,112 @@
+from typing import List, Optional
+
+import regex
+import sentry_sdk
+
+predefined_dict_of_regexes_to_match = {
+ "UUID": [
+ r"[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}"
+ ],
+ "DATETIME": [
+ r"(?:19|20)[0-9]{2}-(?:(?:0?[0-9])|(?:1[012]))-(?:(?:0?[0-9])|1[0-9]|2[0-9]|3[01])T(?:0[0-9]|1[0-9]|2[0-3]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:Z|(?:-(?:0[0-9]:[03]0)))?",
+ r"(?:19|20)[0-9]{2}(?:(?:0?[0-9])|(?:1[012]))(?:(?:0?[0-9])|1[0-9]|2[0-9]|3[01])T(?:0[0-9]|1[0-9]|2[0-3])(?:[0-5][0-9])(?:[0-5][0-9])(?:Z|(?:-(?:0[0-9]:[03]0)))?",
+ ],
+ "DATE": [
+ r"(?:19|20)[0-9]{2}-(?:(?:0?[0-9])|(?:1[012]))-(?:(?:0?[0-9])\b|1[0-9]|2[0-9]|3[01])"
+ ],
+ "TIME": [
+ r"(?:0[0-9]|1[0-9]|2[0-3]):(?:[0-5][0-9]):(?:[0-5][0-9])Z?",
+ r"T(?:0[0-9]|1[0-9]|2[0-3])(?:[0-5][0-9])(?:[0-5][0-9])Z?",
+ ],
+ "URL": [
+ r"[a-z]+:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)"
+ ],
+ "FILEPATH": [r"\/?[a-zA-Z0-9-_]+(\/[a-zA-Z0-9-_]+)+(?=\/([a-zA-Z0-9-_]+\/){2})"],
+ "LINENO": [r":\d+:\d*"],
+ "HEXNUMBER": [r"0?x[A-Fa-f0-9]+\b"],
+ "HASH": [r"[0-9a-fA-F\-]{30}[0-9a-fA-F\-]*"],
+ "NO": [r"[+-]?\d+(\.\d+)?\b"],
+}
+
+
+class FailureNormalizer:
+ """
+ Class for normalizing a failure message
+
+ Takes a dict of strings to strings where the key is the replacement string and the value is
+ the regex we want to match and replace occurences of and a boolean that toggles whether we
+ should ignore the predefined list of patterns in the constructor
+
+ The normalize_failure_message method on the class takes a string as a parameter
+ it will replace all occurences of a match of the regexes specified in the list
+ with the keys that map to that regex, in that string
+
+ If the users
+
+ Usage:
+
+ dict_of_regex_strings = [
+ "DATE": r"(\\d{4}-\\d{2}-\\d{2})",
+ ]
+
+ f = FailureNormalizer(dict_of_regex_strings)
+ s = '''
+ abcdefAB-1234-1234-1234-abcdef123456 test_1 abcdefAB-1234-1234-1234-abcdef123456 test_2 2024-03-09
+ test_3 abcdefAB-5678-5678-5678-abcdef123456 2024-03-10 test_4
+ 2024-03-10
+ '''
+
+ f.normalize_failure_message(s)
+
+ will give:
+
+ '''
+ UUID test_1 UUID test_2 DATE
+ test_3 UUID DATE test_4
+ DATE
+ '''
+ """
+
+ def __init__(
+ self,
+ user_dict_of_regex_strings: dict[str, list[str]],
+ ignore_predefined=False,
+ override_predefined=False,
+ *,
+ key_analysis_order: Optional[List[str]] = None,
+ ):
+ flags = regex.MULTILINE
+
+ self.dict_of_regex = dict()
+ self.key_analysis_order = key_analysis_order
+
+ if not ignore_predefined:
+ dict_of_list_of_regex_string = dict(predefined_dict_of_regexes_to_match)
+ for key, user_regex_string in user_dict_of_regex_strings.items():
+ if not override_predefined and key in dict_of_list_of_regex_string:
+ dict_of_list_of_regex_string[key] = (
+ user_regex_string + dict_of_list_of_regex_string[key]
+ )
+ else:
+ dict_of_list_of_regex_string[key] = user_regex_string
+ else:
+ dict_of_list_of_regex_string = dict(user_dict_of_regex_strings)
+
+ for key, list_of_regex_string in dict_of_list_of_regex_string.items():
+ self.dict_of_regex[key] = [
+ regex.compile(regex_string, flags=flags)
+ for regex_string in list_of_regex_string
+ ]
+
+ @sentry_sdk.trace
+ def normalize_failure_message(self, failure_message: str):
+ key_ordering = self.key_analysis_order or self.dict_of_regex.keys()
+ for key in key_ordering:
+ list_of_compiled_regex = self.dict_of_regex[key]
+ for compiled_regex in list_of_compiled_regex:
+ for match_obj in compiled_regex.finditer(failure_message):
+ actual_match = match_obj.group()
+ # Limit number of replaces to 1 so one match doesn't interfere
+ # With future matches
+ failure_message = failure_message.replace(actual_match, key, 1)
+ return failure_message
diff --git a/apps/worker/services/github.py b/apps/worker/services/github.py
new file mode 100644
index 0000000000..e6cd431eeb
--- /dev/null
+++ b/apps/worker/services/github.py
@@ -0,0 +1,79 @@
+import logging
+from typing import Optional
+
+from redis import RedisError
+from shared.github import InvalidInstallationError
+from shared.github import get_github_integration_token as _get_github_integration_token
+from shared.helpers.redis import get_redis_connection
+
+from database.models.core import Commit
+from helpers.exceptions import RepositoryWithoutValidBotError
+
+log = logging.getLogger(__name__)
+
+
+def get_github_integration_token(
+ service: str,
+ installation_id: int = None,
+ app_id: Optional[str] = None,
+ pem_path: Optional[str] = None,
+):
+ try:
+ return _get_github_integration_token(
+ service, integration_id=installation_id, app_id=app_id, pem_path=pem_path
+ )
+ except InvalidInstallationError:
+ log.warning("Failed to get installation token")
+ raise RepositoryWithoutValidBotError()
+
+
+def COMMIT_GHAPP_KEY_NAME(commit_id):
+ return f"app_to_use_for_commit_{commit_id}"
+
+
+GHAPP_KEY_EXPIRY_SECONDS = 60 * 60 * 2 # 2h
+
+
+def set_github_app_for_commit(
+ installation_id: str | int | None, commit: Commit
+) -> bool:
+ """Sets a GithubAppInstallation.id in Redis as the installation to use for a commit.
+ Keys live in redis for GHAPP_KEY_EXPIRY_SECONDS before being expired.
+
+ Args:
+ installation_id (str | int | None) - the ID to save.
+ None -- there was actually no installation ID. Do nothing.
+ int -- value comes from the Database
+ str -- value comes from Redis (i.e. the installation was already cached)
+ commit (Commit) - the commit to attach installation_id to
+ """
+ if installation_id is None:
+ return False
+ redis = get_redis_connection()
+ try:
+ redis.set(
+ COMMIT_GHAPP_KEY_NAME(commit.id),
+ str(installation_id),
+ ex=GHAPP_KEY_EXPIRY_SECONDS,
+ )
+ return True
+ except RedisError:
+ log.exception(
+ "Failed to set app for commit", extra=dict(commit=commit.commitid)
+ )
+ return False
+
+
+def get_github_app_for_commit(commit: Commit) -> str | None:
+ if commit.repository.service not in ["github", "github_enterprise"]:
+ # Because this feature is GitHub-exclusive we can skip checking for other providers
+ return None
+ redis = get_redis_connection()
+ try:
+ value = redis.get(COMMIT_GHAPP_KEY_NAME(commit.id))
+ return value if value is None else value.decode()
+ except RedisError:
+ log.exception(
+ "Failed to get app for commit", extra=dict(commit=commit.commitid)
+ )
+ return None
diff --git a/apps/worker/services/github_marketplace.py b/apps/worker/services/github_marketplace.py
new file mode 100644
index 0000000000..f83f85c6e0
--- /dev/null
+++ b/apps/worker/services/github_marketplace.py
@@ -0,0 +1,120 @@
+import logging
+
+import requests
+import shared.torngit as torngit
+from shared.config import get_config
+
+from services.github import get_github_integration_token
+
+log = logging.getLogger(__name__)
+
+
+class GitHubMarketplaceService(object):
+ """
+ Static ids for each of Codecov's plans on GitHub marketplace
+ """
+
+ LEGACY_PLAN_ID = 18
+ CURRENT_PLAN_ID = 2147
+ PER_USER_PLAN_ID = 3267
+
+ def __init__(self):
+ self._token = None
+ self.use_stubbed = get_config(
+ "services", "github_marketplace", "use_stubbed", default=False
+ )
+
+ def api(
+ self,
+ method,
+ url,
+ body=None,
+ headers=None,
+ params=None,
+ auth_with_integration_token=True,
+ **args,
+ ):
+ _headers = {
+ "Accept": "application/vnd.github.valkyrie-preview+json",
+ "User-Agent": "Codecov",
+ }
+ if auth_with_integration_token:
+ _headers["Authorization"] = f"Bearer {self.get_integration_token()}"
+ _headers.update(headers or {})
+
+ method = (method or "GET").upper()
+
+ if url.startswith("/"):
+ base_url = torngit.Github.get_api_url()
+ url = base_url + url
+
+ if self.use_stubbed:
+ # use stubbed endpoints for testing
+ # https://developer.github.com/v3/apps/marketplace/#testing-with-stubbed-endpoints
+ url = url.replace("marketplace_listing/", "marketplace_listing/stubbed/", 1)
+
+ res = requests.request(method, url, headers=_headers, params=params)
+ try:
+ res.raise_for_status()
+ except requests.exceptions.HTTPError:
+ log.exception(
+ "Github Marketplace Service Error",
+ extra=dict(code=res.status_code, text=res.text),
+ )
+ raise
+ return res.json()
+
+ def get_integration_token(self):
+ """
+ Get GitHub app token
+ """
+ if not self._token:
+ self._token = get_github_integration_token("github")
+
+ return self._token
+
+ @property
+ def plan_ids(self):
+ return [self.LEGACY_PLAN_ID, self.CURRENT_PLAN_ID, self.PER_USER_PLAN_ID]
+
+ def get_account_plans(self, account_id):
+ """
+ Check if a GitHub account is associated with any Marketplace listing.
+
+ Shows whether the user or organization account actively subscribes to a
+ Codecov plan. When someone submits a plan change that won't be processed until
+ the end of their billing cycle, you will also see the upcoming pending change.
+ """
+ return self.api("get", "/marketplace_listing/accounts/{}".format(account_id))
+
+ def get_codecov_plans(self):
+ """
+ List all plans for Codecov Marketplace listing
+ """
+ return self.api("get", "/marketplace_listing/plans")
+
+ def get_plan_accounts(self, page, plan_id):
+ """
+ List all GitHub accounts (user or organization) on a specific plan.
+ """
+ params = dict(page=page)
+ return self.api(
+ "get",
+ "/marketplace_listing/plans/{}/accounts".format(plan_id),
+ params=params,
+ )
+
+ def get_user(self, service_id):
+ """
+ Get GitHub user details
+ """
+ params = dict(
+ client_id=get_config("github", "client_id"),
+ client_secret=get_config("github", "client_secret"),
+ )
+ return self.api(
+ "get",
+ "/user/{}".format(service_id),
+ params=params,
+ auth_with_integration_token=False,
+ )
diff --git a/apps/worker/services/license.py b/apps/worker/services/license.py
new file mode 100644
index 0000000000..f2bdf65581
--- /dev/null
+++ b/apps/worker/services/license.py
@@ -0,0 +1,114 @@
+import logging
+from datetime import datetime
+from enum import Enum, auto
+from functools import lru_cache
+from typing import Optional
+
+from shared.config import get_config
+from shared.license import get_current_license
+from sqlalchemy import func
+from sqlalchemy.sql import text
+
+from database.models import Owner, Repository
+from helpers.environment import is_enterprise
+
+log = logging.getLogger(__name__)
+
+
+class InvalidLicenseReason(Enum):
+ invalid = auto()
+ no_license = auto()
+ unknown = auto()
+ expired = auto()
+ demo_mode = auto()
+ url_mismatch = auto()
+ users_exceeded = auto()
+ repos_exceeded = auto()
+
+
+def is_properly_licensed(db_session) -> bool:
+ return not requires_license() or has_valid_license(db_session)
+
+
+def requires_license() -> bool:
+ return is_enterprise()
+
+
+def _get_now() -> datetime:
+ return datetime.now()
+
+
+def has_valid_license(db_session) -> bool:
+ return reason_for_not_being_valid(db_session) is None
+
+
+def reason_for_not_being_valid(db_session) -> Optional[InvalidLicenseReason]:
+ return cached_reason_for_not_being_valid(db_session)
+
+
+@lru_cache()
+def cached_reason_for_not_being_valid(db_session) -> Optional[InvalidLicenseReason]:
+ return calculate_reason_for_not_being_valid(db_session)
+
+
+def get_installation_plan_activated_users(db_session) -> list:
+ query_string = text(
+ """
+ WITH all_plan_activated_users AS (
+ SELECT DISTINCT
+ UNNEST(o.plan_activated_users) AS activated_owner_id
+ FROM owners o
+ ) SELECT count(*) as count
+ FROM all_plan_activated_users"""
+ )
+ return db_session.execute(query_string).fetchall()
+
+
+def calculate_reason_for_not_being_valid(db_session) -> Optional[InvalidLicenseReason]:
+ current_license = get_current_license()
+ if not current_license.is_valid:
+ return InvalidLicenseReason.invalid
+ if current_license.url:
+ if get_config("setup", "codecov_url") != current_license.url:
+ return InvalidLicenseReason.url_mismatch
+
+ if current_license.number_allowed_users:
+ if current_license.is_pr_billing:
+ # PR Billing must count _all_ plan_activated_users in db
+ query = get_installation_plan_activated_users(db_session)
+ else:
+ # non PR billing must count all owners with oauth_token != None.
+ query = (
+ db_session.query(func.count(), Owner.service)
+ .filter(Owner.oauth_token.isnot(None))
+ .group_by(Owner.service)
+ .all()
+ )
+ for result in query:
+ if result[0] > current_license.number_allowed_users:
+ return InvalidLicenseReason.users_exceeded
+ elif result[0] > (0.9 * current_license.number_allowed_users):
+ log.warning(
+ "Number of users is approaching license limit of %d/%d",
+ result[0],
+ current_license.number_allowed_users,
+ )
+ if current_license.number_allowed_repos:
+ repos = (
+ db_session.query(func.count())
+ .select_from(Repository)
+ .filter(Repository.updatestamp.isnot(None))
+ .first()[0]
+ )
+ if repos > current_license.number_allowed_repos:
+ return InvalidLicenseReason.repos_exceeded
+ elif repos > (current_license.number_allowed_repos * 0.85):
+ log.warning(
+ "Number of repositories is approaching license limit of %d/%d",
+ repos,
+ current_license.number_allowed_repos,
+ )
+ if current_license.expires:
+ if current_license.expires < _get_now():
+ return InvalidLicenseReason.expired
+ return None
diff --git a/apps/worker/services/lock_manager.py b/apps/worker/services/lock_manager.py
new file mode 100644
index 0000000000..189ccea181
--- /dev/null
+++ b/apps/worker/services/lock_manager.py
@@ -0,0 +1,102 @@
+import logging
+import random
+from contextlib import contextmanager
+from enum import Enum
+from typing import Optional
+
+from redis import Redis
+from redis.exceptions import LockError
+from shared.helpers.redis import get_redis_connection
+
+from database.enums import ReportType
+
+log = logging.getLogger(__name__)
+
+
+class LockType(Enum):
+ BUNDLE_ANALYSIS_PROCESSING = "bundle_analysis_processing"
+ BUNDLE_ANALYSIS_NOTIFY = "bundle_analysis_notify"
+ NOTIFICATION = "notify"
+ # TODO: port existing task locking to use `LockManager`
+
+
+class LockRetry(Exception):
+ def __init__(self, countdown: int):
+ self.countdown = countdown
+
+
+class LockManager:
+ def __init__(
+ self,
+ repoid: int,
+ commitid: str,
+ report_type=ReportType.COVERAGE,
+ lock_timeout=300, # 5 min
+ redis_connection: Optional[Redis] = None,
+ ):
+ self.repoid = repoid
+ self.commitid = commitid
+ self.report_type = report_type
+ self.lock_timeout = lock_timeout
+ self.redis_connection = redis_connection or get_redis_connection()
+
+ def lock_name(self, lock_type: LockType):
+ if self.report_type == ReportType.COVERAGE:
+ # for backward compat this does not include the report type
+ return f"{lock_type.value}_lock_{self.repoid}_{self.commitid}"
+ else:
+ return f"{lock_type.value}_lock_{self.repoid}_{self.commitid}_{self.report_type.value}"
+
+ def is_locked(self, lock_type: LockType) -> bool:
+ lock_name = self.lock_name(lock_type)
+ if self.redis_connection.get(lock_name):
+ return True
+ return False
+
+ @contextmanager
+ def locked(self, lock_type: LockType, retry_num=0):
+ lock_name = self.lock_name(lock_type)
+ try:
+ log.info(
+ "Acquiring lock",
+ extra=dict(
+ repoid=self.repoid,
+ commitid=self.commitid,
+ lock_name=lock_name,
+ ),
+ )
+ with self.redis_connection.lock(
+ lock_name, timeout=self.lock_timeout, blocking_timeout=5
+ ):
+ log.info(
+ "Acquired lock",
+ extra=dict(
+ repoid=self.repoid,
+ commitid=self.commitid,
+ lock_name=lock_name,
+ ),
+ )
+ yield
+ log.info(
+ "Releasing lock",
+ extra=dict(
+ repoid=self.repoid,
+ commitid=self.commitid,
+ lock_name=lock_name,
+ ),
+ )
+ except LockError:
+ max_retry = 200 * 3**retry_num
+ countdown = min(random.randint(max_retry // 2, max_retry), 60 * 60 * 5)
+
+ log.warning(
+ "Unable to acquire lock",
+ extra=dict(
+ repoid=self.repoid,
+ commitid=self.commitid,
+ lock_name=lock_name,
+ countdown=countdown,
+ retry_num=retry_num,
+ ),
+ )
+ raise LockRetry(countdown)
diff --git a/apps/worker/services/notification/__init__.py b/apps/worker/services/notification/__init__.py
new file mode 100644
index 0000000000..853ec3d003
--- /dev/null
+++ b/apps/worker/services/notification/__init__.py
@@ -0,0 +1,377 @@
+"""Notification system
+
+This packages uses the following services:
+ - comparison
+
+"""
+
+import logging
+from typing import Iterator, List, Optional, TypedDict
+
+from celery.exceptions import CeleryError, SoftTimeLimitExceeded
+from shared.config import get_config
+from shared.django_apps.codecov_auth.models import Plan
+from shared.helpers.yaml import default_if_true
+from shared.plan.constants import TierName
+from shared.torngit.base import TorngitBaseAdapter
+from shared.yaml import UserYaml
+
+from database.enums import notification_type_status_or_checks
+from database.models.core import GITHUB_APP_INSTALLATION_DEFAULT_NAME, Owner, Repository
+from services.comparison import ComparisonProxy
+from services.decoration import Decoration
+from services.license import is_properly_licensed
+from services.notification.commit_notifications import (
+ create_or_update_commit_notification_from_notification_result,
+)
+from services.notification.notifiers import (
+ StatusType,
+ get_all_notifier_classes_mapping,
+ get_pull_request_notifiers,
+ get_status_notifier_class,
+)
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.notification.notifiers.checks.checks_with_fallback import (
+ ChecksWithFallback,
+)
+from services.notification.notifiers.codecov_slack_app import CodecovSlackAppNotifier
+from services.notification.notifiers.mixins.status import StatusState
+from services.yaml import read_yaml_field
+from services.yaml.reader import get_components_from_yaml
+
+log = logging.getLogger(__name__)
+
+
+class IndividualResult(TypedDict):
+ notifier: str
+ title: str
+ result: NotificationResult | None
+
+
+class NotificationService(object):
+ def __init__(
+ self,
+ repository: Repository,
+ current_yaml: UserYaml,
+ repository_service: TorngitBaseAdapter,
+ decoration_type=Decoration.standard,
+ gh_installation_name_to_use: str = GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ ) -> None:
+ self.repository = repository
+ self.current_yaml = current_yaml
+ self.decoration_type = decoration_type
+ self.repository_service = repository_service
+ self.gh_installation_name_to_use = gh_installation_name_to_use
+ self.plan = None # used for caching the plan / tier information
+
+ def _should_use_status_notifier(self, status_type: StatusType) -> bool:
+ owner: Owner = self.repository.owner
+
+ if not self.plan:
+ self.plan = Plan.objects.select_related("tier").get(name=owner.plan)
+
+ if (
+ self.plan.tier.tier_name == TierName.TEAM.value
+ and status_type != StatusType.PATCH.value
+ ):
+ return False
+
+ return True
+
+ def _should_use_checks_notifier(self, status_type: StatusType) -> bool:
+ checks_yaml_field = read_yaml_field(self.current_yaml, ("github_checks",))
+ if checks_yaml_field is False:
+ return False
+
+ owner: Owner = self.repository.owner
+ if owner.service not in ["github", "github_enterprise"]:
+ return False
+
+ if not self.plan:
+ self.plan = Plan.objects.select_related("tier").get(name=owner.plan)
+
+ if (
+ self.plan.tier.tier_name == TierName.TEAM.value
+ and status_type != StatusType.PATCH.value
+ ):
+ return False
+
+ app_installation_filter = filter(
+ lambda obj: (
+ obj.name == self.gh_installation_name_to_use and obj.is_configured()
+ ),
+ owner.github_app_installations or [],
+ )
+ # filter is an Iterator, so we need to scan matches
+ for app_installation in app_installation_filter:
+ if app_installation.is_repo_covered_by_integration(self.repository):
+ return True
+ # DEPRECATED FLOW
+ return (
+ self.repository.using_integration
+ and self.repository.owner.integration_id
+ and (self.repository.owner.service in ["github", "github_enterprise"])
+ )
+
+ def _use_status_and_possibly_checks_notifiers(
+ self,
+ key: StatusType,
+ title: str,
+ status_config: dict,
+ ) -> AbstractBaseNotifier:
+ status_notifier_class = get_status_notifier_class(key, "status")
+ if self._should_use_checks_notifier(status_type=key):
+ checks_notifier = get_status_notifier_class(key, "checks")
+ return ChecksWithFallback(
+ checks_notifier=checks_notifier(
+ repository=self.repository,
+ title=title,
+ notifier_yaml_settings=status_config,
+ notifier_site_settings={},
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ ),
+ status_notifier=status_notifier_class(
+ repository=self.repository,
+ title=title,
+ notifier_yaml_settings=status_config,
+ notifier_site_settings={},
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ ),
+ )
+ else:
+ return status_notifier_class(
+ repository=self.repository,
+ title=title,
+ notifier_yaml_settings=status_config,
+ notifier_site_settings={},
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ )
+
+ def get_notifiers_instances(self) -> Iterator[AbstractBaseNotifier]:
+ mapping = get_all_notifier_classes_mapping()
+ yaml_field = read_yaml_field(self.current_yaml, ("coverage", "notify"))
+ if yaml_field is not None:
+ for instance_type, instance_configs in yaml_field.items():
+ class_to_use = mapping.get(instance_type)
+ if not class_to_use:
+ continue
+ for title, individual_config in instance_configs.items():
+ yield class_to_use(
+ repository=self.repository,
+ title=title,
+ notifier_yaml_settings=individual_config,
+ notifier_site_settings=get_config(
+ "services", "notifications", instance_type, default=True
+ ),
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ )
+
+ current_flags = [rf.flag_name for rf in self.repository.flags if not rf.deleted]
+ for key, title, status_config in self.get_statuses(current_flags):
+ if self._should_use_status_notifier(status_type=key):
+ yield self._use_status_and_possibly_checks_notifiers(
+ key=key,
+ title=title,
+ status_config=status_config,
+ )
+
+ # yield notifier if slack_app field is True, nonexistent, or a non-empty dict
+ slack_app_yaml_field = get_config(
+ "setup", "slack_app", default=True
+ ) and read_yaml_field(self.current_yaml, ("slack_app",), True)
+ if slack_app_yaml_field:
+ yield CodecovSlackAppNotifier(
+ repository=self.repository,
+ title="codecov-slack-app",
+ notifier_yaml_settings=slack_app_yaml_field,
+ notifier_site_settings={},
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ )
+
+ comment_yaml_field = read_yaml_field(self.current_yaml, ("comment",))
+ if comment_yaml_field:
+ for pull_notifier_class in get_pull_request_notifiers():
+ yield pull_notifier_class(
+ repository=self.repository,
+ title="comment",
+ notifier_yaml_settings=comment_yaml_field,
+ notifier_site_settings={},
+ current_yaml=self.current_yaml,
+ repository_service=self.repository_service,
+ decoration_type=self.decoration_type,
+ )
+
+ def _get_component_statuses(self, current_flags: List[str]):
+ all_components = get_components_from_yaml(self.current_yaml)
+ for component in all_components:
+ for status in component.statuses:
+ if not status.get(
+ "enabled", True
+ ): # All defined statuses enabled by default
+ continue
+ n_st = {
+ "flags": component.get_matching_flags(current_flags),
+ "paths": component.paths,
+ **status,
+ }
+ yield (
+ status["type"],
+ f"{status.get('name_prefix', '')}{component.get_display_name()}",
+ n_st,
+ )
+
+ def get_statuses(self, current_flags: List[str]):
+ status_fields = read_yaml_field(self.current_yaml, ("coverage", "status"))
+ # Default statuses
+ if status_fields:
+ for key, value in status_fields.items():
+ if key in ["patch", "project", "changes"]:
+ for title, status_config in default_if_true(value):
+ yield (key, title, status_config)
+ # Flag based statuses
+ for f_name in current_flags:
+ flag_configuration = self.current_yaml.get_flag_configuration(f_name)
+ if flag_configuration and flag_configuration.get("enabled", True):
+ for st in flag_configuration.get("statuses", []):
+ n_st = {"flags": [f_name], **st}
+ yield (st["type"], f"{st.get('name_prefix', '')}{f_name}", n_st)
+ # Component based statuses
+ for component_status in self._get_component_statuses(current_flags):
+ yield component_status
+
+ def notify(self, comparison: ComparisonProxy) -> list[IndividualResult]:
+ if not is_properly_licensed(comparison.head.commit.get_db_session()):
+ log.warning(
+ "Not sending notifications because the system is not properly licensed"
+ )
+ return []
+ log.debug(
+ f"Notifying with decoration type {self.decoration_type}",
+ extra=dict(
+ head_commit=comparison.head.commit.commitid,
+ base_commit=(
+ comparison.project_coverage_base.commit.commitid
+ if comparison.project_coverage_base.commit is not None
+ else "NO_BASE"
+ ),
+ repoid=comparison.head.commit.repoid,
+ ),
+ )
+
+ status_or_checks_notifiers, all_other_notifiers = split_notifiers(
+ notifier
+ for notifier in self.get_notifiers_instances()
+ if notifier.is_enabled()
+ )
+
+ results = [
+ self.notify_individual_notifier(notifier, comparison)
+ for notifier in status_or_checks_notifiers
+ ]
+
+ status_or_checks_helper_text = {}
+ if results and all_other_notifiers:
+ # if the status/check fails, sometimes we want to add helper text to the message of the other notifications,
+ # to better surface that the status/check failed.
+ # so if there are status_and_checks_notifiers and all_other_notifiers, do the status_and_checks_notifiers first,
+ # look at the results of the checks, if any failed AND they are the type we have helper text for,
+ # add that text onto the other notifiers messages through status_or_checks_helper_text.
+ for _notifier, result in results:
+ if result is not None and result.data_sent is not None:
+ if (
+ result.data_sent.get("state") == StatusState.failure.value
+ ) and result.data_sent.get("included_helper_text"):
+ status_or_checks_helper_text.update(
+ result.data_sent["included_helper_text"]
+ )
+
+ results.extend(
+ self.notify_individual_notifier(
+ notifier,
+ comparison,
+ status_or_checks_helper_text=status_or_checks_helper_text,
+ )
+ for notifier in all_other_notifiers
+ )
+
+ return [
+ IndividualResult(
+ notifier=notifier.name, title=notifier.title, result=result
+ )
+ for notifier, result in results
+ ]
+
+ def notify_individual_notifier(
+ self,
+ notifier: AbstractBaseNotifier,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> tuple[AbstractBaseNotifier, NotificationResult | None]:
+ commit = comparison.head.commit
+ base_commit = comparison.project_coverage_base.commit
+ log_extra = {
+ "commit": commit.commitid,
+ "base_commit": base_commit.commitid
+ if base_commit is not None
+ else "NO_BASE",
+ "repoid": commit.repoid,
+ "notifier": notifier.name,
+ "notifier_title": notifier.title,
+ }
+
+ log.info("Attempting individual notification", extra=log_extra)
+ res: NotificationResult | None = None
+ try:
+ res = notifier.notify(
+ comparison, status_or_checks_helper_text=status_or_checks_helper_text
+ )
+ log_extra["result"] = res
+
+ # TODO: The `CommentNotifier` is the only one implementing this method,
+ # so maybe we should just move it there
+ notifier.store_results(comparison, res)
+
+ log.info("Individual notification done", extra=log_extra)
+ return notifier, res
+ except (CeleryError, SoftTimeLimitExceeded):
+ raise
+ except Exception:
+ log.exception("Individual notifier failed", extra=log_extra)
+ return notifier, res
+ finally:
+ if res is None or res.notification_attempted:
+ # only running if there is no result (indicating some exception)
+ # or there was an actual attempt
+ create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, res
+ )
+
+
+def split_notifiers(
+ notifiers: Iterator[AbstractBaseNotifier],
+) -> tuple[list[AbstractBaseNotifier], list[AbstractBaseNotifier]]:
+ "Splits the input notifiers based on whether they are a status/check, or other notifier."
+
+ status_or_checks_notifiers = []
+ all_other_notifiers = []
+
+ for notifier in notifiers:
+ if notifier.notification_type in notification_type_status_or_checks:
+ status_or_checks_notifiers.append(notifier)
+ else:
+ all_other_notifiers.append(notifier)
+
+ return status_or_checks_notifiers, all_other_notifiers
diff --git a/apps/worker/services/notification/commit_notifications.py b/apps/worker/services/notification/commit_notifications.py
new file mode 100644
index 0000000000..75efc3adcd
--- /dev/null
+++ b/apps/worker/services/notification/commit_notifications.py
@@ -0,0 +1,76 @@
+import logging
+
+from sqlalchemy.orm.session import Session
+
+from database.enums import NotificationState
+from database.models import CommitNotification, Pull
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+
+log = logging.getLogger(__name__)
+
+
+def create_or_update_commit_notification_from_notification_result(
+ comparison: ComparisonProxy,
+ notifier: AbstractBaseNotifier,
+ notification_result: NotificationResult | None,
+) -> CommitNotification | None:
+ """Saves a CommitNotification entry in the database.
+ We save an entry in the following scenarios:
+ - We save all notification attempts for commits that are part of a PullRequest
+ - We save _successful_ notification attempt _with_ a github app
+ """
+ pull: Pull | None = comparison.pull
+ not_pull = pull is None
+ not_head_commit = comparison.head is None or comparison.head.commit is None
+ not_github_app_info = (
+ notification_result is None or notification_result.github_app_used is None
+ )
+ failed = (
+ notification_result is None
+ or notification_result.notification_successful == False
+ )
+ if not_pull and (not_head_commit or not_github_app_info or failed):
+ return None
+
+ commit = pull.get_head_commit() if pull else comparison.head.commit
+ if not commit:
+ log.warning("Head commit not found for pull", extra=dict(pull=pull))
+ return None
+
+ db_session: Session = commit.get_db_session()
+
+ commit_notification = (
+ db_session.query(CommitNotification)
+ .filter(
+ CommitNotification.commit_id == commit.id_,
+ CommitNotification.notification_type == notifier.notification_type,
+ )
+ .first()
+ )
+
+ notification_state = (
+ NotificationState.error if failed else NotificationState.success
+ )
+ github_app_used = (
+ notification_result.github_app_used if notification_result else None
+ )
+
+ if not commit_notification:
+ commit_notification = CommitNotification(
+ commit_id=commit.id_,
+ notification_type=notifier.notification_type,
+ decoration_type=notifier.decoration_type,
+ gh_app_id=github_app_used,
+ state=notification_state,
+ )
+ db_session.add(commit_notification)
+ db_session.flush()
+ return commit_notification
+
+ commit_notification.decoration_type = notifier.decoration_type
+ commit_notification.state = notification_state
+ return commit_notification
diff --git a/apps/worker/services/notification/notifiers/__init__.py b/apps/worker/services/notification/notifiers/__init__.py
new file mode 100644
index 0000000000..ecc2e932b3
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/__init__.py
@@ -0,0 +1,57 @@
+from enum import Enum
+from typing import Dict, List, Type
+
+from services.notification.notifiers.base import AbstractBaseNotifier
+from services.notification.notifiers.checks import (
+ ChangesChecksNotifier,
+ PatchChecksNotifier,
+ ProjectChecksNotifier,
+)
+from services.notification.notifiers.comment import CommentNotifier
+from services.notification.notifiers.gitter import GitterNotifier
+from services.notification.notifiers.hipchat import HipchatNotifier
+from services.notification.notifiers.irc import IRCNotifier
+from services.notification.notifiers.slack import SlackNotifier
+from services.notification.notifiers.status import (
+ ChangesStatusNotifier,
+ PatchStatusNotifier,
+ ProjectStatusNotifier,
+)
+from services.notification.notifiers.webhook import WebhookNotifier
+
+
+def get_all_notifier_classes_mapping() -> Dict[str, Type[AbstractBaseNotifier]]:
+ return {
+ "gitter": GitterNotifier,
+ "hipchat": HipchatNotifier,
+ "irc": IRCNotifier,
+ "slack": SlackNotifier,
+ "webhook": WebhookNotifier,
+ }
+
+
+class StatusType(Enum):
+ PATCH = "patch"
+ PROJECT = "project"
+ CHANGES = "changes"
+
+
+def get_status_notifier_class(
+ status_type: str, class_type: str = "status"
+) -> Type[AbstractBaseNotifier]:
+ if status_type == StatusType.PATCH.value and class_type == "checks":
+ return PatchChecksNotifier
+ if status_type == StatusType.PROJECT.value and class_type == "checks":
+ return ProjectChecksNotifier
+ if status_type == StatusType.CHANGES.value and class_type == "checks":
+ return ChangesChecksNotifier
+ if status_type == StatusType.PATCH.value and class_type == "status":
+ return PatchStatusNotifier
+ if status_type == StatusType.PROJECT.value and class_type == "status":
+ return ProjectStatusNotifier
+ if status_type == StatusType.CHANGES.value and class_type == "status":
+ return ChangesStatusNotifier
+
+
+def get_pull_request_notifiers() -> List[Type[AbstractBaseNotifier]]:
+ return [CommentNotifier]
diff --git a/apps/worker/services/notification/notifiers/base.py b/apps/worker/services/notification/notifiers/base.py
new file mode 100644
index 0000000000..19af03a070
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/base.py
@@ -0,0 +1,119 @@
+import logging
+from dataclasses import dataclass
+from typing import Any, Mapping, Optional
+
+from shared.torngit.base import TorngitBaseAdapter
+from shared.yaml import UserYaml
+
+from database.models import Repository
+from services.comparison import ComparisonProxy
+from services.decoration import Decoration
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class NotificationResult(object):
+ notification_attempted: bool = False
+ notification_successful: bool = False
+ explanation: str | None = None
+ data_sent: Mapping[str, Any] | None = None
+ data_received: Mapping[str, Any] | None = None
+ github_app_used: int | None = None
+
+ def merge(self, other: "NotificationResult") -> "NotificationResult":
+ ans = NotificationResult()
+ ans.notification_attempted = bool(
+ self.notification_attempted or other.notification_attempted
+ )
+ ans.notification_successful = bool(
+ self.notification_successful or other.notification_successful
+ )
+ ans.explanation = self.explanation or other.explanation
+ ans.data_sent = self.data_sent or other.data_sent
+ ans.data_received = self.data_received or other.data_received
+ return ans
+
+
+class AbstractBaseNotifier(object):
+ """
+ Base Notifier, abstract class that should not be used
+
+ This class has the core ideas of a notifier that has the structure:
+
+ notifications:
+ :
+ ...
+
+ """
+
+ def __init__(
+ self,
+ repository: Repository,
+ title: str,
+ notifier_yaml_settings: Mapping[str, Any],
+ notifier_site_settings: Mapping[str, Any],
+ current_yaml: UserYaml,
+ repository_service: TorngitBaseAdapter,
+ decoration_type: Decoration | None = None,
+ ):
+ """
+ :param repository: The repository notifications are being sent to.
+ :param title: The project name for this notification, if applicable. For more info see https://docs.codecov.io/docs/commit-status#splitting-up-projects-example
+ :param notifier_yaml_settings: Contains the codecov yaml fields, if any, for this particular notification.
+ example: status -> patch -> custom_project_name ->
+ :param notifier_site_settings: Contains the codecov yaml fields under the "notify" header
+ :param current_yaml: The complete codecov yaml used for this notification.
+ :param decoration_type: Indicates whether the user needs to upgrade their account before they can see the notification
+ """
+ self.repository = repository
+ self.title = title
+ self.notifier_yaml_settings = notifier_yaml_settings
+ self.site_settings = notifier_site_settings
+ self.current_yaml = current_yaml
+ self.decoration_type = decoration_type
+ self.repository_service = repository_service
+
+ @property
+ def name(self) -> str:
+ raise NotImplementedError()
+
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ raise NotImplementedError()
+
+ def is_enabled(self) -> bool:
+ raise NotImplementedError()
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ """
+ This function stores the result in the notification wherever it needs to be saved
+ This is the only function in this class allowed to have side-effects in the database
+
+ Args:
+ comparison (Comparison): The comparison with which this notify ran
+ result (NotificationResult): The results of the notification
+ """
+ raise NotImplementedError()
+
+ def should_use_upgrade_decoration(self) -> bool:
+ return self.decoration_type == Decoration.upgrade
+
+ def should_use_upload_limit_decoration(self) -> bool:
+ return self.decoration_type == Decoration.upload_limit
+
+ def is_passing_empty_upload(self) -> bool:
+ return self.decoration_type == Decoration.passing_empty_upload
+
+ def is_failing_empty_upload(self) -> bool:
+ return self.decoration_type == Decoration.failing_empty_upload
+
+ def is_empty_upload(self) -> bool:
+ return self.is_passing_empty_upload() or self.is_failing_empty_upload()
+
+ def is_processing_upload(self) -> bool:
+ return self.decoration_type == Decoration.processing_upload
diff --git a/apps/worker/services/notification/notifiers/checks/__init__.py b/apps/worker/services/notification/notifiers/checks/__init__.py
new file mode 100644
index 0000000000..71d313d2b6
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/__init__.py
@@ -0,0 +1,4 @@
+# ruff: noqa: F401
+from services.notification.notifiers.checks.changes import ChangesChecksNotifier
+from services.notification.notifiers.checks.patch import PatchChecksNotifier
+from services.notification.notifiers.checks.project import ProjectChecksNotifier
diff --git a/apps/worker/services/notification/notifiers/checks/base.py b/apps/worker/services/notification/notifiers/checks/base.py
new file mode 100644
index 0000000000..08ccd0b96f
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/base.py
@@ -0,0 +1,469 @@
+import logging
+from contextlib import nullcontext
+from typing import Any, Optional, TypedDict
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.torngit.exceptions import TorngitClientError, TorngitError
+
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.mixins.status import StatusState
+from services.notification.notifiers.status.base import StatusNotifier
+from services.urls import (
+ append_tracking_params_to_urls,
+ get_commit_url,
+ get_members_url,
+ get_pull_url,
+)
+from services.yaml.reader import get_paths_from_flags
+
+log = logging.getLogger(__name__)
+
+
+class CheckOutput(TypedDict):
+ title: str
+ summary: str
+ annotations: list[Any]
+ text: Optional[str]
+
+
+class CheckResult(TypedDict):
+ state: StatusState
+ output: CheckOutput
+ included_helper_text: dict[str, str]
+
+
+class ChecksNotifier(StatusNotifier):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._repository_service = None
+
+ ANNOTATIONS_PER_REQUEST = 50
+
+ def is_enabled(self) -> bool:
+ return True
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pass
+
+ @property
+ def name(self):
+ return f"checks-{self.context}"
+
+ def get_notifier_filters(self) -> dict:
+ flag_list = self.notifier_yaml_settings.get("flags") or []
+ return dict(
+ path_patterns=set(
+ get_paths_from_flags(self.current_yaml, flag_list)
+ + (self.notifier_yaml_settings.get("paths") or [])
+ ),
+ flags=flag_list,
+ )
+
+ def get_upgrade_message(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> str:
+ db_pull = comparison.enriched_pull.database_pull
+ links = {"members_url": get_members_url(db_pull)}
+ author_username = comparison.enriched_pull.provider_pull["author"].get(
+ "username"
+ )
+ return "\n".join(
+ [
+ f"The author of this PR, {author_username}, is not an activated member of this organization on Codecov.",
+ f"Please [activate this user on Codecov]({links['members_url']}) to display a detailed status check.",
+ "Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.",
+ "Please don't hesitate to email us at support@codecov.io with any questions.",
+ ]
+ )
+
+ def paginate_annotations(self, annotations):
+ for i in range(0, len(annotations), self.ANNOTATIONS_PER_REQUEST):
+ yield annotations[i : i + self.ANNOTATIONS_PER_REQUEST]
+
+ def build_payload(self, comparison: ComparisonProxy | FilteredComparison) -> dict:
+ raise NotImplementedError()
+
+ def get_status_external_name(self) -> str:
+ status_piece = f"/{self.title}" if self.title != "default" else ""
+ return f"codecov/{self.context}{status_piece}"
+
+ @sentry_sdk.trace
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ if comparison.pull is None or ():
+ log.debug(
+ "Falling back to commit_status: Not a pull request",
+ extra=dict(
+ notifier=self.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self.title,
+ commit=comparison.head.commit,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="no_pull_request",
+ data_sent=None,
+ data_received=None,
+ )
+ if (
+ comparison.enriched_pull is None
+ or comparison.enriched_pull.provider_pull is None
+ ):
+ log.debug(
+ "Falling back to commit_status: Pull request not in provider",
+ extra=dict(
+ notifier=self.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self.title,
+ commit=comparison.head.commit,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="pull_request_not_in_provider",
+ data_sent=None,
+ data_received=None,
+ )
+ if comparison.pull.state != "open":
+ log.debug(
+ "Falling back to commit_status: Pull request closed",
+ extra=dict(
+ notifier=self.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self.title,
+ commit=comparison.head.commit,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="pull_request_closed",
+ data_sent=None,
+ data_received=None,
+ )
+ # Check branches config for this status before sending the check
+ if not self.can_we_set_this_status(comparison):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="not_fit_criteria",
+ data_sent=None,
+ )
+ if not self.required_builds(comparison):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="need_more_builds",
+ data_sent=None,
+ data_received=None,
+ )
+ # Check for existing statuses for this commit. If so, retain
+ # statuses and don't do a check as well
+ statuses = comparison.get_existing_statuses()
+ status_title = self.get_status_external_name()
+ if statuses and statuses.get(status_title):
+ log.debug(
+ "Falling back to commit_status: Status already exists for this commit",
+ extra=dict(
+ notifier=self.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self.title,
+ status_title=status_title,
+ commit=comparison.head.commit,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="preexisting_commit_status",
+ data_sent=None,
+ data_received=None,
+ )
+ payload = None
+ try:
+ with nullcontext():
+ # If flag coverage wasn't uploaded, apply the appropriate behavior
+ flag_coverage_not_uploaded_behavior = (
+ self.determine_status_check_behavior_to_apply(
+ comparison, "flag_coverage_not_uploaded_behavior"
+ )
+ )
+ if not comparison.has_head_report():
+ payload = self.build_payload(comparison)
+ elif (
+ flag_coverage_not_uploaded_behavior == "exclude"
+ and not self.flag_coverage_was_uploaded(comparison)
+ ):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="exclude_flag_coverage_not_uploaded_checks",
+ data_sent=None,
+ data_received=None,
+ )
+ elif (
+ flag_coverage_not_uploaded_behavior == "pass"
+ and not self.flag_coverage_was_uploaded(comparison)
+ ):
+ filtered_comparison = comparison.get_filtered_comparison(
+ **self.get_notifier_filters()
+ )
+ payload = self.build_payload(filtered_comparison)
+ payload["state"] = "success"
+ payload["output"]["summary"] = (
+ payload.get("output", {}).get("summary", "")
+ + " [Auto passed due to carriedforward or missing coverage]"
+ )
+ else:
+ filtered_comparison = comparison.get_filtered_comparison(
+ **self.get_notifier_filters()
+ )
+ payload = self.build_payload(filtered_comparison)
+ if comparison.pull:
+ payload["url"] = get_pull_url(comparison.pull)
+ else:
+ payload["url"] = get_commit_url(comparison.head.commit)
+ return self.maybe_send_notification(comparison, payload)
+ except TorngitClientError as e:
+ if e.code == 403:
+ raise e
+ log.warning(
+ "Unable to send checks notification to user due to a client-side error",
+ exc_info=True,
+ extra=dict(
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ notifier_name=self.name,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="client_side_error_provider",
+ data_sent=payload,
+ )
+ except TorngitError:
+ log.warning(
+ "Unable to send checks notification to user due to an unexpected error",
+ exc_info=True,
+ extra=dict(
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ notifier_name=self.name,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="server_side_error_provider",
+ data_sent=payload,
+ )
+
+ def get_line_diff(self, file_diff):
+ """
+ This method traverses a git file diff and returns the lines (as line numbers) that where chnaged
+ Note: For now it only looks for line additions on diff, we can quickly add functionality to handle
+ line deletions if needed
+
+ Parameters:
+ file_diff: file diff returned by repository.get_compare method.
+ structure:
+ {
+ "type": (str),
+ "path": (str),
+ "segments": [
+ {
+ "header": [
+ base reference offset,
+ number of lines in file-segment before changes applied,
+ head reference offset,
+ number of lines in file-segment after changes applied
+ ],
+ "lines": [ # line values for lines in the diff
+ "+this is an added line",
+ "-this is a removed line",
+ "this line is unchanged in the diff",
+ ...
+ ]
+ }
+ ]
+ }
+
+ """
+ segments = file_diff["segments"]
+ if len(segments) <= 0:
+ return None
+
+ lines_diff = []
+ for segment in segments:
+ header = segment["header"].copy()
+ lines = segment["lines"].copy()
+ base_ln = int(header[0])
+ head_ln = int(header[2])
+ while not len(lines) == 0:
+ line_value = lines.pop(0)
+ if line_value and line_value[0] == "+":
+ lines_diff.append({"head_line": head_ln})
+ head_ln += 1
+ elif line_value and line_value[0] == "-":
+ base_ln += 1
+ else:
+ head_ln += 1
+ base_ln += 1
+ file_diff["additions"] = lines_diff
+ return file_diff
+
+ def get_codecov_pr_link(self, comparison: ComparisonProxy | FilteredComparison):
+ return f"[View this Pull Request on Codecov]({get_pull_url(comparison.pull)}?dropdown=coverage&src=pr&el=h1)"
+
+ def get_lines_to_annotate(self, comparison: ComparisonProxy, files_with_change):
+ lines_diff = []
+ for _file in files_with_change:
+ if _file is None:
+ continue
+ head_file_report = comparison.head.report.get(_file["path"])
+ for head_line in head_file_report.lines:
+ line_addition = next(
+ (
+ line
+ for line in _file["additions"]
+ if head_line[0] == line["head_line"]
+ ),
+ None,
+ )
+ if line_addition and head_line[1].coverage == 0:
+ lines_diff.append(
+ {
+ "type": "new_line",
+ "line": head_line[0],
+ "coverage": head_line[1].coverage,
+ "path": _file["path"],
+ }
+ )
+ line_headers = []
+ previous_line = {}
+ for index, line in enumerate(lines_diff):
+ if index == 0:
+ line_headers.append(line)
+ elif line["line"] != previous_line["line"] + 1:
+ line_headers[len(line_headers) - 1]["end_line"] = previous_line["line"]
+ line_headers.append(line)
+ if index == len(lines_diff) - 1:
+ line_headers[len(line_headers) - 1]["end_line"] = line["line"]
+ previous_line = line
+ return line_headers
+
+ def create_annotations(
+ self, comparison: ComparisonProxy | FilteredComparison, diff
+ ):
+ files_with_change = [
+ {"type": _diff["type"], "path": path, "segments": _diff["segments"]}
+ for path, _diff in (diff["files"] if diff else {}).items()
+ if _diff.get("totals")
+ ]
+ file_additions = [self.get_line_diff(_file) for _file in files_with_change]
+ lines_to_annotate = self.get_lines_to_annotate(comparison, file_additions)
+ annotations = []
+ for line in lines_to_annotate:
+ annotation = {
+ "path": line["path"],
+ "start_line": line["line"],
+ "end_line": line["end_line"],
+ "annotation_level": "warning",
+ "message": (
+ "Added lines #L{} - L{} were not covered by tests".format(
+ line["line"], line["end_line"]
+ )
+ if line["line"] != line["end_line"]
+ else "Added line #L{} was not covered by tests".format(line["line"])
+ ),
+ }
+ annotations.append(annotation)
+ return annotations
+
+ def send_notification(self, comparison: ComparisonProxy, payload):
+ repository_service = self.repository_service
+ title = self.get_status_external_name()
+ head = comparison.head.commit
+ state = payload["state"]
+ state = "success" if self.notifier_yaml_settings.get("informational") else state
+
+ # Append tracking parameters to any codecov urls in the title or summary
+ output = payload.get("output", {})
+ if output.get("title"):
+ output["title"] = append_tracking_params_to_urls(
+ output["title"],
+ service=self.repository.service,
+ notification_type="checks",
+ org_name=self.repository.owner.name,
+ )
+ if output.get("summary"):
+ output["summary"] = append_tracking_params_to_urls(
+ output["summary"],
+ service=self.repository.service,
+ notification_type="checks",
+ org_name=self.repository.owner.name,
+ )
+ if output.get("text"):
+ output["text"] = append_tracking_params_to_urls(
+ output["text"],
+ service=self.repository.service,
+ notification_type="checks",
+ org_name=self.repository.owner.name,
+ )
+ if payload.get("url"):
+ payload["url"] = append_tracking_params_to_urls(
+ payload["url"],
+ service=self.repository.service,
+ notification_type="checks",
+ org_name=self.repository.owner.name,
+ )
+
+ # We need to first create the check run, get that id and update the status
+ check_id = async_to_sync(repository_service.create_check_run)(
+ check_name=title, head_sha=head.commitid
+ )
+
+ if len(output.get("annotations", [])) > self.ANNOTATIONS_PER_REQUEST:
+ annotation_pages = list(
+ self.paginate_annotations(output.get("annotations"))
+ )
+ log.info(
+ "Paginating annotations",
+ extra=dict(
+ number_pages=len(annotation_pages),
+ number_annotations=len(output.get("annotations")),
+ ),
+ )
+ for annotation_page in annotation_pages:
+ async_to_sync(repository_service.update_check_run)(
+ check_id,
+ state,
+ output={
+ "title": output.get("title"),
+ "summary": output.get("summary"),
+ "annotations": annotation_page,
+ },
+ url=payload.get("url"),
+ )
+
+ else:
+ async_to_sync(repository_service.update_check_run)(
+ check_id, state, output=output, url=payload.get("url")
+ )
+
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent=payload,
+ github_app_used=self.get_github_app_used(),
+ )
diff --git a/apps/worker/services/notification/notifiers/checks/changes.py b/apps/worker/services/notification/notifiers/checks/changes.py
new file mode 100644
index 0000000000..0c5ebde45a
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/changes.py
@@ -0,0 +1,55 @@
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.checks.base import (
+ CheckOutput,
+ CheckResult,
+ ChecksNotifier,
+)
+from services.notification.notifiers.mixins.status import StatusChangesMixin
+
+
+class ChangesChecksNotifier(StatusChangesMixin, ChecksNotifier):
+ context = "changes"
+ notification_type_display_name = "check"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.checks_changes
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> CheckResult:
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ return CheckResult(
+ state=state,
+ output=CheckOutput(
+ title="Empty Upload",
+ summary=message,
+ annotations=[],
+ ),
+ included_helper_text={},
+ )
+
+ status_result = self.get_changes_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ codecov_link = self.get_codecov_pr_link(comparison)
+
+ title = status_result["message"]
+ message = status_result["message"]
+
+ should_use_upgrade = self.should_use_upgrade_decoration()
+ if should_use_upgrade:
+ message = self.get_upgrade_message(comparison)
+ title = "Codecov Report"
+
+ return CheckResult(
+ state=status_result["state"],
+ output=CheckOutput(
+ title=title,
+ summary="\n\n".join([codecov_link, message]),
+ annotations=[],
+ ),
+ included_helper_text={},
+ )
diff --git a/apps/worker/services/notification/notifiers/checks/checks_with_fallback.py b/apps/worker/services/notification/notifiers/checks/checks_with_fallback.py
new file mode 100644
index 0000000000..571b39fb8b
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/checks_with_fallback.py
@@ -0,0 +1,102 @@
+import logging
+from typing import Optional
+
+from shared.torngit.exceptions import TorngitClientError
+
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+
+log = logging.getLogger(__name__)
+
+
+class ChecksWithFallback(AbstractBaseNotifier):
+ """
+ This class attempts to notify using Github Checks and has the ability of falling back
+ to commit_status in the event of users not having enough permissons to use checks.
+
+ Note: This class is not meant to store results.
+ """
+
+ def __init__(
+ self,
+ checks_notifier: AbstractBaseNotifier,
+ status_notifier: AbstractBaseNotifier,
+ ):
+ self._checks_notifier = checks_notifier
+ self._status_notifier = status_notifier
+ self._decoration_type = checks_notifier.decoration_type
+ self._title = checks_notifier.title
+ self._name = f"{checks_notifier.name}-with-fallback"
+ self._notification_type = checks_notifier.notification_type
+
+ def is_enabled(self):
+ return self._checks_notifier.is_enabled() or self._status_notifier.is_enabled()
+
+ @property
+ def name(self):
+ return self._name
+
+ @property
+ def title(self):
+ return self._title
+
+ @property
+ def notification_type(self):
+ return self._notification_type
+
+ @property
+ def decoration_type(self):
+ return self._decoration_type
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pass
+
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ try:
+ res = self._checks_notifier.notify(
+ comparison, status_or_checks_helper_text=status_or_checks_helper_text
+ )
+ if not res.notification_successful and (
+ res.explanation == "no_pull_request"
+ or res.explanation == "pull_request_not_in_provider"
+ or res.explanation == "pull_request_closed"
+ or res.explanation == "preexisting_commit_status"
+ ):
+ log.info(
+ "Couldn't use checks notifier, falling back to status notifiers",
+ extra=dict(
+ notifier=self._checks_notifier.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self._checks_notifier.title,
+ commit=comparison.head.commit,
+ explanation=res.explanation,
+ ),
+ )
+ res = self._status_notifier.notify(
+ comparison,
+ status_or_checks_helper_text=status_or_checks_helper_text,
+ )
+ return res
+ except TorngitClientError as e:
+ if e.code == 403:
+ log.info(
+ "Checks notifier failed due to torngit error, falling back to status notifiers",
+ extra=dict(
+ notifier=self._checks_notifier.name,
+ repoid=comparison.head.commit.repoid,
+ notifier_title=self._checks_notifier.title,
+ commit=comparison.head.commit,
+ ),
+ )
+ return self._status_notifier.notify(
+ comparison,
+ status_or_checks_helper_text=status_or_checks_helper_text,
+ )
+ raise e
diff --git a/apps/worker/services/notification/notifiers/checks/patch.py b/apps/worker/services/notification/notifiers/checks/patch.py
new file mode 100644
index 0000000000..d2e069aed2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/patch.py
@@ -0,0 +1,93 @@
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.checks.base import (
+ CheckOutput,
+ CheckResult,
+ ChecksNotifier,
+)
+from services.notification.notifiers.mixins.status import StatusPatchMixin
+from services.yaml import read_yaml_field
+
+
+class PatchChecksNotifier(StatusPatchMixin, ChecksNotifier):
+ context = "patch"
+ notification_type_display_name = "check"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.checks_patch
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> CheckResult:
+ """
+ This method build the paylod of the patch github checks.
+
+ We only add annotaions to the top-level patch check of a project.
+ We do not add annotations on checks that are used with paths/flags
+ """
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ result = CheckResult(
+ state=state,
+ output=CheckOutput(
+ title="Empty Upload",
+ summary=message,
+ annotations=[],
+ ),
+ included_helper_text={},
+ )
+ return result
+ status_result = self.get_patch_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ codecov_link = self.get_codecov_pr_link(comparison)
+
+ title = status_result["message"]
+ message = status_result["message"]
+
+ should_use_upgrade = self.should_use_upgrade_decoration()
+ if should_use_upgrade:
+ message = self.get_upgrade_message(comparison)
+ title = "Codecov Report"
+
+ checks_yaml_field = read_yaml_field(self.current_yaml, ("github_checks",))
+ try:
+ # checks_yaml_field can be dict, bool, None
+ # should_annotate defaults to False as of Jan 30 2025
+ should_annotate = checks_yaml_field.get("annotations", False)
+ except AttributeError:
+ should_annotate = False
+
+ flags = self.notifier_yaml_settings.get("flags")
+ paths = self.notifier_yaml_settings.get("paths")
+ if (
+ flags is not None
+ or paths is not None
+ or should_use_upgrade
+ or should_annotate is False
+ ):
+ result = CheckResult(
+ state=status_result["state"],
+ output=CheckOutput(
+ title=title,
+ summary="\n\n".join([codecov_link, message]),
+ annotations=[],
+ ),
+ included_helper_text=status_result["included_helper_text"],
+ )
+ return result
+ diff = comparison.get_diff(use_original_base=True)
+ # TODO: Look into why the apply diff in get_patch_status is not saving state at this point
+ comparison.head.report.apply_diff(diff)
+ annotations = self.create_annotations(comparison, diff)
+ result = CheckResult(
+ state=status_result["state"],
+ output=CheckOutput(
+ title=title,
+ summary="\n\n".join([codecov_link, message]),
+ annotations=annotations,
+ ),
+ included_helper_text=status_result["included_helper_text"],
+ )
+ return result
diff --git a/apps/worker/services/notification/notifiers/checks/project.py b/apps/worker/services/notification/notifiers/checks/project.py
new file mode 100644
index 0000000000..6dcca70c83
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/checks/project.py
@@ -0,0 +1,114 @@
+from typing import Optional
+
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.checks.base import (
+ CheckOutput,
+ CheckResult,
+ ChecksNotifier,
+)
+from services.notification.notifiers.mixins.message import MessageMixin
+from services.notification.notifiers.mixins.status import StatusProjectMixin
+from services.yaml.reader import read_yaml_field
+
+
+class ProjectChecksNotifier(MessageMixin, StatusProjectMixin, ChecksNotifier):
+ context = "project"
+ notification_type_display_name = "check"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.checks_project
+
+ def get_message(
+ self,
+ comparison: ComparisonProxy | FilteredComparison,
+ yaml_comment_settings,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ):
+ pull_dict = comparison.enriched_pull.provider_pull
+ return self.create_message(
+ comparison,
+ pull_dict,
+ yaml_comment_settings,
+ status_or_checks_helper_text=status_or_checks_helper_text,
+ )
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> CheckResult:
+ """
+ This method build the paylod of the project github checks.
+
+ We only show/add the comment message to the top-level check of a project.
+ We do not show/add the message on checks that are used with paths/flags.
+ """
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ result = CheckResult(
+ state=state,
+ output=CheckOutput(
+ title="Empty Upload", summary=message, annotations=[]
+ ),
+ included_helper_text={},
+ )
+ return result
+
+ status_result = self.get_project_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ codecov_link = self.get_codecov_pr_link(comparison)
+
+ title = status_result["message"]
+ summary = status_result["message"]
+
+ should_use_upgrade = self.should_use_upgrade_decoration()
+ if should_use_upgrade:
+ title = "Codecov Report"
+ summary = self.get_upgrade_message(comparison)
+
+ flags = self.notifier_yaml_settings.get("flags")
+ paths = self.notifier_yaml_settings.get("paths")
+ yaml_comment_settings = read_yaml_field(self.current_yaml, ("comment",)) or {}
+ if yaml_comment_settings is True:
+ yaml_comment_settings = self.site_settings.get("comment", {})
+ # copying to a new variable because we will be modifying that
+ settings_to_be_used = dict(yaml_comment_settings)
+ if "flag" in settings_to_be_used.get("layout", ""):
+ old_flags_list = settings_to_be_used.get("layout", "").split(",")
+ new_flags_list = [x for x in old_flags_list if "flag" not in x]
+ settings_to_be_used["layout"] = ",".join(new_flags_list)
+
+ if (
+ flags is not None
+ or paths is not None
+ or should_use_upgrade
+ or not settings_to_be_used
+ ):
+ result = CheckResult(
+ state=status_result["state"],
+ output=CheckOutput(
+ title=title,
+ summary="\n\n".join([codecov_link, summary]),
+ annotations=[],
+ ),
+ included_helper_text=status_result["included_helper_text"],
+ )
+ return result
+
+ message = self.get_message(
+ comparison,
+ settings_to_be_used,
+ status_or_checks_helper_text=status_result["included_helper_text"],
+ )
+ result = CheckResult(
+ state=status_result["state"],
+ output=CheckOutput(
+ title=title,
+ summary="\n\n".join([codecov_link, summary]),
+ annotations=[],
+ text="\n".join(message),
+ ),
+ included_helper_text=status_result["included_helper_text"],
+ )
+ return result
diff --git a/apps/worker/services/notification/notifiers/codecov_slack_app.py b/apps/worker/services/notification/notifiers/codecov_slack_app.py
new file mode 100644
index 0000000000..151f327b9f
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/codecov_slack_app.py
@@ -0,0 +1,141 @@
+import json
+import os
+from decimal import Decimal
+from typing import Optional
+
+import requests
+
+from database.enums import Notification
+from database.models import Commit
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.notification.notifiers.generics import EnhancedJSONEncoder
+from services.urls import get_commit_url, get_pull_url
+from services.yaml.reader import round_number
+
+CODECOV_INTERNAL_TOKEN = os.environ.get("CODECOV_INTERNAL_TOKEN")
+CODECOV_SLACK_APP_URL = os.environ.get("CODECOV_SLACK_APP_URL")
+
+
+class CodecovSlackAppNotifier(AbstractBaseNotifier):
+ name = "codecov-slack-app"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.codecov_slack_app
+
+ def is_enabled(self) -> bool:
+ # if yaml settings are a dict, then check the enabled key and return that
+ # the enabled field should always exist if the yaml settings are a dict because otherwise it would fail the validation
+
+ # else if the yaml settings is a boolean then just return that
+
+ # in any case, self.notifier_yaml_settings should either be a bool or a dict always and should never be None
+ if isinstance(self.notifier_yaml_settings, dict):
+ return self.notifier_yaml_settings.get("enabled", False)
+ elif isinstance(self.notifier_yaml_settings, bool):
+ return self.notifier_yaml_settings
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pass
+
+ def serialize_commit(self, commit: Commit):
+ if not commit:
+ return None
+ return {
+ "commitid": commit.commitid,
+ "branch": commit.branch,
+ "message": commit.message,
+ "author": commit.author.username if commit.author else None,
+ "timestamp": commit.timestamp.isoformat() if commit.timestamp else None,
+ "ci_passed": commit.ci_passed,
+ "totals": commit.totals,
+ "pull": commit.pullid,
+ }
+
+ def build_payload(self, comparison: ComparisonProxy) -> dict:
+ head_full_commit = comparison.head
+ base_full_commit = comparison.project_coverage_base
+ if comparison.has_project_coverage_base_report():
+ difference = Decimal(0)
+ head_coverage = head_full_commit.report.totals.coverage
+ base_coverage = base_full_commit.report.totals.coverage
+ if head_coverage is not None and base_coverage is not None:
+ difference = Decimal(head_coverage) - Decimal(base_coverage)
+
+ message = (
+ "no change"
+ if difference == 0
+ else "increased"
+ if difference > 0
+ else "decreased"
+ )
+ notation = "" if difference == 0 else "+" if difference > 0 else "-"
+ comparison_url = (
+ get_pull_url(comparison.pull)
+ if comparison.pull
+ else get_commit_url(comparison.head.commit)
+ )
+ else:
+ difference = None
+ message = "unknown"
+ notation = ""
+ comparison_url = None
+ head_report = comparison.head.report
+ return {
+ "url": comparison_url,
+ "message": message,
+ "coverage": str(round_number(self.current_yaml, difference))
+ if difference is not None
+ else None,
+ "notation": notation,
+ "head_commit": self.serialize_commit(
+ comparison.head.commit if comparison.head else None
+ ),
+ "base_commit": self.serialize_commit(
+ comparison.project_coverage_base.commit
+ if comparison.project_coverage_base
+ else None
+ ),
+ "head_totals_c": str(head_report.totals.coverage) if head_report else "0",
+ }
+
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ request_url = f"{CODECOV_SLACK_APP_URL}/notify"
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {CODECOV_INTERNAL_TOKEN}",
+ }
+
+ compare_dict = self.build_payload(comparison)
+ data = {
+ "repository": self.repository.name,
+ "owner": self.repository.owner.username,
+ "comparison": compare_dict,
+ }
+ response = requests.post(
+ request_url, headers=headers, data=json.dumps(data, cls=EnhancedJSONEncoder)
+ )
+
+ if response.status_code == 200:
+ return NotificationResult(
+ data_sent=data,
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="Successfully notified slack app",
+ )
+ else:
+ return NotificationResult(
+ data_sent=data,
+ notification_attempted=True,
+ notification_successful=False,
+ explanation=f"Failed to notify slack app\nError {response.status_code}: {response.reason}.",
+ )
diff --git a/apps/worker/services/notification/notifiers/comment/__init__.py b/apps/worker/services/notification/notifiers/comment/__init__.py
new file mode 100644
index 0000000000..6366786365
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/comment/__init__.py
@@ -0,0 +1,444 @@
+import logging
+from datetime import datetime, timezone
+from typing import Any, Mapping, Optional
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.metrics import Counter, inc_counter
+from shared.plan.constants import PlanName
+from shared.torngit.exceptions import (
+ TorngitClientError,
+ TorngitObjectNotFoundError,
+ TorngitServerFailureError,
+)
+
+from database.enums import Notification
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison
+from services.license import requires_license
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.notification.notifiers.comment.conditions import (
+ ComparisonHasPull,
+ HasEnoughBuilds,
+ HasEnoughRequiredChanges,
+ NoAutoActivateMessageIfAutoActivateIsOff,
+ NotifyCondition,
+ PullHeadMatchesComparisonHead,
+ PullRequestInProvider,
+)
+from services.notification.notifiers.mixins.message import MessageMixin
+from services.urls import append_tracking_params_to_urls, get_members_url, get_plan_url
+
+log = logging.getLogger(__name__)
+
+COMMENT_NOTIFIER_COUNTER = Counter(
+ "notifiers_comment_pull_closed_notifying_anyways",
+ "Number of comment notifier runs when pull is closed",
+ ["repo_using_integration"],
+)
+
+
+class CommentNotifier(MessageMixin, AbstractBaseNotifier):
+ notify_conditions: list[type[NotifyCondition]] = [
+ ComparisonHasPull,
+ PullRequestInProvider,
+ PullHeadMatchesComparisonHead,
+ HasEnoughBuilds,
+ HasEnoughRequiredChanges,
+ NoAutoActivateMessageIfAutoActivateIsOff,
+ ]
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pull = comparison.pull
+ if not result.notification_attempted or not result.notification_successful:
+ return
+ data_received = result.data_received
+ if data_received:
+ if data_received.get("id"):
+ pull.commentid = data_received.get("id")
+ elif data_received.get("deleted_comment"):
+ pull.commentid = None
+
+ @property
+ def name(self) -> str:
+ return "comment"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.comment
+
+ def get_diff(self, comparison: Comparison):
+ return comparison.get_diff()
+
+ @sentry_sdk.trace
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ # TODO: remove this when we don't need it anymore
+ # this line is measuring how often we try to comment on a PR that is closed
+ if comparison.pull is not None and comparison.pull.state != "open":
+ inc_counter(
+ COMMENT_NOTIFIER_COUNTER,
+ labels=dict(
+ repo_using_integration="true"
+ if self.repository_service.data["repo"]["using_integration"]
+ else "false",
+ ),
+ )
+
+ for condition in self.notify_conditions:
+ condition_result = condition.check_condition(
+ notifier=self, comparison=comparison
+ )
+ if condition_result == False:
+ side_effect_result = condition.on_failure_side_effect(self, comparison)
+ default_result = NotificationResult(
+ notification_attempted=False,
+ explanation=condition.failure_explanation,
+ data_sent=None,
+ data_received=None,
+ )
+ return default_result.merge(side_effect_result)
+ pull = comparison.pull
+ try:
+ message = self.build_message(
+ comparison, status_or_checks_helper_text=status_or_checks_helper_text
+ )
+ except TorngitClientError:
+ log.warning(
+ "Unable to fetch enough information to build message for comment",
+ extra=dict(
+ commit=comparison.head.commit.commitid,
+ pullid=comparison.pull.pullid,
+ ),
+ exc_info=True,
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ explanation="unable_build_message",
+ data_sent=None,
+ data_received=None,
+ )
+ data = {"message": message, "commentid": pull.commentid, "pullid": pull.pullid}
+ try:
+ return self.send_actual_notification(data)
+ except TorngitServerFailureError:
+ log.warning(
+ "Unable to send comments because the provider server was not reachable or errored",
+ extra=dict(git_service=self.repository.service),
+ exc_info=True,
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="provider_issue",
+ data_sent=data,
+ data_received=None,
+ )
+
+ def send_actual_notification(self, data: Mapping[str, Any]):
+ message = "\n".join(data["message"])
+
+ # Append tracking parameters to any codecov urls in the message
+ message = append_tracking_params_to_urls(
+ message,
+ service=self.repository.service,
+ notification_type="comment",
+ org_name=self.repository.owner.name,
+ )
+
+ behavior = self.notifier_yaml_settings.get("behavior", "default")
+ if behavior == "default":
+ res = self.send_comment_default_behavior(
+ data["pullid"], data["commentid"], message
+ )
+ elif behavior == "once":
+ res = self.send_comment_once_behavior(
+ data["pullid"], data["commentid"], message
+ )
+ elif behavior == "new":
+ res = self.send_comment_new_behavior(
+ data["pullid"], data["commentid"], message
+ )
+ elif behavior == "spammy":
+ res = self.send_comment_spammy_behavior(
+ data["pullid"], data["commentid"], message
+ )
+ return NotificationResult(
+ notification_attempted=res["notification_attempted"],
+ notification_successful=res["notification_successful"],
+ explanation=res["explanation"],
+ data_sent=data,
+ data_received=res["data_received"],
+ )
+
+ def send_comment_default_behavior(self, pullid, commentid, message):
+ if commentid:
+ try:
+ res = async_to_sync(self.repository_service.edit_comment)(
+ pullid, commentid, message
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+ except TorngitObjectNotFoundError:
+ log.warning("Comment was not found to be edited")
+ except TorngitClientError:
+ log.warning(
+ "Comment could not be edited due to client permissions",
+ exc_info=True,
+ extra=dict(pullid=pullid, commentid=commentid),
+ )
+ try:
+ res = async_to_sync(self.repository_service.post_comment)(pullid, message)
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+ except TorngitClientError:
+ log.warning(
+ "Comment could not be posted due to client permissions",
+ exc_info=True,
+ extra=dict(pullid=pullid, commentid=commentid),
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "comment_posting_permissions",
+ "data_received": None,
+ }
+
+ def send_comment_once_behavior(self, pullid, commentid, message):
+ if commentid:
+ try:
+ res = async_to_sync(self.repository_service.edit_comment)(
+ pullid, commentid, message
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+ except TorngitObjectNotFoundError:
+ log.warning("Comment was not found to be edited")
+ return {
+ "notification_attempted": False,
+ "notification_successful": None,
+ "explanation": "comment_deleted",
+ "data_received": None,
+ }
+ except TorngitClientError:
+ log.warning(
+ "Comment could not be edited due to client permissions",
+ exc_info=True,
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "no_permissions",
+ "data_received": None,
+ }
+ res = async_to_sync(self.repository_service.post_comment)(pullid, message)
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+
+ def send_comment_new_behavior(self, pullid, commentid, message):
+ if commentid:
+ try:
+ async_to_sync(self.repository_service.delete_comment)(pullid, commentid)
+ except TorngitObjectNotFoundError:
+ log.info("Comment was already deleted")
+ except TorngitClientError:
+ log.warning(
+ "Comment could not be deleted due to client permissions",
+ exc_info=True,
+ extra=dict(
+ repoid=self.repository.repoid,
+ pullid=pullid,
+ commentid=commentid,
+ ),
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "no_permissions",
+ "data_received": None,
+ }
+ try:
+ res = async_to_sync(self.repository_service.post_comment)(pullid, message)
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+ except TorngitClientError:
+ log.warning(
+ "Comment could not be posted due to client permissions",
+ exc_info=True,
+ extra=dict(
+ repoid=self.repository.repoid, pullid=pullid, commentid=commentid
+ ),
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "comment_posting_permissions",
+ "data_received": None,
+ }
+
+ def send_comment_spammy_behavior(self, pullid, commentid, message):
+ res = async_to_sync(self.repository_service.post_comment)(pullid, message)
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ "data_received": {"id": res["id"]},
+ }
+
+ def is_enabled(self) -> bool:
+ return bool(self.notifier_yaml_settings) and isinstance(
+ self.notifier_yaml_settings, dict
+ )
+
+ def build_message(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> list[str]:
+ if self.should_use_upgrade_decoration():
+ return self._create_upgrade_message(comparison)
+ if self.is_processing_upload():
+ return self._create_processing_upload_message()
+ if self.is_empty_upload():
+ return self._create_empty_upload_message()
+ if self.should_use_upload_limit_decoration():
+ return self._create_reached_upload_limit_message(comparison)
+ if comparison.pull.is_first_coverage_pull:
+ return self._create_welcome_message()
+ pull_dict = comparison.enriched_pull.provider_pull
+ return self.create_message(
+ comparison,
+ pull_dict,
+ self.notifier_yaml_settings,
+ status_or_checks_helper_text=status_or_checks_helper_text,
+ )
+
+ def should_see_project_coverage_cta(self):
+ """
+ Why was this check added? We changed our default behavior on 5/1/2024.
+ Change explained on issue 1078
+ """
+ introduction_date = datetime(2024, 5, 1, 0, 0, 0).replace(tzinfo=timezone.utc)
+
+ if (
+ not self.repository.private
+ and self.repository.owner.createstamp
+ and self.repository.owner.createstamp > introduction_date
+ ):
+ # public repos, only if they signed up after introduction date
+ return True
+
+ if (
+ not (
+ self.repository.owner.plan == PlanName.TEAM_MONTHLY.value
+ or self.repository.owner.plan == PlanName.TEAM_YEARLY.value
+ )
+ and self.repository.owner.createstamp
+ and self.repository.owner.createstamp > introduction_date
+ ):
+ # private repos excluding those on team plans, only if they signed up after introduction date
+ return True
+
+ return False
+
+ def _create_welcome_message(self):
+ welcome_message = [
+ "## Welcome to [Codecov](https://codecov.io) :tada:",
+ "",
+ "Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.",
+ "",
+ "Thanks for integrating Codecov - We've got you covered :open_umbrella:",
+ ]
+ project_coverage_cta = [
+ ":information_source: You can also turn on [project coverage checks](https://docs.codecov.com/docs/common-recipe-list#set-project-coverage-checks-on-a-pull-request) "
+ "and [project coverage reporting on Pull Request comment](https://docs.codecov.com/docs/common-recipe-list#show-project-coverage-changes-on-the-pull-request-comment)",
+ "",
+ ]
+
+ if self.should_see_project_coverage_cta():
+ welcome_message_with_project_coverage_cta = (
+ welcome_message[0:4] + project_coverage_cta + welcome_message[4:]
+ )
+ return welcome_message_with_project_coverage_cta
+
+ return welcome_message
+
+ def _create_empty_upload_message(self):
+ if self.is_passing_empty_upload():
+ return [
+ "## Codecov Report",
+ ":heavy_check_mark: **No coverage data to report**, because files changed do not require tests or are set to [ignore](https://docs.codecov.com/docs/ignoring-paths#:~:text=You%20can%20use%20the%20top,will%20be%20skipped%20during%20processing.) ",
+ ]
+ if self.is_failing_empty_upload():
+ return [
+ "## Codecov Report",
+ "This is an empty upload",
+ "Files changed in this PR are testable or aren't ignored by Codecov, please run your tests and upload coverage. If you wish to ignore these files, please visit our [ignoring paths docs](https://docs.codecov.com/docs/ignoring-paths).",
+ ]
+
+ def _create_reached_upload_limit_message(self, comparison: ComparisonProxy):
+ db_pull = comparison.enriched_pull.database_pull
+ links = {"plan_url": get_plan_url(db_pull)}
+ return [
+ f"## [Codecov]({links['plan_url']}) upload limit reached :warning:",
+ f"This org is currently on the free Basic Plan; which includes 250 free private repo uploads each rolling month.\
+ This limit has been reached and additional reports cannot be generated. For unlimited uploads,\
+ upgrade to our [pro plan]({links['plan_url']}).",
+ "",
+ "**Do you have questions or need help?** Connect with our sales team today at ` sales@codecov.io `",
+ ]
+
+ def _create_upgrade_message(self, comparison: ComparisonProxy):
+ db_pull = comparison.enriched_pull.database_pull
+ links = {
+ "members_url_cloud": get_members_url(db_pull),
+ "members_url_self_hosted": get_members_url(db_pull),
+ }
+ author_username = comparison.enriched_pull.provider_pull["author"].get(
+ "username"
+ )
+ if not requires_license():
+ return [
+ f"The author of this PR, {author_username}, is not an activated member of this organization on Codecov.",
+ f"Please [activate this user on Codecov]({links['members_url_cloud']}) to display this PR comment.",
+ "Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.",
+ "Please don't hesitate to email us at support@codecov.io with any questions.",
+ ]
+ else:
+ return [
+ f"The author of this PR, {author_username}, is not activated in your Codecov Self-Hosted installation.",
+ f"Please [activate this user]({links['members_url_self_hosted']}) to display this PR comment.",
+ "Coverage data is still being uploaded to Codecov Self-Hosted for the purposes of overall coverage calculations.",
+ "Please contact your Codecov On-Premises installation administrator with any questions.",
+ ]
+
+ def _create_processing_upload_message(self):
+ return [
+ "We're currently processing your upload. This comment will be updated when the results are available.",
+ ]
diff --git a/apps/worker/services/notification/notifiers/comment/conditions.py b/apps/worker/services/notification/notifiers/comment/conditions.py
new file mode 100644
index 0000000000..017e934fde
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/comment/conditions.py
@@ -0,0 +1,226 @@
+import logging
+from abc import ABC, abstractmethod
+from decimal import Decimal
+
+from shared.validation.types import (
+ CoverageCommentRequiredChanges,
+ CoverageCommentRequiredChangesORGroup,
+)
+
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.yaml import read_yaml_field
+
+log = logging.getLogger(__name__)
+
+
+class NotifyCondition(ABC):
+ """Abstract class that defines the basis of a NotifyCondition.
+
+ NotifyCondition specifies the conditions that need to be met in order for a notification to be sent
+ from Codecov to a git provider.
+ NotifyCondition can have a side effect that is called when the condition fails.
+ """
+
+ failure_explanation: str
+
+ @staticmethod
+ @abstractmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ return True
+
+ @staticmethod
+ def on_failure_side_effect(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> NotificationResult:
+ return NotificationResult()
+
+
+class ComparisonHasPull(NotifyCondition):
+ failure_explanation = "no_pull_request"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ return comparison.pull is not None
+
+
+class PullRequestInProvider(NotifyCondition):
+ failure_explanation = "pull_request_not_in_provider"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ return (
+ comparison.enriched_pull is not None
+ and comparison.enriched_pull.provider_pull is not None
+ )
+
+
+class PullRequestOpen(NotifyCondition):
+ failure_explanation = "pull_request_closed"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ return comparison.pull.state == "open"
+
+
+class PullHeadMatchesComparisonHead(NotifyCondition):
+ failure_explanation = "pull_head_does_not_match"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ return comparison.pull.head == comparison.head.commit.commitid
+
+
+class HasEnoughBuilds(NotifyCondition):
+ failure_explanation = "not_enough_builds"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ expected_builds = notifier.notifier_yaml_settings.get("after_n_builds", 0)
+ present_builds = len(comparison.head.report.sessions)
+ return present_builds >= expected_builds
+
+
+class HasEnoughRequiredChanges(NotifyCondition):
+ failure_explanation = "changes_required"
+
+ @staticmethod
+ def _check_unexpected_changes(comparison: ComparisonProxy) -> bool:
+ """Returns a bool that indicates wether there are unexpected changes"""
+ return bool(comparison.get_changes())
+
+ @staticmethod
+ def _check_coverage_change(comparison: ComparisonProxy) -> bool:
+ """Returns a bool that indicates wether there is any change in coverage"""
+ diff = comparison.get_diff()
+ res = None if diff is None else comparison.head.report.calculate_diff(diff)
+ return res is not None and res["general"].lines > 0
+
+ @staticmethod
+ def _check_any_change(comparison: ComparisonProxy) -> bool:
+ unexpected_changes = HasEnoughRequiredChanges._check_unexpected_changes(
+ comparison
+ )
+ coverage_changes = HasEnoughRequiredChanges._check_coverage_change(comparison)
+ return unexpected_changes or coverage_changes
+
+ @staticmethod
+ def _check_coverage_drop(comparison: ComparisonProxy) -> bool:
+ no_head_coverage = comparison.head.report.totals.coverage is None
+ no_base_report = comparison.project_coverage_base.report is None
+ no_base_coverage = (
+ no_base_report
+ or comparison.project_coverage_base.report.totals.coverage is None
+ )
+ if no_head_coverage or no_base_coverage:
+ # We don't know if there was a coverage drop, because we can't compare BASE and HEAD (missing some info)
+ # But we default to showing the comment. It might have info for the user about _what_ info we are missing
+ return True
+ head_coverage = Decimal(comparison.head.report.totals.coverage).quantize(
+ Decimal("0.00000")
+ )
+ project_status_config = read_yaml_field(
+ comparison.comparison.current_yaml, ("coverage", "status", "project"), {}
+ )
+ threshold = 0
+ if isinstance(project_status_config, dict):
+ # Project status can also be a bool value, so check is needed
+ threshold = Decimal(project_status_config.get("threshold", 0))
+ target_coverage = Decimal(
+ comparison.project_coverage_base.report.totals.coverage
+ ).quantize(Decimal("0.00000"))
+ diff = head_coverage - target_coverage
+ # Need to take the project threshold into consideration
+ return diff < 0 and abs(diff) >= (threshold + Decimal(0.01))
+
+ @staticmethod
+ def _check_uncovered_patch(comparison: ComparisonProxy) -> bool:
+ diff = comparison.get_diff(use_original_base=True)
+ totals = None if diff is None else comparison.head.report.apply_diff(diff)
+ coverage_not_affected_by_patch = totals and totals.lines == 0
+ if totals is None or coverage_not_affected_by_patch:
+ # The patch doesn't affect coverage. So we don't show a comment.
+ # The patch is probably not related to testable files
+ return False
+ coverage = Decimal(totals.coverage).quantize(Decimal("0.00000"))
+ return abs(coverage - Decimal(100).quantize(Decimal("0.00000"))) >= Decimal(
+ 0.01
+ )
+
+ @staticmethod
+ def check_condition_OR_group(
+ condition_group: CoverageCommentRequiredChangesORGroup,
+ comparison: ComparisonProxy,
+ ) -> bool:
+ if condition_group == CoverageCommentRequiredChanges.no_requirements.value:
+ return True
+ cache_results = {
+ individual_condition: None
+ for individual_condition in CoverageCommentRequiredChanges
+ }
+ functions_lookup = {
+ CoverageCommentRequiredChanges.any_change: HasEnoughRequiredChanges._check_any_change,
+ CoverageCommentRequiredChanges.coverage_drop: HasEnoughRequiredChanges._check_coverage_drop,
+ CoverageCommentRequiredChanges.uncovered_patch: HasEnoughRequiredChanges._check_uncovered_patch,
+ }
+ final_result = False
+ for individual_condition in CoverageCommentRequiredChanges:
+ if condition_group & individual_condition.value:
+ if cache_results[individual_condition] is None:
+ function_to_call = functions_lookup[individual_condition]
+ cache_results[individual_condition] = function_to_call(comparison)
+ final_result |= cache_results[individual_condition]
+ return final_result
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ if comparison.pull and comparison.pull.commentid:
+ log.info(
+ "Comment already exists. Skipping required_changes verification to update comment",
+ extra=dict(pull=comparison.pull.pullid, commit=comparison.pull.head),
+ )
+ return True
+ required_changes = notifier.notifier_yaml_settings.get(
+ "require_changes", [CoverageCommentRequiredChanges.no_requirements.value]
+ )
+ # backwards compatibility (can be removed after full rollout)
+ if isinstance(required_changes, bool):
+ # True --> 1 (any_change)
+ # False --> 0 (no_requirements)
+ required_changes = [int(required_changes)]
+ return all(
+ HasEnoughRequiredChanges.check_condition_OR_group(or_group, comparison)
+ for or_group in required_changes
+ )
+
+
+class NoAutoActivateMessageIfAutoActivateIsOff(NotifyCondition):
+ failure_explanation = "auto_activate_message_but_auto_activate_is_off"
+
+ @staticmethod
+ def check_condition(
+ notifier: AbstractBaseNotifier, comparison: ComparisonProxy
+ ) -> bool:
+ owner = notifier.repository.owner
+ # Return False ONLY if (owner.plan_auto_activate is False) and should_use_upgrade_message
+ # Checking if owner.plan_auto_activate is False so None will pass (tests)
+ return (owner.plan_auto_activate != False) or (
+ not notifier.should_use_upgrade_decoration()
+ )
diff --git a/apps/worker/services/notification/notifiers/generics.py b/apps/worker/services/notification/notifiers/generics.py
new file mode 100644
index 0000000000..fef7d7f3a2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/generics.py
@@ -0,0 +1,262 @@
+import json
+import logging
+from decimal import Decimal
+from typing import Any, Mapping, Optional
+from urllib.parse import urlparse
+
+import httpx
+import sentry_sdk
+from shared.config import get_config
+
+from helpers.match import match
+from helpers.metrics import metrics
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.urls import get_commit_url, get_pull_url
+from services.yaml.reader import get_paths_from_flags, round_number
+
+log = logging.getLogger(__name__)
+
+
+class StandardNotifier(AbstractBaseNotifier):
+ """
+ This class is our standard notifier. It assumes and does the following:
+
+ - Ensure that the notifier has a valid `url` to be used
+ - Ensure that the `url` base is enabled on site-wide settings
+ - Check that the current branch is inside the list of enabled branches
+ - Filters the reports according to the given paths and flags
+ - Check that the threshold of the webhook is satisfied on this comparison
+ """
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pass
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ def is_enabled(self) -> bool:
+ if not bool(self.site_settings):
+ log.info(
+ "Not notifying on %s, because it is not enabled on site-level settings",
+ self.name,
+ )
+ return False
+ if not self.notifier_yaml_settings.get("url"):
+ log.warning("Not notifying because webhook had no url")
+ return False
+ parsed_url = urlparse(self.notifier_yaml_settings.get("url"))
+ if (
+ isinstance(self.site_settings, list)
+ and parsed_url.netloc not in self.site_settings
+ ):
+ log.warning("Not notifying because url not permitted by site settings")
+ return False
+ return True
+
+ def should_notify_comparison(self, comparison: Comparison) -> bool:
+ head_full_commit = comparison.head
+ if not match(
+ self.notifier_yaml_settings.get("branches"), head_full_commit.commit.branch
+ ):
+ log.warning(
+ "Not notifying because branch not in expected branches",
+ extra=dict(
+ commit=head_full_commit.commit.commitid,
+ repoid=head_full_commit.commit.repoid,
+ current_branch=head_full_commit.commit.branch,
+ branch_patterns=self.notifier_yaml_settings.get("branches"),
+ ),
+ )
+ return False
+ if not self.is_above_threshold(comparison):
+ return False
+ return True
+
+ @sentry_sdk.trace
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ filtered_comparison = comparison.get_filtered_comparison(
+ **self.get_notifier_filters()
+ )
+ if self.should_notify_comparison(filtered_comparison):
+ result = self.do_notify(filtered_comparison)
+ else:
+ result = NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="Did not fit criteria",
+ data_sent=None,
+ )
+ return result
+
+ def get_notifier_filters(self) -> dict:
+ flag_list = self.notifier_yaml_settings.get("flags") or []
+ return dict(
+ path_patterns=set(
+ get_paths_from_flags(self.current_yaml, flag_list)
+ + (self.notifier_yaml_settings.get("paths") or [])
+ ),
+ flags=flag_list,
+ )
+
+ def do_notify(self, comparison: Comparison) -> NotificationResult:
+ data = self.build_payload(comparison)
+ result = self.send_actual_notification(data)
+ return NotificationResult(
+ notification_attempted=result["notification_attempted"],
+ notification_successful=result["notification_successful"],
+ explanation=result["explanation"],
+ data_sent=data,
+ )
+
+ def is_above_threshold(self, comparison: Comparison):
+ head_full_commit = comparison.head
+ base_full_commit = comparison.project_coverage_base
+ threshold = self.notifier_yaml_settings.get("threshold")
+ if threshold is None:
+ return True
+ if not comparison.has_project_coverage_base_report():
+ log.info(
+ "Cannot compare commits because base commit does not have a report",
+ extra=dict(
+ commit=head_full_commit.commit.commitid,
+ base_commit=base_full_commit.commit.commitid
+ if base_full_commit.commit
+ else None,
+ ),
+ )
+ return False
+ if (
+ base_full_commit.report.totals.coverage is None
+ or head_full_commit.report.totals.coverage is None
+ ):
+ log.info(
+ "Cannot compare commits because either base or head commit has no coverage information",
+ extra=dict(
+ commit=head_full_commit.commit.commitid,
+ base_commit=base_full_commit.commit.commitid
+ if base_full_commit.commit
+ else None,
+ ),
+ )
+ return False
+ diff_coverage = Decimal(head_full_commit.report.totals.coverage) - Decimal(
+ base_full_commit.report.totals.coverage
+ )
+ rounded_coverage = round_number(self.current_yaml, diff_coverage)
+ return rounded_coverage >= threshold
+
+ def generate_compare_dict(self, comparison: Comparison):
+ head_full_commit = comparison.head
+ base_full_commit = comparison.project_coverage_base
+ if comparison.has_project_coverage_base_report():
+ difference = Decimal(head_full_commit.report.totals.coverage) - Decimal(
+ base_full_commit.report.totals.coverage
+ )
+ message = (
+ "no change"
+ if difference == 0
+ else "increased"
+ if difference > 0
+ else "decreased"
+ )
+ notation = "" if difference == 0 else "+" if difference > 0 else "-"
+ comparison_url = (
+ get_pull_url(comparison.pull)
+ if comparison.pull
+ else get_commit_url(comparison.head.commit)
+ )
+ else:
+ difference = None
+ message = "unknown"
+ notation = ""
+ comparison_url = None
+ return {
+ "url": comparison_url,
+ "message": message,
+ "coverage": round_number(self.current_yaml, difference)
+ if difference is not None
+ else None,
+ "notation": notation,
+ }
+
+ def generate_message(self, comparison: Comparison):
+ if self.notifier_yaml_settings.get("message"):
+ return self.notifier_yaml_settings.get("message")
+ commit = comparison.head.commit
+ comparison_string = ""
+ if comparison.has_project_coverage_base_report():
+ compare = self.generate_compare_dict(comparison)
+ comparison_string = self.COMPARISON_STRING.format(
+ compare_message=compare["message"],
+ compare_url=compare["url"],
+ compare_notation=compare["notation"],
+ compare_coverage=compare["coverage"],
+ )
+ return self.BASE_MESSAGE.format(
+ head_url=get_commit_url(commit),
+ owner_username=commit.repository.owner.username,
+ repo_name=commit.repository.name,
+ comparison_string=comparison_string,
+ head_branch=commit.branch,
+ head_totals_c=comparison.head.report.totals.coverage,
+ head_short_commitid=commit.commitid[:7],
+ )
+
+
+class EnhancedJSONEncoder(json.JSONEncoder):
+ def default(self, o):
+ if isinstance(o, Decimal):
+ return str(o)
+ return super().default(o)
+
+
+class RequestsYamlBasedNotifier(StandardNotifier):
+ """
+ This class is a small implementation detail for using `requests` package to communicate with
+ the server we want to notify
+ """
+
+ json_headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "User-Agent": "Codecov",
+ }
+
+ def send_actual_notification(self, data: Mapping[str, Any]):
+ _timeouts = get_config("setup", "http", "timeouts", "external", default=10)
+ kwargs = dict(timeout=_timeouts, headers=self.json_headers)
+ try:
+ with metrics.timer(
+ f"worker.services.notifications.notifiers.{self.name}.actual_connection"
+ ):
+ with httpx.Client() as client:
+ res = client.post(
+ url=self.notifier_yaml_settings["url"],
+ data=json.dumps(data, cls=EnhancedJSONEncoder),
+ **kwargs,
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": res.status_code < 400,
+ "explanation": None if res.status_code else res.message,
+ }
+ except httpx.HTTPError:
+ log.warning(
+ "Unable to send notification to server due to a connection error",
+ exc_info=True,
+ )
+ return {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "connection_issue",
+ }
diff --git a/apps/worker/services/notification/notifiers/gitter.py b/apps/worker/services/notification/notifiers/gitter.py
new file mode 100644
index 0000000000..78030cc3bb
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/gitter.py
@@ -0,0 +1,43 @@
+from shared.torngit.enums import Endpoints
+
+from database.enums import Notification
+from services.notification.notifiers.generics import (
+ Comparison,
+ RequestsYamlBasedNotifier,
+)
+from services.urls import get_commit_url
+
+
+class GitterNotifier(RequestsYamlBasedNotifier):
+ # TODO (Thiago): Fix base message
+ BASE_MESSAGE = " ".join(
+ [
+ "Coverage {comparison_string}on `{head_branch}` is `{head_totals_c}%`",
+ "via {head_url}",
+ ]
+ )
+
+ COMPARISON_STRING = "*{compare_message}* {compare_notation}{compare_coverage}% "
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.gitter
+
+ def build_payload(self, comparison: Comparison) -> dict:
+ compare_dict = self.generate_compare_dict(comparison)
+ message = self.generate_message(comparison)
+ head_commit = comparison.head.commit
+ return {
+ "message": message,
+ "branch": head_commit.branch,
+ "pr": comparison.pull.pullid if comparison.pull else None,
+ "commit": head_commit.commitid,
+ "commit_short": head_commit.commitid[:7],
+ "text": compare_dict["message"],
+ "commit_url": self.repository_service.get_href(
+ Endpoints.commit_detail, commitid=head_commit.commitid
+ ),
+ "codecov_url": get_commit_url(head_commit),
+ "coverage": comparison.head.report.totals.coverage,
+ "coverage_change": compare_dict["coverage"],
+ }
diff --git a/apps/worker/services/notification/notifiers/hipchat.py b/apps/worker/services/notification/notifiers/hipchat.py
new file mode 100644
index 0000000000..e8cb8fd595
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/hipchat.py
@@ -0,0 +1,101 @@
+from decimal import Decimal
+
+from database.enums import Notification
+from services.notification.notifiers.generics import (
+ Comparison,
+ RequestsYamlBasedNotifier,
+)
+from services.urls import get_commit_url, get_graph_url
+from services.yaml.reader import round_number
+
+
+class HipchatNotifier(RequestsYamlBasedNotifier):
+ # TODO (Thiago): Fix base message
+ BASE_MESSAGE = " ".join(
+ [
+ 'Coverage for {owner_username}/{repo_name}',
+ "{comparison_string}on {head_branch} is {head_totals_c}%",
+ '# via {head_short_commitid}',
+ ]
+ )
+
+ COMPARISON_STRING = "{compare_message} {compare_notation}{compare_coverage}% "
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.hipchat
+
+ def build_payload(self, comparison: Comparison) -> dict:
+ card = None
+ head_commit = comparison.head.commit
+ commitid = head_commit.commitid
+ repository = comparison.head.commit.repository
+ head_url = get_commit_url(comparison.head.commit)
+ comparison_dict = self.generate_compare_dict(comparison)
+ if self.notifier_yaml_settings.get("card"):
+ compare = []
+ if comparison.project_coverage_base.report is not None:
+ compare = [
+ {
+ "label": "Compare",
+ "value": {
+ "style": {"+": "lozenge-success", "-": "lozenge-error"}.get(
+ comparison_dict["notation"], "lozenge-current"
+ ),
+ "label": "{0}{1}%".format(
+ comparison_dict["notation"],
+ round_number(
+ self.current_yaml, comparison_dict["coverage"]
+ ),
+ ),
+ },
+ }
+ ]
+ card = {
+ "id": commitid,
+ "title": f"Codecov \u22c5 {repository.slug} on {head_commit.branch}",
+ "style": "application",
+ "format": "compact",
+ "url": head_url,
+ "icon": {
+ "url": get_graph_url(
+ comparison.head.commit, "sunburst.svg", size=100
+ )
+ },
+ "attributes": [
+ {
+ "label": "Author",
+ "value": {
+ "url": head_url,
+ "label": comparison.head.commit.author.username,
+ },
+ },
+ {
+ "label": "Commit",
+ "value": {"url": head_url, "label": commitid[:7]},
+ },
+ ]
+ + compare,
+ "description": {
+ "value": "Coverage for {0} on {1} is now {2}%".format(
+ repository.slug,
+ head_commit.branch,
+ round_number(
+ self.current_yaml,
+ Decimal(comparison.head.report.totals.coverage),
+ ),
+ ),
+ "format": "html",
+ },
+ }
+ message = self.generate_message(comparison)
+ return {
+ "from": "Codecov",
+ "card": card,
+ "message": message,
+ "color": {"+": "green", "-": "red"}.get(
+ comparison_dict["notation"], "gray"
+ ),
+ "notify": self.notifier_yaml_settings.get("notify", False),
+ "message_format": "html",
+ }
diff --git a/apps/worker/services/notification/notifiers/irc.py b/apps/worker/services/notification/notifiers/irc.py
new file mode 100644
index 0000000000..99836dc75b
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/irc.py
@@ -0,0 +1,91 @@
+import logging
+import socket
+from io import BytesIO
+from typing import Any, List, Mapping
+
+from database.enums import Notification
+from services.notification.notifiers.generics import Comparison, StandardNotifier
+
+log = logging.getLogger(__name__)
+
+
+class IRCClient(object):
+ def __init__(self, server) -> None:
+ self.server = server
+ server = tuple(self.server.split(":")) + (6667,)
+ self.con = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.con.settimeout(1)
+ self.con.connect((server[0], int(server[1])))
+
+ def send(self, message: str):
+ message = message + "\r\n"
+ return self.con.send(message.encode())
+
+ def close(self) -> None:
+ self.con.close()
+
+ def receive_everything(self) -> List[str]:
+ received_messages = BytesIO()
+ try:
+ while 1:
+ data = self.con.recv(1024)
+ received_messages.write(data)
+ if not data:
+ break
+ except socket.timeout:
+ log.debug("Message sent")
+ final_data = received_messages.getvalue().decode().split("\r\n")
+ for line in final_data:
+ if line.startswith("PING"):
+ message_to_respond = line.split(" ")[1]
+ self.send(f"PONG {message_to_respond}")
+ return final_data
+
+
+class IRCNotifier(StandardNotifier):
+ BASE_MESSAGE = " ".join(
+ [
+ "Coverage for {owner_username}/{repo_name}",
+ "{comparison_string}on `{head_branch}` is `{head_totals_c}%`",
+ "via `{head_short_commitid}`",
+ ]
+ )
+
+ COMPARISON_STRING = "*{compare_message}* `{compare_notation}{compare_coverage}%` "
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.irc
+
+ def send_actual_notification(self, data: Mapping[str, Any]) -> dict:
+ # https://github.com/travis-ci/travis-tasks/blob/94c97165d7ecf89d986609614667ae86dff7e9ce/lib/travis/addons/irc/client.rb
+ message = data["message"]
+ con = IRCClient(self.notifier_yaml_settings["server"])
+ if self.notifier_yaml_settings.get("password"):
+ command_to_send = "PASS %s" % self.notifier_yaml_settings["password"]
+ con.send(command_to_send)
+ con.send("USER codecov codecov codecov :codecov")
+ con.send("NICK codecov")
+ con.receive_everything()
+ if self.notifier_yaml_settings.get("nickserv_password"):
+ nickserv_password = self.notifier_yaml_settings["nickserv_password"]
+ command_to_send = f"PRIVMSG NickServ :IDENTIFY {nickserv_password}"
+ con.send(command_to_send)
+ con.receive_everything()
+ if self.notifier_yaml_settings["channel"][0] == "#":
+ command = "JOIN %s \n" % self.notifier_yaml_settings["channel"]
+ con.send(command)
+ con.receive_everything()
+
+ status = (
+ "NOTICE" if self.notifier_yaml_settings.get("notice", True) else "PRIVMSG"
+ )
+ channel = self.notifier_yaml_settings["channel"]
+ message_to_send = f"{status} {channel} :{message} "
+ con.send(message_to_send)
+ con.receive_everything()
+ con.close()
+ return {"successful": True, "reason": None}
+
+ def build_payload(self, comparison: Comparison) -> dict:
+ return {"message": self.generate_message(comparison)}
diff --git a/apps/worker/services/notification/notifiers/mixins/__init__.py b/apps/worker/services/notification/notifiers/mixins/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/notification/notifiers/mixins/message/__init__.py b/apps/worker/services/notification/notifiers/mixins/message/__init__.py
new file mode 100644
index 0000000000..106d88c3a2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/message/__init__.py
@@ -0,0 +1,227 @@
+import logging
+from typing import Any, Callable, Mapping, Optional
+
+from shared.django_apps.core.models import Repository
+from shared.plan.constants import TierName
+from shared.plan.service import PlanService
+from shared.reports.resources import ReportTotals
+
+from database.models.core import Owner
+from helpers.environment import is_enterprise
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.mixins.message.helpers import (
+ should_message_be_compact,
+)
+from services.notification.notifiers.mixins.message.sections import get_message_layout
+from services.notification.notifiers.mixins.message.writers import TeamPlanWriter
+from services.urls import get_commit_url, get_pull_url
+from services.yaml.reader import read_yaml_field
+
+log = logging.getLogger(__name__)
+
+
+class MessageMixin(object):
+ def create_message(
+ self,
+ comparison: ComparisonProxy | FilteredComparison,
+ pull_dict: Optional[Mapping[str, Any]],
+ yaml_settings: dict,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ):
+ """
+ Assemble the various components of the PR comments message in accordance with their YAML configuration.
+ See https://docs.codecov.io/docs/pull-request-comments for more context on the different parts of a PR comment.
+
+ Returns the PR comment message as a list of strings, where each item in the list corresponds to a line in the comment.
+
+ Parameters:
+ yaml_settings: YAML settings for notifier
+
+ Note: Github Checks Notifiers are initialized with "status" YAML settings.
+ Thus, the comment block of the codecov YAML is passed as the "yaml_settings" parameter for these Notifiers.
+
+ """
+ changes = comparison.get_changes()
+ diff = comparison.get_diff(use_original_base=True)
+ behind_by = comparison.get_behind_by()
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ pull = comparison.pull
+
+ settings = yaml_settings
+ current_yaml = self.current_yaml
+
+ links = {
+ "pull": get_pull_url(pull),
+ "base": (
+ get_commit_url(comparison.project_coverage_base.commit)
+ if comparison.project_coverage_base.commit is not None
+ else None
+ ),
+ "head": get_commit_url(comparison.head.commit),
+ }
+
+ # bool: show complexity
+ if read_yaml_field(current_yaml, ("codecov", "ui", "hide_complexity")):
+ show_complexity = False
+ else:
+ show_complexity = bool(
+ (base_report.totals if base_report else ReportTotals()).complexity
+ or (head_report.totals if head_report else ReportTotals()).complexity
+ )
+
+ message: list[str] = []
+ # note: since we're using append, calling write("") will add a newline to the message
+ write = message.append
+
+ self._possibly_write_install_app(comparison, write)
+
+ # Write Header
+ write(f"## [Codecov]({links['pull']}?dropdown=coverage&src=pr&el=h1) Report")
+
+ repo = comparison.head.commit.repository
+ owner: Owner = repo.owner
+
+ # Separate PR comment based on plan that can't/won't be tweaked by codecov.yml settings
+ owner_plan = PlanService(owner)
+ if owner_plan.tier_name == TierName.TEAM.value:
+ return self._team_plan_notification(
+ comparison=comparison,
+ message=message,
+ diff=diff,
+ settings=settings,
+ links=links,
+ current_yaml=current_yaml,
+ )
+
+ sections = get_message_layout(settings, status_or_checks_helper_text)
+
+ for upper_section_name, section_writer_class in sections.upper:
+ section_writer = section_writer_class(
+ self.repository,
+ upper_section_name,
+ show_complexity,
+ settings,
+ current_yaml,
+ status_or_checks_helper_text,
+ )
+ self.write_section_to_msg(
+ comparison, changes, diff, links, write, section_writer, behind_by
+ )
+
+ if head_report:
+ is_compact_message = sections.middle and should_message_be_compact(
+ comparison, settings
+ )
+ if is_compact_message:
+ write(
+ "Additional details and impacted files
\n"
+ )
+ write("")
+
+ for layout, section_writer_class in sections.middle:
+ section_writer = section_writer_class(
+ self.repository, layout, show_complexity, settings, current_yaml
+ )
+ self.write_section_to_msg(
+ comparison, changes, diff, links, write, section_writer
+ )
+
+ if is_compact_message:
+ write(" ")
+
+ for lower_section_name, section_writer_class in sections.lower:
+ section_writer = section_writer_class(
+ self.repository,
+ lower_section_name,
+ show_complexity,
+ settings,
+ current_yaml,
+ )
+ self.write_section_to_msg(
+ comparison, changes, diff, links, write, section_writer
+ )
+
+ # TODO(swatinem): should this rather be part of the `announcements` section
+ self.write_cross_pollination_message(write=write)
+
+ return [m for m in message if m is not None]
+
+ def _possibly_write_install_app(
+ self, comparison: ComparisonProxy, write: Callable
+ ) -> None:
+ """Write a message if the user does not have any GH installations
+ and will be writing with a Codecov Commenter Account.
+ """
+ repo: Repository = comparison.head.commit.repository
+ repo_owner: Owner = repo.owner
+ if (
+ repo_owner.service == "github"
+ and not is_enterprise()
+ and repo_owner.github_app_installations == []
+ and comparison.context.gh_is_using_codecov_commenter
+ ):
+ message_to_display = ":warning: Please install the  to ensure uploads and comments are reliably processed by Codecov."
+ write(message_to_display)
+ write("")
+
+ def _team_plan_notification(
+ self,
+ comparison: ComparisonProxy,
+ message: list[str],
+ diff,
+ settings,
+ links,
+ current_yaml,
+ ) -> list[str]:
+ writer_class = TeamPlanWriter()
+
+ # Settings here enable failed tests results for now as a new product
+ message.extend(
+ writer_class.header_lines(
+ comparison=comparison, diff=diff, settings=settings
+ )
+ )
+ message.extend(
+ writer_class.middle_lines(
+ comparison=comparison, diff=diff, links=links, current_yaml=current_yaml
+ )
+ )
+ message.extend(writer_class.footer_lines(comparison))
+
+ return message
+
+ def write_section_to_msg(
+ self, comparison, changes, diff, links, write, section_writer, behind_by=None
+ ):
+ wrote_something = False
+ for line in section_writer.write_section(
+ comparison, diff, changes, links, behind_by=behind_by
+ ):
+ wrote_something |= line is not None
+ write(line)
+ if wrote_something:
+ write("")
+
+ def write_cross_pollination_message(self, write: Callable):
+ extra_message = []
+
+ ta_message = "- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems."
+ ba_message = "- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges."
+
+ if not self.repository.test_analytics_enabled:
+ extra_message.append(ta_message)
+
+ if not self.repository.bundle_analysis_enabled and set(
+ {"javascript", "typescript"}
+ ).intersection(self.repository.languages or {}):
+ extra_message.append(ba_message)
+
+ if extra_message:
+ for i in [
+ " :rocket: New features to boost your workflow:
",
+ "",
+ *extra_message,
+ " ",
+ ]:
+ write(i)
diff --git a/apps/worker/services/notification/notifiers/mixins/message/helpers.py b/apps/worker/services/notification/notifiers/mixins/message/helpers.py
new file mode 100644
index 0000000000..81ab6f166a
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/message/helpers.py
@@ -0,0 +1,392 @@
+import re
+from decimal import Decimal
+from typing import List, Sequence
+
+from shared.reports.resources import ReportTotals
+from shared.yaml.user_yaml import UserYaml
+
+from services.comparison import ComparisonProxy
+from services.comparison.changes import Change
+from services.yaml import read_yaml_field
+from services.yaml.reader import get_minimum_precision, round_number
+
+zero_change_regex = re.compile("0.0+%?")
+
+
+def has_project_status(yaml: UserYaml) -> bool:
+ project_status_details = read_yaml_field(
+ yaml, ("coverage", "status", "project"), False
+ )
+ if isinstance(project_status_details, bool):
+ return project_status_details
+ # If it's not a bool, it has to be a dict
+ if isinstance(project_status_details, dict):
+ if "enabled" in project_status_details:
+ return project_status_details["enabled"]
+ return True
+ # The config is not according to what we expect
+ # So it's an invalid status definition
+ return False
+
+
+def is_coverage_drop_significant(comparison: ComparisonProxy) -> bool:
+ head_coverage = (
+ comparison.head.report.totals.coverage if comparison.has_head_report() else None
+ )
+ base_coverage = (
+ comparison.project_coverage_base.report.totals.coverage
+ if comparison.has_project_coverage_base_report()
+ else None
+ )
+ if head_coverage is None or base_coverage is None:
+ # No change to be significant
+ return False
+ diff = Decimal(head_coverage) - Decimal(base_coverage)
+ change_is_positive = diff >= 0
+ # head_coverage is the percent of the project covered in HEAD
+ # base_coverage is the percent of the project covered in BASE
+ # So "change_is_significant" is checking if the diff between them is more than 5%
+ change_is_significant = abs(diff) >= Decimal(5)
+ return not change_is_positive and change_is_significant
+
+
+def make_metrics(before, after, relative, show_complexity, yaml, pull_url=None):
+ coverage_good = None
+ icon = " |"
+ if after is None:
+ # e.g. missing flags
+ coverage = " `?` |"
+ complexity = " `?` |" if show_complexity else ""
+
+ elif after is False:
+ # e.g. file deleted
+ coverage = " |"
+ complexity = " |" if show_complexity else ""
+
+ else:
+ if isinstance(before, list):
+ before = ReportTotals(*before)
+ if isinstance(after, list):
+ after = ReportTotals(*after)
+
+ layout = " `{absolute} <{relative}> ({impact})` |"
+
+ if (
+ before
+ and before.coverage is not None
+ and after
+ and after.coverage is not None
+ ):
+ coverage_change = float(after.coverage) - float(before.coverage)
+ else:
+ coverage_change = None
+ coverage_good = (coverage_change > 0) if coverage_change is not None else None
+ coverage = layout.format(
+ absolute=format_number_to_str(
+ yaml, after.coverage, style="{0}%", if_null="\u2205"
+ ),
+ relative=format_number_to_str(
+ yaml, relative.coverage if relative else 0, style="{0}%", if_null="\xf8"
+ ),
+ impact=(
+ format_number_to_str(
+ yaml,
+ coverage_change,
+ style="{0}%",
+ if_zero="\xf8",
+ if_null="\u2205",
+ plus=True,
+ )
+ if before
+ else "?"
+ if before is None
+ else "\xf8"
+ ),
+ )
+
+ if show_complexity:
+ is_string = isinstance(relative.complexity if relative else "", str)
+ style = "{0}%" if is_string else "{0}"
+ complexity_change = (
+ Decimal(after.complexity) - Decimal(before.complexity)
+ if before
+ else None
+ )
+ complexity_good = (complexity_change < 0) if before else None
+ complexity = layout.format(
+ absolute=style.format(format_number_to_str(yaml, after.complexity)),
+ relative=style.format(
+ format_number_to_str(
+ yaml, relative.complexity if relative else 0, if_null="\xf8"
+ )
+ ),
+ impact=style.format(
+ format_number_to_str(
+ yaml,
+ complexity_change,
+ if_zero="\xf8",
+ if_null="\xf8",
+ plus=True,
+ )
+ if before
+ else "?"
+ ),
+ )
+
+ show_up_arrow = coverage_good and complexity_good
+ show_down_arrow = (coverage_good is False and coverage_change != 0) and (
+ complexity_good is False and complexity_change != 0
+ )
+ icon = (
+ " :arrow_up: |"
+ if show_up_arrow
+ else " :arrow_down: |"
+ if show_down_arrow
+ else " |"
+ )
+
+ else:
+ complexity = ""
+ icon = (
+ " :arrow_up: |"
+ if coverage_good
+ else (
+ " :arrow_down: |"
+ if coverage_good is False and coverage_change != 0
+ else " |"
+ )
+ )
+
+ return "".join(("|", coverage, complexity, icon))
+
+
+def make_patch_only_metrics(before, after, relative, show_complexity, yaml, pull_url):
+ if after is None:
+ # e.g. missing flags
+ coverage = " `?` |"
+ missing_line_str = " `?` |"
+
+ elif after is False:
+ # e.g. file deleted
+ coverage = " |"
+ missing_line_str = " |"
+
+ else:
+ patch_cov = format_number_to_str(
+ yaml, relative.coverage if relative else 0, style="{0}%", if_null="\xf8"
+ )
+ coverage = f" {patch_cov} |"
+ missing_lines = relative.misses if relative else 0
+ partials = relative.partials if relative else 0
+ s = "s" if partials > 1 else ""
+ partials_str = "{n} partial{s}".format(
+ n=partials,
+ s=s,
+ )
+ missing_line_str = (
+ " [{m} Missing {partials}:warning: ]({pull_url}?src=pr&el=tree) |".format(
+ m=missing_lines,
+ partials=f"and {partials_str} " if partials else "",
+ pull_url=pull_url,
+ )
+ )
+ return "".join(("|", coverage, missing_line_str))
+
+
+def get_table_header(show_complexity):
+ return (
+ "| Coverage \u0394 |"
+ + (" Complexity \u0394 |" if show_complexity else "")
+ + " |"
+ )
+
+
+def get_table_layout(show_complexity):
+ return "|---|---|---|" + ("---|" if show_complexity else "")
+
+
+def format_number_to_str(
+ yml, value, if_zero=None, if_null=None, plus=False, style="{0}"
+) -> str:
+ if value is None:
+ return if_null
+ precision = get_minimum_precision(yml)
+ value = Decimal(value)
+ res = round_number(yml, value)
+
+ if if_zero and value == 0:
+ return if_zero
+
+ if res == 0 and value != 0:
+ # <.01
+ return style.format(
+ "%s<%s"
+ % ("+" if plus and value > 0 else "" if value > 0 else "-", precision)
+ )
+
+ if plus and res > Decimal("0"):
+ res = "+" + str(res)
+ return style.format(res)
+
+
+def add_plus_sign(value: str) -> str:
+ if value in ("", "0", "0%") or zero_change_regex.fullmatch(value):
+ return ""
+ elif value[0] != "-":
+ return "+%s" % value
+ else:
+ return value
+
+
+def list_to_text_table(rows, padding=0) -> List[str]:
+ """
+ Assumes align left.
+
+ list_to_text_table(
+ [
+ ('|##', 'master|', 'stable|', '+/-|', '##|'),
+ ('+', '1|', '2|', '+1', ''),
+ ], 2) == ['## master stable +/- ##',
+ '+ 1 2 +1 ']
+
+ """
+ # (2, 6, 6, 3, 2)
+ column_w = list(
+ map(
+ max,
+ zip(*map(lambda row: map(lambda cell: len(cell.strip("|")), row), rows)),
+ )
+ )
+
+ def _fill(a):
+ w, cell = a
+ return "{text:{fill}{align}{width}}".format(
+ text=cell.strip("|"),
+ fill=" ",
+ align=(("^" if cell[:1] == "|" else ">") if cell[-1:] == "|" else "<"),
+ width=w,
+ )
+
+ # now they are filled with spaces
+ spacing = (" " * padding).join
+ return list(map(lambda row: spacing(map(_fill, zip(column_w, row))), rows))
+
+
+def diff_to_string(current_yaml, base_title, base, head_title, head) -> List[str]:
+ """
+ ('master', {},
+ 'stable', {},
+ ('ui', before, after), ...})
+ """
+
+ def F(value):
+ if value is None:
+ return "?"
+ elif isinstance(value, str):
+ return "%s%%" % round_number(current_yaml, Decimal(value))
+ else:
+ return value
+
+ def _row(title, c1, c2, plus="+", minus="-", neutral=" "):
+ if c1 == c2 == 0:
+ return ("", "", "", "", "")
+ else:
+ # TODO if coverage format to smallest string or precision
+ if c1 is None or c2 is None:
+ change = ""
+ elif isinstance(c2, str) or isinstance(c1, str):
+ change = F(str(float(c2) - float(c1)))
+ else:
+ change = str(c2 - c1)
+ change_is_zero = change in ("0", "0%", "") or zero_change_regex.fullmatch(
+ change
+ )
+ sign = neutral if change_is_zero else plus if change[0] != "-" else minus
+ return (
+ "%s %s" % (sign, title),
+ "%s|" % F(c1),
+ "%s|" % F(c2),
+ "%s|" % add_plus_sign(change),
+ "",
+ )
+
+ c = int(isinstance(base.complexity, str)) if base else 0
+ # create a spaced table with data
+ table = list_to_text_table(
+ [
+ ("|##", "%s|" % base_title, "%s|" % head_title, "+/-|", "##|"),
+ _row("Coverage", base.coverage if base else None, head.coverage, "+", "-"),
+ _row(
+ "Complexity",
+ base.complexity if base else None,
+ head.complexity,
+ "-+"[c],
+ "+-"[c],
+ ),
+ _row("Files", base.files if base else None, head.files, " ", " "),
+ _row("Lines", base.lines if base else None, head.lines, " ", " "),
+ _row("Branches", base.branches if base else None, head.branches, " ", " "),
+ _row("Hits", base.hits if base else None, head.hits, "+", "-"),
+ _row("Misses", base.misses if base else None, head.misses, "-", "+"),
+ _row("Partials", base.partials if base else None, head.partials, "-", "+"),
+ ],
+ 3,
+ )
+ row_w = len(table[0])
+
+ spacer = ["=" * row_w]
+
+ title = "@@%s@@" % "{text:{fill}{align}{width}}".format(
+ text="Coverage Diff",
+ fill=" ",
+ align="^",
+ width=row_w - 4,
+ )
+
+ table = (
+ [title, table[0]]
+ + spacer
+ + table[1:3]
+ + spacer # coverage, complexity
+ + table[3:6]
+ + spacer # files, lines, branches
+ + table[6:9] # hits, misses, partials
+ )
+
+ # no complexity included
+ if head.complexity in (None, 0):
+ table.pop(4)
+
+ return "\n".join(filter(lambda row: row.strip(" "), table)).strip("=").split("\n")
+
+
+def sort_by_importance(changes: Sequence[Change]) -> List[Change]:
+ return sorted(
+ changes or [],
+ key=lambda c: (float((c.totals or ReportTotals()).coverage), c.new, c.deleted),
+ )
+
+
+def ellipsis(text, length, cut_from="left") -> str:
+ if cut_from == "right":
+ return (text[:length] + "...") if len(text) > length else text
+ elif cut_from is None:
+ return (
+ (text[: (length / 2)] + "..." + text[(length / -2) :])
+ if len(text) > length
+ else text
+ )
+ else:
+ return ("..." + text[len(text) - length :]) if len(text) > length else text
+
+
+def escape_markdown(value: str) -> str:
+ return value.replace("`", "\\`").replace("*", "\\*").replace("_", "\\_")
+
+
+def should_message_be_compact(comparison, settings):
+ # bitbucket doesnt support
+ supported_services = ("github", "gitlab")
+ if comparison.repository_service.service not in supported_services:
+ return False
+ return settings.get("hide_comment_details", False)
diff --git a/apps/worker/services/notification/notifiers/mixins/message/sections.py b/apps/worker/services/notification/notifiers/mixins/message/sections.py
new file mode 100644
index 0000000000..bf41f332f2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/message/sections.py
@@ -0,0 +1,773 @@
+import logging
+import random
+from base64 import b64encode
+from dataclasses import dataclass
+from decimal import Decimal
+from enum import Enum, auto
+from itertools import starmap
+from urllib.parse import urlencode
+
+from shared.helpers.yaml import walk
+from shared.reports.resources import Report
+from shared.validation.helpers import LayoutStructure
+
+from helpers.environment import is_enterprise
+from helpers.reports import get_totals_from_file_in_reports
+from services.comparison import ComparisonProxy
+from services.comparison.types import ReportUploadedCount
+from services.notification.notifiers.mixins.message.helpers import (
+ diff_to_string,
+ ellipsis,
+ escape_markdown,
+ get_table_header,
+ get_table_layout,
+ has_project_status,
+ is_coverage_drop_significant,
+ make_metrics,
+ make_patch_only_metrics,
+)
+from services.urls import get_commit_url_from_commit_sha, get_pull_graph_url
+from services.yaml.reader import get_components_from_yaml, round_number
+
+log = logging.getLogger(__name__)
+
+ALL_TESTS_PASSED_MSG = ":white_check_mark: All tests successful. No failed tests found."
+
+SectionList = list[tuple[str, type["BaseSectionWriter"]]]
+
+
+@dataclass
+class MessageLayout:
+ upper: SectionList
+ middle: SectionList
+ lower: SectionList
+
+
+HEADER_SECTIONS = {"header", "newheader", "condensed_header"}
+
+
+def get_message_layout(
+ settings, status_or_checks_helper_text: dict[str, str] | None
+) -> MessageLayout:
+ sections = [s.strip() for s in settings.get("layout", "").split(",")]
+
+ # force at least the `condensed_header` if no header has been configured
+ if not any(s in HEADER_SECTIONS for s in sections):
+ sections.insert(0, "condensed_header")
+
+ # append the `newfiles` section if there is a `files` or `tree` section
+ if "files" in sections or "tree" in sections:
+ sections.append("newfiles")
+
+ upper: SectionList = []
+ middle: SectionList = []
+ lower: SectionList = []
+
+ for section in sections:
+ if section in HEADER_SECTIONS:
+ upper.append((section, HeaderSectionWriter))
+ elif section == "newfiles" or section == "condensed_files":
+ upper.append((section, NewFilesSectionWriter))
+
+ elif section.startswith("flag"):
+ middle.append((section, FlagSectionWriter))
+ elif section == "diff":
+ middle.append((section, DiffSectionWriter))
+ elif section.startswith(("files", "tree")):
+ middle.append((section, FileSectionWriter))
+ elif section == "reach":
+ middle.append((section, ReachSectionWriter))
+ elif section == "announcements":
+ middle.append((section, AnnouncementSectionWriter))
+ elif section.startswith("component"):
+ middle.append((section, ComponentsSectionWriter))
+ elif section == "footer":
+ middle.append((section, FooterSectionWriter))
+
+ elif section == "newfooter" or section == "condensed_footer":
+ lower.append(("newfooter", NewFooterSectionWriter))
+
+ elif section not in LayoutStructure.acceptable_objects:
+ log.warning("Improper layout name", extra={"layout": section})
+
+ # append the helper text and messages to user to the upper section
+ if status_or_checks_helper_text:
+ upper.append(("status_or_checks_helper_text", HelperTextSectionWriter))
+ upper.append(("messages_to_user", MessagesToUserSectionWriter))
+
+ return MessageLayout(upper, middle, lower)
+
+
+class BaseSectionWriter(object):
+ def __init__(
+ self,
+ repository,
+ layout,
+ show_complexity,
+ settings,
+ current_yaml,
+ status_or_checks_helper_text=None,
+ ):
+ self.repository = repository
+ self.layout = layout
+ self.show_complexity = show_complexity
+ self.settings = settings
+ self.current_yaml = current_yaml
+ self.status_or_checks_helper_text = status_or_checks_helper_text
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ def write_section(self, *args, **kwargs) -> list[str]:
+ return list(self.do_write_section(*args, **kwargs))
+
+
+class NewFooterSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ hide_project_coverage = self.settings.get("hide_project_coverage", False)
+ if hide_project_coverage:
+ yield ""
+ yield ":loudspeaker: Thoughts on this report? [Let us know!]({0})".format(
+ "https://about.codecov.io/pull-request-comment-report/"
+ )
+
+ else:
+ repo_service = comparison.repository_service.service
+ yield ""
+ yield "[:umbrella: View full report in Codecov by Sentry]({0}?dropdown=coverage&src=pr&el=continue). ".format(
+ links["pull"]
+ )
+ yield ":loudspeaker: Have feedback on the report? [Share it here]({0}).".format(
+ "https://about.codecov.io/codecov-pr-comment-feedback/"
+ if repo_service == "github"
+ else "https://gitlab.com/codecov-open-source/codecov-user-feedback/-/issues/4"
+ )
+
+
+class HeaderSectionWriter(BaseSectionWriter):
+ def _possibly_include_test_result_setup_confirmation(self, comparison):
+ if ta_error_msg := comparison.test_results_error():
+ yield ""
+ yield ta_error_msg
+ elif comparison.all_tests_passed():
+ yield ""
+ yield ALL_TESTS_PASSED_MSG
+
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ yaml = self.current_yaml
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ pull_dict = comparison.enriched_pull.provider_pull
+
+ diff_totals = head_report.apply_diff(diff)
+ if diff_totals:
+ misses_and_partials = diff_totals.misses + diff_totals.partials
+ patch_coverage = diff_totals.coverage
+ else:
+ misses_and_partials = None
+ patch_coverage = None
+ if misses_and_partials:
+ ln_text = "lines" if misses_and_partials > 1 else "line"
+ yield (
+ f"Attention: Patch coverage is `{patch_coverage}%` with `{misses_and_partials} {ln_text}` in your changes missing coverage. Please review."
+ )
+ else:
+ yield "All modified and coverable lines are covered by tests :white_check_mark:"
+
+ hide_project_coverage = self.settings.get("hide_project_coverage", False)
+ if hide_project_coverage:
+ for msg in self._possibly_include_test_result_setup_confirmation(
+ comparison
+ ):
+ yield msg
+ return
+
+ if base_report and head_report:
+ yield (
+ "> Project coverage is {head_cov}%. Comparing base [(`{commitid_base}`)]({links[base]}?dropdown=coverage&el=desc) to head [(`{commitid_head}`)]({links[head]}?dropdown=coverage&el=desc).".format(
+ commitid_head=comparison.head.commit.commitid[:7],
+ commitid_base=comparison.project_coverage_base.commit.commitid[:7],
+ links=links,
+ head_cov=round_number(yaml, Decimal(head_report.totals.coverage)),
+ )
+ )
+ else:
+ # This doesn't actually emit a message if the _head_ report is missing
+ # Because we don't notify if the _head_ report is missing
+ # But it's still used if the base report is missing.
+ # Why didn't you change the condition and the code then? Idk... maybe I'm wrong in my assumptions :P
+ what = "BASE" if not base_report else "HEAD"
+ branch = pull_dict["base" if not base_report else "head"]["branch"]
+ commit = pull_dict["base" if not base_report else "head"]["commitid"][:7]
+ yield (
+ f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for {what} (`{branch}@{commit}`). [Learn more](https://docs.codecov.io/docs/error-reference#section-missing-{what.lower()}-commit) about missing {what} report."
+ )
+
+ if behind_by:
+ yield (
+ f"> Report is {behind_by} commits behind head on {pull_dict['base']['branch']}."
+ )
+ if (
+ comparison.enriched_pull.provider_pull is not None
+ and comparison.head.commit.commitid
+ != comparison.enriched_pull.provider_pull["head"]["commitid"]
+ ):
+ # Temporary log so we understand when this happens
+ log.info(
+ "Notifying user that current head and pull head differ",
+ extra=dict(
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ pull_head=comparison.enriched_pull.provider_pull["head"][
+ "commitid"
+ ],
+ ),
+ )
+ yield ""
+ pull_head = comparison.enriched_pull.provider_pull["head"]["commitid"][:7]
+ current_head = comparison.head.commit.commitid[:7]
+ yield (
+ f"> :exclamation: **Current head {current_head} differs from pull request most recent head {pull_head}**"
+ )
+ yield "> "
+ yield f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) reports for the commit {pull_head} to get more accurate results."
+
+ for msg in self._possibly_include_test_result_setup_confirmation(comparison):
+ yield msg
+
+
+class AnnouncementSectionWriter(BaseSectionWriter):
+ current_active_messages = [
+ "Codecov offers a browser extension for seamless coverage viewing on GitHub. Try it in [Chrome](https://chrome.google.com/webstore/detail/codecov/gedikamndpbemklijjkncpnolildpbgo) or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/codecov/) today!"
+ # "Codecov can now indicate which changes are the most critical in Pull Requests. [Learn more](https://about.codecov.io/product/feature/runtime-insights/)" # This is disabled as of CODE-1885. But we might bring it back later.
+ ]
+
+ def do_write_section(self, comparison: ComparisonProxy, *args, **kwargs):
+ # This allows us to shift through active messages while respecting the annoucement limit.
+ message_to_display = random.choice(
+ AnnouncementSectionWriter.current_active_messages
+ )
+
+ yield f":mega: {message_to_display}"
+
+
+class FooterSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ pull_dict = comparison.enriched_pull.provider_pull
+ yield "------"
+ yield ""
+ yield (
+ "[Continue to review full report in Codecov by Sentry]({0}?dropdown=coverage&src=pr&el=continue).".format(
+ links["pull"]
+ )
+ )
+ yield "> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)"
+ yield "> `\u0394 = absolute (impact)`, `\xf8 = not affected`, `? = missing data`"
+
+ yield (
+ "> Powered by [Codecov]({pull}?dropdown=coverage&src=pr&el=footer). Last update [{base}...{head}]({pull}?dropdown=coverage&src=pr&el=lastupdated). Read the [comment docs]({comment}).".format(
+ pull=links["pull"],
+ base=pull_dict["base"]["commitid"][:7],
+ head=pull_dict["head"]["commitid"][:7],
+ comment="https://docs.codecov.io/docs/pull-request-comments",
+ )
+ )
+
+
+class ReachSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ pull = comparison.enriched_pull.database_pull
+ yield (
+ "[]({}?src=pr&el=tree)".format(
+ get_pull_graph_url(
+ pull,
+ "tree.svg",
+ width=650,
+ height=150,
+ src="pr",
+ token=pull.repository.image_token,
+ ),
+ links["pull"],
+ )
+ )
+
+
+class DiffSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ if base_report is None:
+ base_report = Report()
+ pull_dict = comparison.enriched_pull.provider_pull
+ pull = comparison.enriched_pull.database_pull
+ yield "```diff"
+ lines = diff_to_string(
+ self.current_yaml,
+ pull_dict["base"]["branch"], # important because base may be null
+ base_report.totals if base_report else None,
+ "#%s" % pull.pullid,
+ head_report.totals,
+ )
+ yield from lines
+ yield "```"
+
+
+def _get_tree_cell(typ, path, metrics, compare, is_critical):
+ return "| {rm}[{path}]({compare}?src=pr&el=tree&{path_as_query_param}#diff-{hash}){rm}{file_tags} {metrics}".format(
+ rm="~~" if typ == "deleted" else "",
+ path=escape_markdown(ellipsis(path, 50, False)),
+ path_as_query_param=urlencode({"filepath": path}),
+ compare=compare,
+ hash=b64encode(path.encode()).decode(),
+ metrics=metrics,
+ file_tags=" **Critical**" if is_critical else "",
+ )
+
+
+class NewFilesSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ # create list of files changed in diff
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ if base_report is None:
+ base_report = Report()
+ files_in_diff = [
+ (
+ _diff["type"],
+ path,
+ make_patch_only_metrics(
+ get_totals_from_file_in_reports(base_report, path) or False,
+ get_totals_from_file_in_reports(head_report, path) or False,
+ _diff["totals"],
+ self.show_complexity,
+ self.current_yaml,
+ links["pull"],
+ ),
+ int(_diff["totals"].misses + _diff["totals"].partials),
+ )
+ for path, _diff in (diff["files"] if diff else {}).items()
+ if _diff.get("totals")
+ ]
+
+ all_files = set(f[1] for f in files_in_diff or []) | set(
+ c.path for c in changes or []
+ )
+ if files_in_diff:
+ table_header = "| Patch % | Lines |"
+ table_layout = "|---|---|---|"
+
+ # get limit of results to show
+ limit = int(self.layout.split(":")[1] if ":" in self.layout else 10)
+ mentioned = []
+ files_in_critical = set()
+
+ def tree_cell(typ, path, metrics, _=None):
+ if path not in mentioned:
+ # mentioned: for files that are in diff and changes
+ mentioned.append(path)
+ return _get_tree_cell(
+ typ=typ,
+ path=path,
+ metrics=metrics,
+ compare=links["pull"],
+ is_critical=path in files_in_critical,
+ )
+
+ remaining_files = 0
+ printed_files = 0
+ changed_files = sorted(
+ files_in_diff, key=lambda a: a[3] or Decimal("0"), reverse=True
+ )
+ changed_files_with_missing_lines = [f for f in changed_files if f[3] > 0]
+ if changed_files_with_missing_lines:
+ yield (
+ "| [Files with missing lines]({0}?dropdown=coverage&src=pr&el=tree) {1}".format(
+ links["pull"], table_header
+ )
+ )
+ yield table_layout
+ for file in changed_files_with_missing_lines:
+ if printed_files == limit:
+ remaining_files += 1
+ else:
+ printed_files += 1
+ yield tree_cell(file[0], file[1], file[2])
+ if remaining_files:
+ yield (
+ "| ... and [{n} more]({href}?src=pr&el=tree-more) | |".format(
+ n=remaining_files, href=links["pull"]
+ )
+ )
+
+
+class FileSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ # create list of files changed in diff
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ if base_report is None:
+ base_report = Report()
+ files_in_diff = [
+ (
+ _diff["type"],
+ path,
+ make_metrics(
+ get_totals_from_file_in_reports(base_report, path) or False,
+ get_totals_from_file_in_reports(head_report, path) or False,
+ _diff["totals"],
+ self.show_complexity,
+ self.current_yaml,
+ links["pull"],
+ ),
+ int(_diff["totals"].misses + _diff["totals"].partials),
+ )
+ for path, _diff in (diff["files"] if diff else {}).items()
+ if _diff.get("totals")
+ ]
+
+ all_files = set(f[1] for f in files_in_diff or []) | set(
+ c.path for c in changes or []
+ )
+ if files_in_diff:
+ table_header = get_table_header(self.show_complexity)
+ table_layout = get_table_layout(self.show_complexity)
+
+ # get limit of results to show
+ limit = int(self.layout.split(":")[1] if ":" in self.layout else 10)
+ mentioned = []
+ files_in_critical = set()
+
+ def tree_cell(typ, path, metrics, _=None):
+ if path not in mentioned:
+ # mentioned: for files that are in diff and changes
+ mentioned.append(path)
+ return _get_tree_cell(
+ typ=typ,
+ path=path,
+ metrics=metrics,
+ compare=links["pull"],
+ is_critical=path in files_in_critical,
+ )
+
+ yield (
+ "| [Files with missing lines]({0}?dropdown=coverage&src=pr&el=tree) {1}".format(
+ links["pull"], table_header
+ )
+ )
+ yield table_layout
+ yield from starmap(
+ tree_cell,
+ sorted(files_in_diff, key=lambda a: a[3] or Decimal("0"))[:limit],
+ )
+ remaining = len(files_in_diff) - limit
+ if remaining > 0:
+ yield (
+ "| ... and [{n} more]({href}?src=pr&el=tree-more) | |".format(
+ n=remaining, href=links["pull"]
+ )
+ )
+
+ if changes:
+ len_changes_not_in_diff = len(all_files or []) - len(files_in_diff or [])
+ if files_in_diff and len_changes_not_in_diff > 0:
+ yield ""
+ yield (
+ "... and [{n} file{s} with indirect coverage changes]({href}/indirect-changes?src=pr&el=tree-more)".format(
+ n=len_changes_not_in_diff,
+ href=links["pull"],
+ s="s" if len_changes_not_in_diff > 1 else "",
+ )
+ )
+ elif len_changes_not_in_diff > 0:
+ yield (
+ "[see {n} file{s} with indirect coverage changes]({href}/indirect-changes?src=pr&el=tree-more)".format(
+ n=len_changes_not_in_diff,
+ href=links["pull"],
+ s="s" if len_changes_not_in_diff > 1 else "",
+ )
+ )
+
+
+class FlagSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison, diff, changes, links, behind_by=None):
+ # flags
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ if base_report is None:
+ base_report = Report()
+ base_flags = base_report.flags if base_report else {}
+ head_flags = head_report.flags if head_report else {}
+ missing_flags = set(base_flags.keys()) - set(head_flags.keys())
+ flags = []
+
+ show_carriedforward_flags = self.settings.get("show_carryforward_flags", False)
+ for name, flag in head_flags.items():
+ if (show_carriedforward_flags is True) or ( # Include all flags
+ show_carriedforward_flags is False
+ and flag.carriedforward
+ is False # Only include flags without carriedforward coverage
+ ):
+ flags.append(
+ {
+ "name": name,
+ "before": get_totals_from_file_in_reports(base_flags, name),
+ "after": flag.totals,
+ "diff": (
+ flag.apply_diff(diff) if walk(diff, ("files",)) else None
+ ),
+ "carriedforward": flag.carriedforward,
+ "carriedforward_from": flag.carriedforward_from,
+ }
+ )
+
+ flags.extend(
+ {
+ "name": flag,
+ "before": base_flags[flag],
+ "after": None,
+ "diff": None,
+ "carriedforward": False,
+ "carriedforward_from": None,
+ }
+ for flag in missing_flags
+ )
+
+ # TODO: get icons working
+ # flag_icon_url = ""
+ # carriedforward_flag_icon_url = ""
+
+ if flags:
+ # Even if "show_carryforward_flags" is true, we don't want to show that column if there isn't actually carriedforward coverage,
+ # so figure out if we actually have any carriedforward coverage to show
+ has_carriedforward_flags = any(
+ flag["carriedforward"] is True
+ for flag in flags # If "show_carryforward_flags" yaml setting is set to false there won't be any flags in this list with carriedforward coverage.
+ )
+
+ table_header = (
+ "| Coverage \u0394 |"
+ + (" Complexity \u0394 |" if self.show_complexity else "")
+ + " |"
+ + (" *Carryforward flag |" if has_carriedforward_flags else "")
+ )
+ table_layout = (
+ "|---|---|---|"
+ + ("---|" if self.show_complexity else "")
+ + ("---|" if has_carriedforward_flags else "")
+ )
+
+ yield (
+ "| [Flag]({href}/flags?src=pr&el=flags) ".format(href=links["pull"])
+ + table_header
+ )
+ yield table_layout
+ for flag in sorted(flags, key=lambda f: f["name"]):
+ carriedforward, carriedforward_from = (
+ flag["carriedforward"],
+ flag["carriedforward_from"],
+ )
+ # Format the message for the "carriedforward" column, if the flag was carried forward
+ if carriedforward is True:
+ # The "from " text will only appear if we actually know which commit we carried forward from
+ carriedforward_from_url = (
+ get_commit_url_from_commit_sha(
+ self.repository, carriedforward_from
+ )
+ if carriedforward_from
+ else ""
+ )
+
+ carriedforward_message = (
+ " Carriedforward"
+ + (
+ f" from [{carriedforward_from[:7]}]({carriedforward_from_url})"
+ if carriedforward_from and carriedforward_from_url
+ else ""
+ )
+ + " |"
+ )
+ else:
+ carriedforward_message = " |" if has_carriedforward_flags else ""
+
+ yield (
+ "| {name} {metrics}{cf}".format(
+ name="[{flag_name}]({href}/flags?src=pr&el=flag)".format(
+ flag_name=flag["name"],
+ href=links["pull"],
+ ),
+ metrics=make_metrics(
+ flag["before"],
+ flag["after"],
+ flag["diff"],
+ self.show_complexity,
+ self.current_yaml,
+ ),
+ cf=carriedforward_message,
+ )
+ )
+
+ if has_carriedforward_flags and show_carriedforward_flags:
+ yield ""
+ yield (
+ "*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags) to find out more."
+ )
+ elif not show_carriedforward_flags:
+ yield ""
+ yield (
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more."
+ )
+
+
+class ComponentsSectionWriter(BaseSectionWriter):
+ def _get_table_data_for_components(
+ self, all_components, comparison: ComparisonProxy
+ ) -> list[dict]:
+ component_data = []
+ for component in all_components:
+ flags = component.get_matching_flags(comparison.head.report.flags.keys())
+ filtered_comparison = comparison.get_filtered_comparison(
+ flags, component.paths
+ )
+ diff = filtered_comparison.get_diff()
+ component_data.append(
+ {
+ "name": component.get_display_name(),
+ "before": (
+ filtered_comparison.project_coverage_base.report.totals
+ if filtered_comparison.project_coverage_base.report is not None
+ else None
+ ),
+ "after": filtered_comparison.head.report.totals,
+ "diff": filtered_comparison.head.report.apply_diff(
+ diff, _save=False
+ ),
+ }
+ )
+ return component_data
+
+ def do_write_section(
+ self, comparison: ComparisonProxy, diff, changes, links, behind_by=None
+ ):
+ all_components = get_components_from_yaml(self.current_yaml)
+ if all_components == []:
+ return # fast return if there's noting to process
+
+ component_data_to_show = self._get_table_data_for_components(
+ all_components, comparison
+ )
+
+ # Table header and layout
+ yield "| [Components]({href}/components?src=pr&el=components) | Coverage \u0394 | |".format(
+ href=links["pull"],
+ )
+ yield "|---|---|---|"
+ # The interesting part
+ for component_data in component_data_to_show:
+ yield (
+ "| {name} {metrics}".format(
+ name="[{component_name}]({href}/components?src=pr&el=component)".format(
+ component_name=component_data["name"],
+ href=links["pull"],
+ ),
+ metrics=make_metrics(
+ component_data["before"],
+ component_data["after"],
+ component_data["diff"],
+ show_complexity=False,
+ yaml=self.current_yaml,
+ ),
+ )
+ )
+
+
+class HelperTextSectionWriter(BaseSectionWriter):
+ def do_write_section(self, comparison: ComparisonProxy, *args, **kwargs):
+ helper_template = ":x: {helper_text}"
+ sorted_helper_text_keys = sorted(self.status_or_checks_helper_text.keys())
+ for helper_text_key in sorted_helper_text_keys:
+ yield helper_template.format(
+ helper_text=self.status_or_checks_helper_text[helper_text_key]
+ )
+
+
+class MessagesToUserSectionWriter(BaseSectionWriter):
+ class Messages(Enum):
+ INSTALL_GITHUB_APP_WARNING = auto()
+ DIFFERENT_UPLOAD_COUNT_WARNING = auto()
+
+ def _write_different_upload_count_warning(self, comparison: ComparisonProxy) -> str:
+ upload_diff = comparison.get_reports_uploaded_count_per_flag_diff()
+ is_commit_complete = comparison.head.commit.state == "complete"
+ if (
+ is_commit_complete
+ and upload_diff
+ and has_project_status(self.current_yaml)
+ and is_coverage_drop_significant(comparison)
+ ):
+ template = (
+ "> :exclamation: There is a different number of reports uploaded between BASE ({base_short_sha}) and HEAD ({head_short_sha}). Click for more details."
+ + "\n> "
+ + "\n> HEAD has {aggregated_upload_diff} upload{plural} {more_or_less} than BASE
"
+ # There needs to be a padding line between the and the table or it won't be rendered correctly
+ + "\n>"
+ + "\n>| Flag | BASE ({base_short_sha}) | HEAD ({head_short_sha}) |"
+ + "\n>|------|------|------|"
+ + "{per_flag_diff_lines}"
+ + "\n>
"
+ )
+
+ def get_line_for_flag_diff(info: ReportUploadedCount) -> str:
+ line_template = "\n>|{flag_name}|{base_count}|{head_count}|"
+ return line_template.format(
+ flag_name=info["flag"],
+ base_count=info["base_count"],
+ head_count=info["head_count"],
+ )
+
+ aggregated_upload_diff = sum(
+ map(lambda diff: diff["head_count"] - diff["base_count"], upload_diff)
+ )
+ context = dict(
+ aggregated_upload_diff=abs(aggregated_upload_diff),
+ more_or_less="more" if aggregated_upload_diff > 0 else "less",
+ plural="s" if abs(aggregated_upload_diff) > 1 else "",
+ base_short_sha=comparison.project_coverage_base.commit.commitid[:7],
+ head_short_sha=comparison.head.commit.commitid[:7],
+ per_flag_diff_lines="".join(
+ [get_line_for_flag_diff(info) for info in upload_diff]
+ ),
+ )
+
+ return template.format(**context)
+ return ""
+
+ def _write_install_github_app_warning(self, comparison: ComparisonProxy) -> str:
+ """Writes a warning message to GitHub owners that have not yet installed the Codecov App to their account."""
+ repo = comparison.head.commit.repository
+ owner = repo.owner
+ is_user_in_github = owner.service == "github"
+ owner_is_using_app = (
+ owner.integration_id is not None or owner.github_app_installations != []
+ )
+ if is_user_in_github and not is_enterprise() and not owner_is_using_app:
+ return ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality."
+ return ""
+
+ def do_write_section(self, comparison: ComparisonProxy, *args, **kwargs):
+ messages_ordering = [
+ self.Messages.INSTALL_GITHUB_APP_WARNING,
+ self.Messages.DIFFERENT_UPLOAD_COUNT_WARNING,
+ ]
+ messages_content = {
+ self.Messages.INSTALL_GITHUB_APP_WARNING: self._write_install_github_app_warning(
+ comparison
+ ),
+ self.Messages.DIFFERENT_UPLOAD_COUNT_WARNING: self._write_different_upload_count_warning(
+ comparison
+ ),
+ }
+ for message in messages_ordering:
+ message_content = messages_content[message]
+ if message_content != "":
+ yield message_content
diff --git a/apps/worker/services/notification/notifiers/mixins/message/tests/__init__.py b/apps/worker/services/notification/notifiers/mixins/message/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/notification/notifiers/mixins/message/tests/test_helpers.py b/apps/worker/services/notification/notifiers/mixins/message/tests/test_helpers.py
new file mode 100644
index 0000000000..329b6ecd97
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/message/tests/test_helpers.py
@@ -0,0 +1,77 @@
+import pytest
+from shared.reports.resources import Report, ReportTotals
+
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.mixins.message.helpers import (
+ has_project_status,
+ is_coverage_drop_significant,
+)
+
+
+@pytest.mark.parametrize(
+ "yaml, expected",
+ [
+ pytest.param(
+ {"coverage": {"status": {"project": False}}},
+ False,
+ id="project_coverage_boolean_disabled",
+ ),
+ pytest.param(
+ {"coverage": {"status": {"project": True}}},
+ True,
+ id="project_coverage_boolean_enabled",
+ ),
+ pytest.param(
+ {"coverage": {"status": {"project": {"enabled": True}}}},
+ True,
+ id="project_coverage_dict_enabled",
+ ),
+ pytest.param(
+ {"coverage": {"status": {"project": {"enabled": False}}}},
+ False,
+ id="project_coverage_dict_disabled",
+ ),
+ pytest.param(
+ {
+ "coverage": {
+ "status": {"project": {"only_pulls": True, "informational": True}}
+ }
+ },
+ True,
+ id="project_coverage_dict_no_explicit_enabled_value",
+ ),
+ pytest.param(
+ {"coverage": {"status": {"project": "enabled"}}},
+ False,
+ id="project_coverage_invalid_value",
+ ),
+ ],
+)
+def test_has_project_status(yaml: dict, expected: bool):
+ assert has_project_status(yaml) == expected
+
+
+@pytest.mark.parametrize(
+ "head_coverage, base_coverage, expected",
+ [
+ pytest.param(None, None, False, id="no_head_no_base_not_significant_drop"),
+ pytest.param(85.0, None, False, id="no_base_not_significant_drop"),
+ pytest.param(None, 85.0, False, id="no_head_not_significant_drop"),
+ pytest.param(85.0, 85.0, False, id="no_change"),
+ pytest.param(91.0, 85.0, False, id="change_is_significant_but_positive"),
+ pytest.param(86.0, 85.0, False, id="change_not_significant"),
+ pytest.param(80.0, 85.0, True, id="change_is_significant"),
+ ],
+)
+def test_is_coverage_drop_significant(
+ head_coverage: float, base_coverage: float, expected, mocker
+):
+ head_report = Report()
+ head_report._totals = ReportTotals(coverage=head_coverage)
+ base_report = Report()
+ base_report._totals = ReportTotals(coverage=base_coverage)
+ mock_head = mocker.MagicMock(report=head_report)
+ mock_base = mocker.MagicMock(report=base_report)
+ mock_comparison = mocker.MagicMock(head=mock_head, project_coverage_base=mock_base)
+ fake_comparison = ComparisonProxy(comparison=mock_comparison)
+ assert is_coverage_drop_significant(fake_comparison) == expected
diff --git a/apps/worker/services/notification/notifiers/mixins/message/writers.py b/apps/worker/services/notification/notifiers/mixins/message/writers.py
new file mode 100644
index 0000000000..85e768a7c0
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/message/writers.py
@@ -0,0 +1,151 @@
+import logging
+from base64 import b64encode
+from decimal import Decimal
+from typing import List
+
+from shared.reports.resources import Report
+
+from database.models import Repository
+from helpers.reports import get_totals_from_file_in_reports
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.mixins.message.helpers import (
+ ellipsis,
+ escape_markdown,
+ make_patch_only_metrics,
+)
+from services.notification.notifiers.mixins.message.sections import (
+ ALL_TESTS_PASSED_MSG,
+)
+
+log = logging.getLogger(__name__)
+
+
+# Unlike sections.py, this is an alternative take for creating messages based functionality.
+# This is a plan specific section, so it doesn't adhere to the settings/yaml configurations
+# like other writers do, hence the new file
+class TeamPlanWriter:
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ def header_lines(self, comparison: ComparisonProxy, diff, settings) -> List[str]:
+ lines = []
+
+ head_report = comparison.head.report
+ diff_totals = head_report.apply_diff(diff)
+
+ if diff_totals:
+ misses_and_partials = diff_totals.misses + diff_totals.partials
+ patch_coverage = diff_totals.coverage
+ else:
+ misses_and_partials = None
+ patch_coverage = None
+ if misses_and_partials:
+ ln_text = "lines" if misses_and_partials > 1 else "line"
+ lines.append(
+ f"Attention: Patch coverage is `{patch_coverage}%` with `{misses_and_partials} {ln_text}` in your changes missing coverage. Please review."
+ )
+ else:
+ lines.append(
+ "All modified and coverable lines are covered by tests :white_check_mark:"
+ )
+
+ hide_project_coverage = settings.get("hide_project_coverage", False)
+ if hide_project_coverage:
+ if ta_error_msg := comparison.test_results_error():
+ lines.append("")
+ lines.append(ta_error_msg)
+ elif comparison.all_tests_passed():
+ lines.append("")
+ lines.append(ALL_TESTS_PASSED_MSG)
+ return lines
+
+ def middle_lines(
+ self, comparison: ComparisonProxy, diff, links, current_yaml
+ ) -> List[str]:
+ lines = []
+
+ # create list of files changed in diff
+ base_report = comparison.project_coverage_base.report
+ head_report = comparison.head.report
+ if base_report is None:
+ base_report = Report()
+ files_in_diff = [
+ (
+ _diff["type"],
+ path,
+ make_patch_only_metrics(
+ get_totals_from_file_in_reports(base_report, path) or False,
+ get_totals_from_file_in_reports(head_report, path) or False,
+ _diff["totals"],
+ # show_complexity defaulted to none
+ None,
+ current_yaml,
+ links["pull"],
+ ),
+ int(_diff["totals"].misses + _diff["totals"].partials),
+ )
+ for path, _diff in (diff["files"] if diff else {}).items()
+ if _diff.get("totals")
+ ]
+
+ if files_in_diff:
+ table_header = "| Patch % | Lines |"
+ table_layout = "|---|---|---|"
+
+ # get limit of results to show
+ limit = 10
+ mentioned = []
+
+ def tree_cell(typ, path, metrics, _=None):
+ if path not in mentioned:
+ # mentioned: for files that are in diff and changes
+ mentioned.append(path)
+ return "| {rm}[{path}]({compare}?src=pr&el=tree#diff-{hash}){rm} {metrics}".format(
+ rm="~~" if typ == "deleted" else "",
+ path=escape_markdown(ellipsis(path, 50, False)),
+ compare=links["pull"],
+ hash=b64encode(path.encode()).decode(),
+ metrics=metrics,
+ )
+
+ remaining_files = 0
+ printed_files = 0
+ changed_files = sorted(
+ files_in_diff, key=lambda a: a[3] or Decimal("0"), reverse=True
+ )
+ changed_files_with_missing_lines = [f for f in changed_files if f[3] > 0]
+ if changed_files_with_missing_lines:
+ lines.append(
+ "| [Files with missing lines]({0}?dropdown=coverage&src=pr&el=tree) {1}".format(
+ links["pull"], table_header
+ )
+ )
+ lines.append(table_layout)
+ for file in changed_files_with_missing_lines:
+ if printed_files == limit:
+ remaining_files += 1
+ else:
+ printed_files += 1
+ lines.append(tree_cell(file[0], file[1], file[2]))
+ if remaining_files:
+ lines.append(
+ "| ... and [{n} more]({href}?src=pr&el=tree-more) | |".format(
+ n=remaining_files, href=links["pull"]
+ )
+ )
+
+ return lines
+
+ def footer_lines(self, comparison: ComparisonProxy) -> List[str]:
+ lines = []
+ lines.append("")
+ lines.append(
+ ":loudspeaker: Thoughts on this report? [Let us know!]({0})".format(
+ "https://github.com/codecov/feedback/issues/255"
+ )
+ )
+ # CAN BE REMOVED AFTER January 31st 2025
+ # Will only be shown to orgs specified in the feature flag override.
+ repo: Repository = comparison.head.commit.repository
+ return lines
diff --git a/apps/worker/services/notification/notifiers/mixins/status.py b/apps/worker/services/notification/notifiers/mixins/status.py
new file mode 100644
index 0000000000..7ae5706780
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/mixins/status.py
@@ -0,0 +1,610 @@
+import logging
+from decimal import Decimal, InvalidOperation
+from enum import Enum
+from typing import Literal, TypedDict
+
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.yaml.reader import round_number
+
+log = logging.getLogger(__name__)
+
+
+class StatusState(Enum):
+ success = "success"
+ failure = "failure"
+
+
+class StatusResult(TypedDict):
+ """
+ The mixins in this file do the calculations and decide the SuccessState for all Status and Checks Notifiers.
+ Checks have different fields than Statuses, so Checks are converted to the CheckResult type later.
+ """
+
+ state: Literal["success", "failure"] # StatusState values
+ message: str
+ included_helper_text: dict[str, str]
+
+
+class HelperTextKey(str, Enum):
+ CUSTOM_TARGET_PATCH = "custom_target_helper_text_patch"
+ CUSTOM_TARGET_PROJECT = "custom_target_helper_text_project"
+ RCB_INDIRECT_CHANGES = "rcb_indirect_changes_helper_text"
+ INDIRECT_CHANGES_KEY = "indirect_changes_helper_text"
+ RCB_ADJUST_BASE = "rcb_adjust_base_helper_text"
+
+
+class HelperTextTemplate(str, Enum):
+ CUSTOM_TARGET = (
+ "Your {context} {notification_type} has failed because the {point_of_comparison} coverage ({coverage}%) is below the target coverage ({target}%). "
+ "You can increase the {point_of_comparison} coverage or adjust the "
+ "[target](https://docs.codecov.com/docs/commit-status#target) coverage."
+ )
+ INDIRECT_CHANGES = (
+ "Your {context} {notification_type} has failed because you have indirect coverage changes. "
+ "Learn more about [Unexpected Coverage Changes](https://docs.codecov.com/docs/unexpected-coverage-changes) "
+ "and [reasons for indirect coverage changes](https://docs.codecov.com/docs/unexpected-coverage-changes#reasons-for-indirect-changes)."
+ )
+ RCB_ADJUST_BASE = (
+ "Your project {notification_type} has failed because the head coverage ({coverage}%) "
+ "is below the [adjusted base coverage](https://docs.codecov.com/docs/removed-code-behavior#option-3-default-adjust_base) ({adjusted_base_cov}%). "
+ "You can increase the head coverage or adjust the "
+ "[Removed Code Behavior](https://docs.codecov.com/docs/removed-code-behavior)."
+ )
+
+
+HELPER_TEXT_MAP = {
+ HelperTextKey.CUSTOM_TARGET_PATCH: HelperTextTemplate.CUSTOM_TARGET,
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET,
+ HelperTextKey.RCB_INDIRECT_CHANGES: HelperTextTemplate.INDIRECT_CHANGES,
+ HelperTextKey.INDIRECT_CHANGES_KEY: HelperTextTemplate.INDIRECT_CHANGES,
+ HelperTextKey.RCB_ADJUST_BASE: HelperTextTemplate.RCB_ADJUST_BASE,
+}
+
+
+class StatusPatchMixin(object):
+ context = "patch"
+
+ def _get_threshold(self) -> Decimal:
+ """
+ Threshold can be configured by user, default is 0.0
+ """
+ threshold = self.notifier_yaml_settings.get("threshold", "0.0")
+
+ try:
+ # check if user has erroneously added a % to this input and fix
+ threshold = Decimal(str(threshold).replace("%", ""))
+ except (InvalidOperation, TypeError, AttributeError):
+ threshold = Decimal("0.0")
+ return threshold
+
+ def _get_target(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> tuple[Decimal | None, bool]:
+ """
+ Target can be configured by user, default is auto, which is the coverage level from the base report.
+ Target will be None if no report is found to compare against.
+ """
+ if self.notifier_yaml_settings.get("target") not in ("auto", None):
+ # check if user has erroneously added a % to this input and fix
+ target_coverage = Decimal(
+ str(self.notifier_yaml_settings.get("target")).replace("%", "")
+ )
+ is_custom_target = True
+ else:
+ target_coverage = (
+ Decimal(comparison.project_coverage_base.report.totals.coverage)
+ if comparison.has_project_coverage_base_report()
+ and comparison.project_coverage_base.report.totals.coverage is not None
+ else None
+ )
+ is_custom_target = False
+ return target_coverage, is_custom_target
+
+ def get_patch_status(
+ self, comparison: ComparisonProxy | FilteredComparison, notification_type: str
+ ) -> StatusResult:
+ threshold = self._get_threshold()
+ target_coverage, is_custom_target = self._get_target(comparison)
+ totals = comparison.get_patch_totals()
+ included_helper_text = {}
+
+ # coverage affected
+ if totals and totals.lines > 0:
+ coverage = Decimal(totals.coverage)
+ if target_coverage is None:
+ state = self.notifier_yaml_settings.get(
+ "if_not_found", StatusState.success.value
+ )
+ message = "No report found to compare against"
+ else:
+ state = (
+ StatusState.success.value
+ if coverage >= target_coverage
+ else StatusState.failure.value
+ )
+ # use rounded numbers for messages
+ coverage_rounded = round_number(self.current_yaml, coverage)
+ target_rounded = round_number(self.current_yaml, target_coverage)
+ if state == StatusState.failure.value and coverage >= (
+ target_coverage - threshold
+ ):
+ threshold_rounded = round_number(self.current_yaml, threshold)
+ state = StatusState.success.value
+ message = f"{coverage_rounded}% of diff hit (within {threshold_rounded}% threshold of {target_rounded}%)"
+ else:
+ message = (
+ f"{coverage_rounded}% of diff hit (target {target_rounded}%)"
+ )
+ if state == StatusState.failure.value and is_custom_target:
+ helper_text = HELPER_TEXT_MAP[
+ HelperTextKey.CUSTOM_TARGET_PATCH
+ ].value.format(
+ context=self.context,
+ notification_type=notification_type,
+ point_of_comparison=self.context,
+ coverage=coverage_rounded,
+ target=target_rounded,
+ )
+ included_helper_text[HelperTextKey.CUSTOM_TARGET_PATCH.value] = (
+ helper_text
+ )
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
+
+ # coverage not affected
+ if comparison.project_coverage_base.commit:
+ description = "Coverage not affected when comparing {0}...{1}".format(
+ comparison.project_coverage_base.commit.commitid[:7],
+ comparison.head.commit.commitid[:7],
+ )
+ else:
+ description = "Coverage not affected"
+ return StatusResult(
+ state=StatusState.success.value,
+ message=description,
+ included_helper_text=included_helper_text,
+ )
+
+
+class StatusChangesMixin(object):
+ context = "changes"
+
+ def is_a_change_worth_noting(self, change) -> bool:
+ if not change.new and not change.deleted:
+ # has totals and not -10m => 10h
+ t = change.totals
+ if t:
+ # new missed||partial lines
+ return (t.misses + t.partials) > 0
+ return False
+
+ def get_changes_status(
+ self, comparison: ComparisonProxy | FilteredComparison, notification_type: str
+ ) -> StatusResult:
+ included_helper_text = {}
+ pull = comparison.pull
+ if self.notifier_yaml_settings.get("base") in ("auto", None, "pr") and pull:
+ if not comparison.has_project_coverage_base_report():
+ description = (
+ "Unable to determine changes, no report found at pull request base"
+ )
+ return StatusResult(
+ state=StatusState.success.value,
+ message=description,
+ included_helper_text=included_helper_text,
+ )
+
+ # filter changes
+ changes = comparison.get_changes()
+ if changes:
+ changes = list(filter(self.is_a_change_worth_noting, changes))
+
+ # remove new additions
+ if changes:
+ lpc = len(changes)
+ eng = "files have" if lpc > 1 else "file has"
+ description = (
+ "{0} {1} indirect coverage changes not visible in diff".format(lpc, eng)
+ )
+ state = (
+ StatusState.success.value
+ if self.notifier_yaml_settings.get("informational")
+ else StatusState.failure.value
+ )
+ if state == StatusState.failure.value:
+ # their comparison failed because of unexpected/indirect changes, give them helper text about it
+ included_helper_text[HelperTextKey.INDIRECT_CHANGES_KEY.value] = (
+ HELPER_TEXT_MAP[HelperTextKey.INDIRECT_CHANGES_KEY].value.format(
+ context=self.context,
+ notification_type=notification_type,
+ )
+ )
+ return StatusResult(
+ state=state,
+ message=description,
+ included_helper_text=included_helper_text,
+ )
+
+ description = "No indirect coverage changes found"
+ return StatusResult(
+ state=StatusState.success.value,
+ message=description,
+ included_helper_text=included_helper_text,
+ )
+
+
+class StatusProjectMixin(object):
+ DEFAULT_REMOVED_CODE_BEHAVIOR = "adjust_base"
+ context = "project"
+ point_of_comparison = "head"
+
+ def _apply_removals_only_behavior(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> tuple[str, str] | None:
+ """
+ Rule for passing project status on removals_only behavior:
+ Pass if code was _only removed_ (i.e. no addition, no unexpected changes)
+ """
+ log.info(
+ "Applying removals_only behavior to project status",
+ extra=dict(commit=comparison.head.commit.commitid),
+ )
+ impacted_files = comparison.get_impacted_files().get("files", [])
+
+ no_added_no_unexpected_change = all(
+ not file.get("added_diff_coverage")
+ and not file.get("unexpected_line_changes")
+ for file in impacted_files
+ )
+ some_removed = any(file.get("removed_diff_coverage") for file in impacted_files)
+
+ if no_added_no_unexpected_change and some_removed:
+ return (
+ StatusState.success.value,
+ ", passed because this change only removed code",
+ )
+ return None
+
+ def _apply_adjust_base_behavior(
+ self,
+ comparison: ComparisonProxy | FilteredComparison,
+ notification_type: str,
+ ) -> tuple[tuple[str, str] | None, dict]:
+ """
+ Rule for passing project status on adjust_base behavior:
+ We adjust the BASE of the comparison by removing from it lines that were removed in HEAD
+ And then re-calculate BASE coverage and compare it to HEAD coverage.
+ """
+ helper_text = {}
+ log.info(
+ "Applying adjust_base behavior to project status",
+ extra=dict(commit=comparison.head.commit.commitid),
+ )
+ # If the user defined a target value for project coverage
+ # Adjusting the base won't make HEAD change in relation to the target value
+ # So we skip the calculation entirely
+ if self.notifier_yaml_settings.get("target") not in ("auto", None):
+ log.info(
+ "Notifier settings specify target value. Skipping adjust_base.",
+ extra=dict(commit=comparison.head.commit.commitid),
+ )
+ return None, helper_text
+
+ impacted_files = comparison.get_impacted_files().get("files", [])
+
+ hits_removed = 0
+ misses_removed = 0
+ partials_removed = 0
+
+ for file_dict in impacted_files:
+ removed_diff_coverage_list = file_dict.get("removed_diff_coverage")
+ if removed_diff_coverage_list:
+ hits_removed += sum(
+ 1 if item[1] == "h" else 0 for item in removed_diff_coverage_list
+ )
+ misses_removed += sum(
+ 1 if item[1] == "m" else 0 for item in removed_diff_coverage_list
+ )
+ partials_removed += sum(
+ 1 if item[1] == "p" else 0 for item in removed_diff_coverage_list
+ )
+
+ base_totals = comparison.project_coverage_base.report.totals
+ base_adjusted_hits = base_totals.hits - hits_removed
+ base_adjusted_misses = base_totals.misses - misses_removed
+ base_adjusted_partials = base_totals.partials - partials_removed
+ base_adjusted_totals = (
+ base_adjusted_hits + base_adjusted_misses + base_adjusted_partials
+ )
+
+ if not base_adjusted_totals:
+ return None, helper_text
+
+ # The coverage info is in percentage, so multiply by 100
+ base_adjusted_coverage = (
+ Decimal(base_adjusted_hits / base_adjusted_totals) * 100
+ )
+ threshold = self._get_threshold()
+
+ head_coverage = Decimal(comparison.head.report.totals.coverage)
+ log.info(
+ "Adjust base applied to project status",
+ extra=dict(
+ commit=comparison.head.commit.commitid,
+ base_adjusted_coverage=base_adjusted_coverage,
+ threshold=threshold,
+ head_coverage=head_coverage,
+ hits_removed=hits_removed,
+ misses_removed=misses_removed,
+ partials_removed=partials_removed,
+ ),
+ )
+ base_adjusted_coverage = base_adjusted_coverage - threshold
+
+ # the head coverage is rounded to five digits after the dot, using shared.helpers.numeric.ratio
+ # so we should round the base adjusted coverage to the same amount of digits after the dot
+ # Decimal.quantize: https://docs.python.org/3/library/decimal.html#decimal.Decimal.quantize
+ quantized_base_adjusted_coverage = base_adjusted_coverage.quantize(
+ Decimal("0.00000")
+ )
+ rounded_base_adjusted_coverage = round_number(
+ self.current_yaml, base_adjusted_coverage
+ )
+
+ if quantized_base_adjusted_coverage - head_coverage < Decimal("0.005"):
+ rounded_difference = max(
+ 0,
+ round_number(self.current_yaml, head_coverage - base_adjusted_coverage),
+ )
+ message = f", passed because coverage increased by {rounded_difference}% when compared to adjusted base ({rounded_base_adjusted_coverage}%)"
+ return (
+ (
+ StatusState.success.value,
+ message,
+ ),
+ helper_text,
+ )
+
+ # use rounded numbers for messages
+ coverage_rounded = round_number(self.current_yaml, head_coverage)
+
+ # their comparison failed despite the adjusted base, give them helper text about it
+ helper_text[HelperTextKey.RCB_ADJUST_BASE.value] = HELPER_TEXT_MAP[
+ HelperTextKey.RCB_ADJUST_BASE
+ ].value.format(
+ notification_type=notification_type,
+ coverage=coverage_rounded,
+ adjusted_base_cov=rounded_base_adjusted_coverage,
+ )
+ return None, helper_text
+
+ def _apply_fully_covered_patch_behavior(
+ self,
+ comparison: ComparisonProxy | FilteredComparison,
+ notification_type: str,
+ ) -> tuple[tuple[str, str] | None, dict]:
+ """
+ Rule for passing project status on fully_covered_patch behavior:
+ Pass if patch coverage is 100% and there are no indirect changes
+ """
+ helper_text = {}
+ log.info(
+ "Applying fully_covered_patch behavior to project status",
+ extra=dict(commit=comparison.head.commit.commitid),
+ )
+ impacted_files = comparison.get_impacted_files().get("files", [])
+
+ no_unexpected_changes = all(
+ not file.get("unexpected_line_changes") for file in impacted_files
+ )
+
+ if not no_unexpected_changes:
+ log.info(
+ "Unexpected changes when applying patch_100 behavior",
+ extra=dict(commit=comparison.head.commit.commitid),
+ )
+
+ # their comparison failed because of unexpected/indirect changes, give them helper text about it
+ helper_text[HelperTextKey.RCB_INDIRECT_CHANGES] = HELPER_TEXT_MAP[
+ HelperTextKey.RCB_INDIRECT_CHANGES
+ ].format(
+ context=self.context,
+ notification_type=notification_type,
+ )
+ return None, helper_text
+
+ diff = comparison.get_diff(use_original_base=True)
+ patch_totals = comparison.head.report.apply_diff(diff)
+ if patch_totals is None or patch_totals.lines == 0:
+ # Coverage was not changed by patch
+ return (
+ (
+ StatusState.success.value,
+ ", passed because coverage was not affected by patch",
+ ),
+ helper_text,
+ )
+ coverage = Decimal(patch_totals.coverage)
+ if coverage == 100.0:
+ return (
+ (
+ StatusState.success.value,
+ ", passed because patch was fully covered by tests, and no indirect coverage changes",
+ ),
+ helper_text,
+ )
+ return None, helper_text
+
+ def get_project_status(
+ self, comparison: ComparisonProxy | FilteredComparison, notification_type: str
+ ) -> StatusResult:
+ result = self._get_project_status(
+ comparison, notification_type=notification_type
+ )
+ if result["state"] == StatusState.success.value:
+ return result
+
+ # Possibly pass the status check via removed_code_behavior
+ # The removed code behavior can change the `state` from `failure` to `success` and add to the `message`.
+ # We need both reports to be able to get the diff and apply the removed_code behavior
+ if comparison.project_coverage_base.report and comparison.head.report:
+ is_custom_rcb = True
+ removed_code_behavior = self.notifier_yaml_settings.get(
+ "removed_code_behavior", None
+ )
+ if removed_code_behavior is None:
+ is_custom_rcb = False
+ removed_code_behavior = self.DEFAULT_REMOVED_CODE_BEHAVIOR
+
+ # Apply removed_code_behavior
+ removed_code_result = None
+ if removed_code_behavior == "removals_only":
+ removed_code_result = self._apply_removals_only_behavior(comparison)
+ elif removed_code_behavior == "adjust_base":
+ removed_code_result, helper_text = self._apply_adjust_base_behavior(
+ comparison,
+ notification_type=notification_type,
+ )
+ if is_custom_rcb:
+ # if user set this in their yaml, give them helper text related to it
+ result["included_helper_text"].update(helper_text)
+ elif removed_code_behavior == "fully_covered_patch":
+ removed_code_result, helper_text = (
+ self._apply_fully_covered_patch_behavior(
+ comparison,
+ notification_type=notification_type,
+ )
+ )
+ # if user set this in their yaml, give them helper text related to it
+ result["included_helper_text"].update(helper_text)
+ else:
+ if removed_code_behavior not in [False, "off"]:
+ log.warning(
+ "Unknown removed_code_behavior",
+ extra=dict(
+ removed_code_behavior=removed_code_behavior,
+ commit_id=comparison.head.commit.commitid,
+ ),
+ )
+ # Possibly change status
+ if removed_code_result:
+ removed_code_state, removed_code_message = removed_code_result
+ if removed_code_state == StatusState.success.value:
+ # the status was failure, has been changed to success through RCB settings
+ # since the status is no longer failing, remove any included_helper_text
+ result["included_helper_text"] = {}
+ result["state"] = removed_code_state
+ result["message"] = result["message"] + removed_code_message
+ return result
+
+ def _get_threshold(self) -> Decimal:
+ """
+ Threshold can be configured by user, default is 0.0
+ """
+ threshold = self.notifier_yaml_settings.get("threshold", "0.0")
+
+ try:
+ # check if user has erroneously added a % to this input and fix
+ threshold = Decimal(str(threshold).replace("%", ""))
+ except (InvalidOperation, TypeError, AttributeError):
+ threshold = Decimal("0.0")
+ return threshold
+
+ def _get_target(
+ self, base_report_totals: ComparisonProxy | FilteredComparison
+ ) -> tuple[Decimal, bool]:
+ """
+ Target can be configured by user, default is auto, which is the coverage level from the base report.
+ """
+ if self.notifier_yaml_settings.get("target") not in ("auto", None):
+ # check if user has erroneously added a % to this input and fix
+ target_coverage = Decimal(
+ str(self.notifier_yaml_settings.get("target")).replace("%", "")
+ )
+ is_custom_target = True
+ else:
+ target_coverage = Decimal(base_report_totals.coverage)
+ is_custom_target = False
+ return target_coverage, is_custom_target
+
+ def _get_project_status(
+ self, comparison: ComparisonProxy | FilteredComparison, notification_type: str
+ ) -> StatusResult:
+ included_helper_text = {}
+ if (
+ not comparison.head.report
+ or (head_report_totals := comparison.head.report.totals) is None
+ or head_report_totals.coverage is None
+ ):
+ state = self.notifier_yaml_settings.get(
+ "if_not_found", StatusState.success.value
+ )
+ message = "No coverage information found on head"
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
+
+ base_report = comparison.project_coverage_base.report
+ if base_report is None:
+ # No base report - can't pass by offset coverage
+ state = self.notifier_yaml_settings.get(
+ "if_not_found", StatusState.success.value
+ )
+ message = "No report found to compare against"
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
+
+ base_report_totals = base_report.totals
+ if base_report_totals.coverage is None:
+ # Base report, no coverage on base report - can't pass by offset coverage
+ state = self.notifier_yaml_settings.get(
+ "if_not_found", StatusState.success.value
+ )
+ message = "No coverage information found on base report"
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
+
+ # Proper comparison head vs base report
+ threshold = self._get_threshold()
+ target_coverage, is_custom_target = self._get_target(base_report_totals)
+ head_coverage = Decimal(head_report_totals.coverage)
+ head_coverage_rounded = round_number(self.current_yaml, head_coverage)
+
+ # threshold is used to determine success/failure, but is not included in messaging
+ state = (
+ StatusState.success.value
+ if head_coverage >= (target_coverage - threshold)
+ else StatusState.failure.value
+ )
+
+ if is_custom_target:
+ # Explicit target coverage defined in YAML
+ # use rounded numbers for messages
+ target_rounded = round_number(self.current_yaml, target_coverage)
+ message = f"{head_coverage_rounded}% (target {target_rounded}%)"
+ if state == StatusState.failure.value:
+ helper_text = HELPER_TEXT_MAP[
+ HelperTextKey.CUSTOM_TARGET_PROJECT
+ ].value.format(
+ context=self.context,
+ notification_type=notification_type,
+ point_of_comparison=self.point_of_comparison,
+ coverage=head_coverage_rounded,
+ target=target_rounded,
+ )
+ included_helper_text[HelperTextKey.CUSTOM_TARGET_PROJECT] = helper_text
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
+
+ # use rounded numbers for messages
+ change_coverage_rounded = round_number(
+ self.current_yaml, head_coverage - target_coverage
+ )
+ message = f"{head_coverage_rounded}% ({change_coverage_rounded:+}%) compared to {comparison.project_coverage_base.commit.commitid[:7]}"
+ return StatusResult(
+ state=state, message=message, included_helper_text=included_helper_text
+ )
diff --git a/apps/worker/services/notification/notifiers/slack.py b/apps/worker/services/notification/notifiers/slack.py
new file mode 100644
index 0000000000..fbf2c3c7c2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/slack.py
@@ -0,0 +1,48 @@
+from database.enums import Notification
+from services.notification.notifiers.generics import (
+ Comparison,
+ RequestsYamlBasedNotifier,
+)
+from services.urls import get_commit_url, get_graph_url
+
+
+class SlackNotifier(RequestsYamlBasedNotifier):
+ BASE_MESSAGE = " ".join(
+ [
+ "Coverage for <{head_url}|{owner_username}/{repo_name}>",
+ "{comparison_string}on `{head_branch}` is `{head_totals_c}%`",
+ "via `<{head_url}|{head_short_commitid}>`",
+ ]
+ )
+
+ COMPARISON_STRING = (
+ "*{compare_message}* `<{compare_url}|{compare_notation}{compare_coverage}%>` "
+ )
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.slack
+
+ def build_payload(self, comparison: Comparison) -> dict:
+ message = self.generate_message(comparison)
+ compare_dict = self.generate_compare_dict(comparison)
+ color = "good" if compare_dict["notation"] in ("", "+") else "bad"
+ attachments = [
+ {
+ "fallback": "Commit sunburst attachment",
+ "color": color,
+ "title": "Commit Sunburst",
+ "title_link": get_commit_url(comparison.head.commit),
+ "image_url": get_graph_url(
+ comparison.head.commit, "sunburst.svg", size=100
+ ),
+ }
+ for attachment_type in self.notifier_yaml_settings.get("attachments", [])
+ if attachment_type == "sunburst"
+ ]
+ return {
+ "text": message,
+ "author_name": "Codecov",
+ "author_link": get_commit_url(comparison.head.commit),
+ "attachments": attachments,
+ }
diff --git a/apps/worker/services/notification/notifiers/status/__init__.py b/apps/worker/services/notification/notifiers/status/__init__.py
new file mode 100644
index 0000000000..c4c636fadf
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/status/__init__.py
@@ -0,0 +1,4 @@
+# ruff: noqa: F401
+from services.notification.notifiers.status.changes import ChangesStatusNotifier
+from services.notification.notifiers.status.patch import PatchStatusNotifier
+from services.notification.notifiers.status.project import ProjectStatusNotifier
diff --git a/apps/worker/services/notification/notifiers/status/base.py b/apps/worker/services/notification/notifiers/status/base.py
new file mode 100644
index 0000000000..eeaebadca9
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/status/base.py
@@ -0,0 +1,390 @@
+import logging
+from typing import Optional
+
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from shared.config import get_config
+from shared.helpers.cache import NO_VALUE, cache, make_hash_sha256
+from shared.torngit.exceptions import TorngitClientError, TorngitError
+
+from helpers.match import match
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.base import (
+ AbstractBaseNotifier,
+ NotificationResult,
+)
+from services.urls import get_commit_url, get_pull_url
+from services.yaml import read_yaml_field
+from services.yaml.reader import get_paths_from_flags
+
+log = logging.getLogger(__name__)
+
+
+class StatusNotifier(AbstractBaseNotifier):
+ def is_enabled(self) -> bool:
+ return True
+
+ def store_results(self, comparison: ComparisonProxy, result: NotificationResult):
+ pass
+
+ @property
+ def name(self):
+ return f"status-{self.context}"
+
+ def build_payload(self, comparison: ComparisonProxy | FilteredComparison) -> dict:
+ raise NotImplementedError()
+
+ def get_upgrade_message(self) -> str:
+ # TODO: this is the message in the PR author billing spec but maybe we should add the actual username?
+ return "Please activate this user to display a detailed status check"
+
+ def get_status_check_for_empty_upload(self):
+ if self.is_passing_empty_upload():
+ return ("success", "Non-testable files changed.")
+
+ if self.is_failing_empty_upload():
+ return ("failure", "Testable files changed")
+
+ def can_we_set_this_status(self, comparison: ComparisonProxy) -> bool:
+ head = comparison.head.commit
+ pull = comparison.pull
+ if (
+ self.notifier_yaml_settings.get("only_pulls")
+ or self.notifier_yaml_settings.get("base") == "pr"
+ ) and not pull:
+ return False
+ if not match(self.notifier_yaml_settings.get("branches"), head.branch):
+ return False
+ return True
+
+ def determine_status_check_behavior_to_apply(
+ self, comparison: ComparisonProxy, field_name
+ ) -> str | None:
+ """
+ Used for fields that can be set at the global level for all checks in "default_rules", or at the component level for an individual check.
+ For more context, see https://docs.codecov.io/docs/commit-status#default_rules
+ """
+ # Get the component level setting, if one is specified
+ component_behavior = self.notifier_yaml_settings.get(field_name)
+ # Get the value set at the global level via the default_rules key. This can be 'None' if no value was provided.
+ # If provided, this is populated either by the YAML file directly or by the defaults set in 'shared'.
+ default_rules_behavior = read_yaml_field(
+ self.current_yaml, ("coverage", "status", "default_rules", field_name)
+ )
+
+ behavior_to_apply = (
+ component_behavior
+ if component_behavior is not None
+ else default_rules_behavior
+ )
+
+ return behavior_to_apply
+
+ def flag_coverage_was_uploaded(self, comparison: ComparisonProxy) -> bool:
+ """
+ Indicates whether coverage was uploaded for any of the flags on this status check.
+ If there are no flags on the status check, this will return true.
+ If there are multiple flags on the status check, this will return true if at least one of them has uploaded coverage.
+ """
+
+ flags_included_in_status_check = set(
+ self.notifier_yaml_settings.get("flags") or []
+ )
+ if not flags_included_in_status_check:
+ return True
+ report_uploaded_flags = comparison.head.report.get_uploaded_flags()
+ return (
+ len(report_uploaded_flags.intersection(flags_included_in_status_check)) > 0
+ )
+
+ def get_notifier_filters(self) -> dict:
+ flag_list = self.notifier_yaml_settings.get("flags") or []
+ return dict(
+ path_patterns=set(
+ get_paths_from_flags(self.current_yaml, flag_list)
+ + (self.notifier_yaml_settings.get("paths") or [])
+ ),
+ flags=flag_list,
+ )
+
+ def required_builds(self, comparison: ComparisonProxy) -> bool:
+ flags = self.notifier_yaml_settings.get("flags") or []
+ head_report = comparison.head.report
+
+ for flag in flags:
+ flag_configuration = self.current_yaml.get_flag_configuration(flag)
+ if flag_configuration and head_report and head_report.sessions:
+ number_of_occ = 0
+ for session in head_report.sessions.values():
+ if session.flags and flag in session.flags:
+ number_of_occ += 1
+ needed_builds = flag_configuration.get("after_n_builds", 0)
+ if number_of_occ < needed_builds:
+ log.info(
+ "Flag needs more builds to send status check",
+ extra=dict(
+ flag=flag,
+ needed_builds=needed_builds,
+ number_of_occ=number_of_occ,
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ ),
+ )
+ return False
+ return True
+
+ def get_github_app_used(self) -> int | None:
+ torngit = self.repository_service
+ if torngit is None:
+ return None
+ torngit_installation = torngit.data.get("installation")
+ selected_installation_id = (
+ torngit_installation.get("id") if torngit_installation else None
+ )
+ return selected_installation_id
+
+ @sentry_sdk.trace
+ def notify(
+ self,
+ comparison: ComparisonProxy,
+ status_or_checks_helper_text: Optional[dict[str, str]] = None,
+ ) -> NotificationResult:
+ payload = None
+ if not self.can_we_set_this_status(comparison):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="not_fit_criteria",
+ data_sent=None,
+ )
+ if not self.required_builds(comparison):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="need_more_builds",
+ data_sent=None,
+ )
+ # Filter the coverage report based on fields in this notification's YAML settings
+ # e.g. if "paths" is specified, exclude the coverage not on those paths
+ try:
+ # If flag coverage wasn't uploaded, apply the appropriate behavior
+ flag_coverage_not_uploaded_behavior = (
+ self.determine_status_check_behavior_to_apply(
+ comparison, "flag_coverage_not_uploaded_behavior"
+ )
+ )
+ if not comparison.has_head_report():
+ payload = self.build_payload(comparison)
+ elif (
+ flag_coverage_not_uploaded_behavior == "exclude"
+ and not self.flag_coverage_was_uploaded(comparison)
+ ):
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="exclude_flag_coverage_not_uploaded_checks",
+ data_sent=None,
+ data_received=None,
+ )
+ elif (
+ flag_coverage_not_uploaded_behavior == "pass"
+ and not self.flag_coverage_was_uploaded(comparison)
+ ):
+ filtered_comparison = comparison.get_filtered_comparison(
+ **self.get_notifier_filters()
+ )
+ payload = self.build_payload(filtered_comparison)
+ payload["state"] = "success"
+ payload["message"] = (
+ payload["message"]
+ + " [Auto passed due to carriedforward or missing coverage]"
+ )
+ else:
+ filtered_comparison = comparison.get_filtered_comparison(
+ **self.get_notifier_filters()
+ )
+ payload = self.build_payload(filtered_comparison)
+ if comparison.pull:
+ payload["url"] = get_pull_url(comparison.pull)
+ else:
+ payload["url"] = get_commit_url(comparison.head.commit)
+
+ return self.maybe_send_notification(comparison, payload)
+ except TorngitClientError:
+ log.warning(
+ "Unable to send status notification to user due to a client-side error",
+ exc_info=True,
+ extra=dict(
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ notifier_name=self.name,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="client_side_error_provider",
+ data_sent=payload,
+ )
+ except TorngitError:
+ log.warning(
+ "Unable to send status notification to user due to an unexpected error",
+ exc_info=True,
+ extra=dict(
+ repoid=comparison.head.commit.repoid,
+ commit=comparison.head.commit.commitid,
+ notifier_name=self.name,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="server_side_error_provider",
+ data_sent=payload,
+ )
+
+ def status_already_exists(
+ self, comparison: ComparisonProxy, title, state, description
+ ) -> bool:
+ statuses = comparison.get_existing_statuses()
+ if statuses:
+ exists = statuses.get(title)
+ return (
+ exists
+ and exists["state"] == state
+ and exists["description"] == description
+ )
+ return False
+
+ def get_status_external_name(self) -> str:
+ status_piece = f"/{self.title}" if self.title != "default" else ""
+ return f"codecov/{self.context}{status_piece}"
+
+ def maybe_send_notification(
+ self, comparison: ComparisonProxy, payload: dict
+ ) -> NotificationResult:
+ base_commit = (
+ comparison.project_coverage_base.commit
+ if comparison.project_coverage_base
+ else None
+ )
+ head_commit = comparison.head.commit if comparison.head else None
+
+ cache_key = "cache:" + make_hash_sha256(
+ dict(
+ type="status_check_notification",
+ repoid=head_commit.repoid,
+ base_commitid=base_commit.commitid if base_commit else None,
+ head_commitid=head_commit.commitid if head_commit else None,
+ notifier_name=self.name,
+ notifier_title=self.title,
+ )
+ )
+
+ last_payload = cache.get_backend().get(cache_key)
+ if last_payload is NO_VALUE or last_payload != payload:
+ ttl = int(
+ get_config("setup", "cache", "send_status_notification", default=600)
+ ) # 10 min default
+ cache.get_backend().set(cache_key, ttl, payload)
+ return self.send_notification(comparison, payload)
+ else:
+ log.info(
+ "Notification payload unchanged. Skipping notification.",
+ extra=dict(
+ repoid=head_commit.repoid,
+ base_commitid=base_commit.commitid if base_commit else None,
+ head_commitid=head_commit.commitid if head_commit else None,
+ notifier_name=self.name,
+ notifier_title=self.title,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="payload_unchanged",
+ data_sent=None,
+ )
+
+ def send_notification(self, comparison: ComparisonProxy, payload):
+ repository_service = self.repository_service
+ title = self.get_status_external_name()
+ head_commit_sha = comparison.head.commit.commitid
+ head_report = comparison.head.report
+ state = payload["state"]
+ message = payload["message"]
+ url = payload["url"]
+ if self.status_already_exists(comparison, title, state, message):
+ log.info(
+ "Status already set",
+ extra=dict(context=title, description=message, state=state),
+ )
+ return NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="already_done",
+ data_sent={"title": title, "state": state, "message": message},
+ )
+
+ state = "success" if self.notifier_yaml_settings.get("informational") else state
+
+ notification_result_data_sent = {
+ "title": title,
+ "state": state,
+ "message": message,
+ }
+
+ if payload.get("included_helper_text"):
+ notification_result_data_sent["included_helper_text"] = payload[
+ "included_helper_text"
+ ]
+
+ all_shas_to_notify = [head_commit_sha] + list(
+ comparison.context.gitlab_extra_shas or set()
+ )
+ if len(all_shas_to_notify) > 1:
+ log.info(
+ "Notifying multiple SHAs",
+ extra=dict(all_shas=all_shas_to_notify, commit=head_commit_sha),
+ )
+
+ try:
+ _set_commit_status = async_to_sync(repository_service.set_commit_status)
+ head_coverage = head_report and head_report.totals.coverage
+ all_results = [
+ _set_commit_status(
+ commitid,
+ state,
+ title,
+ description=message,
+ url=url,
+ coverage=(float(head_coverage) if head_coverage else 0),
+ )
+ for commitid in all_shas_to_notify
+ ]
+ res = all_results[0]
+
+ except TorngitClientError:
+ log.warning(
+ "Status not posted because this user can see but not set statuses on this repo",
+ extra=dict(
+ data_sent=notification_result_data_sent,
+ commit=head_commit_sha,
+ repoid=comparison.head.commit.repoid,
+ ),
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="no_write_permission",
+ data_sent=notification_result_data_sent,
+ data_received=None,
+ )
+ return NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent=notification_result_data_sent,
+ data_received={"id": res.get("id", "NO_ID")},
+ github_app_used=self.get_github_app_used(),
+ )
diff --git a/apps/worker/services/notification/notifiers/status/changes.py b/apps/worker/services/notification/notifiers/status/changes.py
new file mode 100644
index 0000000000..08eac62a82
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/status/changes.py
@@ -0,0 +1,46 @@
+import logging
+
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.mixins.status import (
+ StatusChangesMixin,
+ StatusResult,
+)
+from services.notification.notifiers.status.base import StatusNotifier
+
+log = logging.getLogger(__name__)
+
+
+class ChangesStatusNotifier(StatusChangesMixin, StatusNotifier):
+ """This status analyzes the "unexpected changes" (see services/notification/changes.py
+ for a better description) and covered lines within it
+
+ Attributes:
+ context (str): The context
+
+ Possible results
+ - 'No unexpected coverage changes found.'
+ - {0} {1} unexpected coverage changes not visible in diff
+ - Unable to determine changes, no report found at pull request base
+ """
+
+ context = "changes"
+ notification_type_display_name = "status"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.status_changes
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> StatusResult:
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ return StatusResult(state=state, message=message, included_helper_text={})
+ result = self.get_changes_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ if self.should_use_upgrade_decoration():
+ result["message"] = self.get_upgrade_message()
+
+ return result
diff --git a/apps/worker/services/notification/notifiers/status/patch.py b/apps/worker/services/notification/notifiers/status/patch.py
new file mode 100644
index 0000000000..10c52ba4e2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/status/patch.py
@@ -0,0 +1,40 @@
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.mixins.status import StatusPatchMixin, StatusResult
+from services.notification.notifiers.status.base import StatusNotifier
+
+
+class PatchStatusNotifier(StatusPatchMixin, StatusNotifier):
+ """This status analyzes the git patch and sees covered lines within it
+
+ Attributes:
+ context (str): The context
+
+ Possible results
+ - No report found to compare against
+ - f'{coverage_str}% of diff hit (within {threshold_str}% threshold of {target_str}%)'
+ - {coverage_str}% of diff hit (target {target_str}%)
+ """
+
+ context = "patch"
+ notification_type_display_name = "status"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.status_patch
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> StatusResult:
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ result = StatusResult(state=state, message=message, included_helper_text={})
+ return result
+
+ result = self.get_patch_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ if self.should_use_upgrade_decoration():
+ result["message"] = self.get_upgrade_message()
+
+ return result
diff --git a/apps/worker/services/notification/notifiers/status/project.py b/apps/worker/services/notification/notifiers/status/project.py
new file mode 100644
index 0000000000..702ed4bfab
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/status/project.py
@@ -0,0 +1,49 @@
+import logging
+
+from database.enums import Notification
+from services.comparison import ComparisonProxy, FilteredComparison
+from services.notification.notifiers.mixins.status import (
+ StatusProjectMixin,
+ StatusResult,
+)
+from services.notification.notifiers.status.base import StatusNotifier
+
+log = logging.getLogger(__name__)
+
+
+class ProjectStatusNotifier(StatusProjectMixin, StatusNotifier):
+ """
+
+ Attributes:
+ context (str): The context
+
+ Possible results
+ - 100% remains the same compared to 29320f9
+ - 57.42% (+<.01%) compared to 559fe9e
+ - 85.65% (target 87%)
+ - No report found to compare against
+
+ Not implemented results (yet):
+ - Absolute coverage decreased by -{0}% but relative coverage ...
+ """
+
+ context = "project"
+ notification_type_display_name = "status"
+
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.status_project
+
+ def build_payload(
+ self, comparison: ComparisonProxy | FilteredComparison
+ ) -> StatusResult:
+ if self.is_empty_upload():
+ state, message = self.get_status_check_for_empty_upload()
+ return StatusResult(state=state, message=message, included_helper_text={})
+
+ result = self.get_project_status(
+ comparison, notification_type=self.notification_type_display_name
+ )
+ if self.should_use_upgrade_decoration():
+ result["message"] = self.get_upgrade_message()
+ return result
diff --git a/apps/worker/services/notification/notifiers/tests/__init__.py b/apps/worker/services/notification/notifiers/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/notification/notifiers/tests/conftest.py b/apps/worker/services/notification/notifiers/tests/conftest.py
new file mode 100644
index 0000000000..fca2dce59d
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/conftest.py
@@ -0,0 +1,836 @@
+from datetime import datetime, timezone
+
+import pytest
+from shared.reports.readonly import ReadOnlyReport
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportLine
+from shared.utils.sessions import Session
+
+from database.tests.factories import (
+ CommitFactory,
+ OwnerFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from services.archive import ArchiveService
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison, FullCommit
+from services.report import ReportService
+from services.repository import EnrichedPull
+
+
+def get_small_report(flags=None):
+ if flags is None:
+ flags = ["integration"]
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(11, 20))
+ )
+ first_file.append(3, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(51, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=flags))
+ return report
+
+
+@pytest.fixture
+def small_report():
+ return get_small_report()
+
+
+@pytest.fixture
+def sample_report_without_flags():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=None))
+ return report
+
+
+@pytest.fixture
+def sample_report():
+ report = Report()
+ first_file = ReportFile("file_1.go")
+ first_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
+ second_file = ReportFile("file_2.py")
+ second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ second_file.append(
+ 51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
+ )
+ report.append(first_file)
+ report.append(second_file)
+ report.add_session(Session(flags=["unit"]))
+ return report
+
+
+@pytest.fixture
+def sample_commit_with_report_already_carriedforward(dbsession, mock_storage):
+ sessions_dict = {
+ "0": {
+ "N": None,
+ "a": None,
+ "c": None,
+ "d": None,
+ "e": None,
+ "f": [],
+ "j": None,
+ "n": None,
+ "p": None,
+ "st": "uploaded",
+ "t": None,
+ "u": None,
+ },
+ "1": {
+ "N": None,
+ "a": None,
+ "c": None,
+ "d": None,
+ "e": None,
+ "f": ["unit"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "st": "uploaded",
+ "t": None,
+ "u": None,
+ },
+ "2": {
+ "N": None,
+ "a": None,
+ "c": None,
+ "d": None,
+ "e": None,
+ "f": ["enterprise"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "st": "carriedforward",
+ "t": None,
+ "u": None,
+ "se": {"carriedforward_from": "123456789sha"},
+ },
+ "3": {
+ "N": None,
+ "a": None,
+ "c": None,
+ "d": None,
+ "e": None,
+ "f": ["integration"],
+ "j": None,
+ "n": None,
+ "p": None,
+ "st": "carriedforward",
+ "t": None,
+ "u": None,
+ },
+ }
+ file_headers = {
+ "file_00.py": [
+ 0,
+ [0, 14, 12, 0, 2, "85.71429", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 14, 12, 0, 2, "85.71429", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_01.py": [
+ 1,
+ [0, 11, 8, 0, 3, "72.72727", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 11, 8, 0, 3, "72.72727", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_10.py": [
+ 10,
+ [0, 10, 6, 1, 3, "60.00000", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 10, 6, 1, 3, "60.00000", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_11.py": [
+ 11,
+ [0, 23, 15, 1, 7, "65.21739", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 23, 15, 1, 7, "65.21739", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_12.py": [
+ 12,
+ [0, 14, 8, 0, 6, "57.14286", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 14, 8, 0, 6, "57.14286", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_13.py": [
+ 13,
+ [0, 15, 9, 0, 6, "60.00000", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 15, 9, 0, 6, "60.00000", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_14.py": [
+ 14,
+ [0, 23, 13, 0, 10, "56.52174", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 23, 13, 0, 10, "56.52174", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_02.py": [
+ 2,
+ [0, 13, 9, 0, 4, "69.23077", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 13, 9, 0, 4, "69.23077", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_03.py": [
+ 3,
+ [0, 16, 8, 0, 8, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 16, 8, 0, 8, "50.00000", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_04.py": [
+ 4,
+ [0, 10, 6, 0, 4, "60.00000", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 10, 6, 0, 4, "60.00000", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_05.py": [
+ 5,
+ [0, 14, 10, 0, 4, "71.42857", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 14, 10, 0, 4, "71.42857", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_06.py": [
+ 6,
+ [0, 9, 7, 1, 1, "77.77778", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 9, 7, 1, 1, "77.77778", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_07.py": [
+ 7,
+ [0, 11, 9, 0, 2, "81.81818", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 11, 9, 0, 2, "81.81818", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_08.py": [
+ 8,
+ [0, 11, 6, 0, 5, "54.54545", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 11, 6, 0, 5, "54.54545", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ "file_09.py": [
+ 9,
+ [0, 14, 10, 1, 3, "71.42857", 0, 0, 0, 0, 0, 0, 0],
+ [None, None, None, [0, 14, 10, 1, 3, "71.42857", 0, 0, 0, 0, 0, 0, 0]],
+ None,
+ ],
+ }
+ commit = CommitFactory.create(
+ _report_json={"sessions": sessions_dict, "files": file_headers},
+ repository__owner__service="github",
+ repository__owner__integration_id="10000",
+ repository__using_integration=True,
+ )
+ dbsession.add(commit)
+ dbsession.flush()
+
+ with open("tasks/tests/samples/sample_chunks_4_sessions.txt") as f:
+ content = f.read().encode()
+ archive_hash = ArchiveService.get_archive_hash(commit.repository)
+ chunks_url = f"v4/repos/{archive_hash}/commits/{commit.commitid}/chunks.txt"
+ mock_storage.write_file("archive", chunks_url, content)
+
+ return commit
+
+
+@pytest.fixture
+def create_sample_comparison(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+
+ def _comparison(service="github", username="codecov-test"):
+ repository = RepositoryFactory.create(
+ owner__username=username,
+ owner__service=service,
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(commit=base_commit, report=get_small_report())
+ head_full_commit = FullCommit(commit=head_commit, report=sample_report)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(database_pull=pull, provider_pull={}),
+ )
+ )
+
+ return _comparison
+
+
+@pytest.fixture
+def sample_comparison(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ # Setting the time to _before_ patch centric default YAMLs start date of 2024-04-30
+ owner__createstamp=datetime(2023, 1, 1, tzinfo=timezone.utc),
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ author=OwnerFactory(username="codecov-test-user"),
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_coverage_carriedforward(
+ dbsession, request, sample_commit_with_report_already_carriedforward, mocker
+):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ head_commit = sample_commit_with_report_already_carriedforward
+ base_commit = CommitFactory.create(repository=head_commit.repository)
+
+ repository = head_commit.repository
+ dbsession.add(repository)
+ dbsession.flush()
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+
+ yaml_dict = {"flags": {"enterprise": {"carryforward": True}}}
+ report = ReportService(yaml_dict).get_existing_report_for_commit(head_commit)
+ report._totals = (
+ None # need to reset the report to get it to recalculate totals correctly
+ )
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(report)
+ )
+
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_negative_change(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_no_change(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_without_pull(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(database_pull=None, provider_pull=None),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_database_pull_without_provider(
+ dbsession, request, sample_report, mocker
+):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(database_pull=pull, provider_pull=None),
+ )
+ )
+
+
+def generate_sample_comparison(username, dbsession, base_report, head_report):
+ repository = RepositoryFactory.create(
+ owner__username=username,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(commit=base_commit, report=base_report)
+ head_full_commit = FullCommit(commit=head_commit, report=head_report)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_without_base_report(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(head_commit)
+ dbsession.add(base_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ base_full_commit = FullCommit(commit=base_commit, report=None)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_matching_flags(dbsession, request, sample_report):
+ base_report = ReadOnlyReport.create_from_report(get_small_report(flags=["unit"]))
+ head_report = ReadOnlyReport.create_from_report(sample_report)
+ return generate_sample_comparison(
+ request.node.name, dbsession, base_report, head_report
+ )
+
+
+@pytest.fixture
+def sample_comparison_without_base_with_pull(dbsession, request, sample_report, mocker):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base="base_commitid", head=head_commit.commitid
+ )
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ base_full_commit = FullCommit(commit=None, report=None)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid="cdf9aa4bd2c6bcd8a662864097cb62a85a2fd55b",
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {
+ "branch": "master",
+ "commitid": "cdf9aa4bd2c6bcd8a662864097cb62a85a2fd55b",
+ },
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_head_and_pull_head_differ(
+ dbsession, request, sample_report, mocker
+):
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ repository = RepositoryFactory.create(
+ owner__service="github",
+ owner__username="ThiagoCodecov",
+ name="example-python",
+ owner__unencrypted_oauth_token="testtlxuu2kfef3km1fbecdlmnb2nvpikvmoadi3",
+ image_token="abcdefghij",
+ owner__integration_id="10000",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository, author__service="github")
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author__service="github"
+ )
+ random_commit = CommitFactory.create(
+ repository=repository, author__service="github"
+ )
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(get_small_report())
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": random_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
diff --git a/apps/worker/services/notification/notifiers/tests/integration/__init__.py b/apps/worker/services/notification/notifiers/tests/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify.yaml
new file mode 100644
index 0000000000..8a4421905f
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify.yaml
@@ -0,0 +1,960 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:4535be1...ThiagoCodecov:2e2600a","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.patch","base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"merge_base_commit":{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjMwY2MxZWQ3NTFhNTlmYTllN2FkOGU3OWZmZjQxYTZmZTExZWY1ZGQ=","commit":{"author":{"name":"Thiago","email":"44376991+ThiagoCodecov@users.noreply.github.com","date":"2019-12-09T12:02:11Z"},"committer":{"name":"GitHub","email":"noreply@github.com","date":"2019-12-09T12:02:11Z"},"message":"Update
+ README.md","tree":{"sha":"c901b3cb75fd85aebf03f254494d5ad06b86777e","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/c901b3cb75fd85aebf03f254494d5ad06b86777e"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ PGP SIGNATURE-----\n\nwsBcBAABCAAQBQJd7jfDCRBK7hj4Ov3rIwAAdHIIAF8TrpEbraVNLuNObBiQefch\nVHGpc6MNxOnnRgGPZZl/egrosAxCJ2og+ArrjQG+D/7eYUhWeb73YPR8lWvVf6YN\nIkNSwv1o/2bmH3pma9DR4wDXnKeO/w/UPhNZaGC1w3dcvsHSJReSRkl/ipLxtPep\n3XpmC8RdIwFNU1hG8Dd5iKqJz3d3iA3mdkb3vrvYmKq2imQigSbXpOvL1o/Du9DN\nxE29+ZeGqTZf0Z5QHgJT7THw8P1bf/UJUado7nn6cIon/slDbP/ZirN3rgEJWXU6\neH9/bdS+HMcCwK5TZwJBXgRkbtzRIgc7mj5cTWOSX8XFuUnOS/QCyfkAIvajseU=\n=3PXD\n-----END
+ PGP SIGNATURE-----\n","payload":"tree c901b3cb75fd85aebf03f254494d5ad06b86777e\nparent
+ 2dfb36626ca60a8aa6d2bc624ad0f54b473bb436\nauthor Thiago <44376991+ThiagoCodecov@users.noreply.github.com>
+ 1575892931 -0300\ncommitter GitHub 1575892931 -0300\n\nUpdate
+ README.md"}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"web-flow","id":19864447,"node_id":"MDQ6VXNlcjE5ODY0NDQ3","avatar_url":"https://avatars3.githubusercontent.com/u/19864447?v=4","gravatar_id":"","url":"https://api.github.com/users/web-flow","html_url":"https://github.com/web-flow","followers_url":"https://api.github.com/users/web-flow/followers","following_url":"https://api.github.com/users/web-flow/following{/other_user}","gists_url":"https://api.github.com/users/web-flow/gists{/gist_id}","starred_url":"https://api.github.com/users/web-flow/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/web-flow/subscriptions","organizations_url":"https://api.github.com/users/web-flow/orgs","repos_url":"https://api.github.com/users/web-flow/repos","events_url":"https://api.github.com/users/web-flow/events{/privacy}","received_events_url":"https://api.github.com/users/web-flow/received_events","type":"User","site_admin":false},"parents":[{"sha":"2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436"}]},"status":"diverged","ahead_by":3,"behind_by":11,"total_commits":3,"commits":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmUwMjJmNTg4ZTQwMjY5ZjhhYjFjNTY4MDgwZGM0YTc2NjJmZjIzMzU=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"message":"69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA","tree":{"sha":"6188b6c086ebf6c63626abca649d2596b145c84c","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/6188b6c086ebf6c63626abca649d2596b145c84c"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd"}]},{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmMyYTA1YWExNWVjYWQ1YmVjMzdlMjliOWZlNTFlZjMwMTIwZjg2NDI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"message":"81905186-E567-4E4E-A5CB-3E5BFB820E57","tree":{"sha":"dd944d7739d5d429f264709345fe481c1e602be8","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/dd944d7739d5d429f264709345fe481c1e602be8"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335"}]},{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjJlMjYwMGFhMDk1MjVlMmUxZTFkOThiMDlkZTYxNDU0ZDI5Yzk0YmI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"message":"AE0A9904-3B05-405A-B8F7-A5DD3B38C92D","tree":{"sha":"f64d89541a0899881da6764081991e70ed43bfea","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/f64d89541a0899881da6764081991e70ed43bfea"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642"}]}],"files":[{"sha":"657d4c0ca181f2df83261d3ab1922d6ec47ece1e","filename":"README.md","status":"modified","additions":8,"deletions":0,"changes":8,"blob_url":"https://github.com/ThiagoCodecov/example-python/blob/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","raw_url":"https://github.com/ThiagoCodecov/example-python/raw/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/README.md?ref=2e2600aa09525e2e1e1d98b09de61454d29c94bb","patch":"@@
+ -1 +1,9 @@\n Now this\n+\n+\n+\n+80EC0808-DD33-42DF-8873-B2A605A82432\n+63FE7DEF-8367-47BD-B864-D86DE9F94358\n+69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA\n+81905186-E567-4E4E-A5CB-3E5BFB820E57\n+AE0A9904-3B05-405A-B8F7-A5DD3B38C92D"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:18 GMT
+ Etag:
+ - W/"44cd18200d235728506114fd6b930cca"
+ Last-Modified:
+ - Tue, 31 Dec 2019 03:23:55 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D081:309E:10C87AE:17A2BD6:5E0F9D8E
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4995'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15","id":350683791,"node_id":"MDExOlB1bGxSZXF1ZXN0MzUwNjgzNzkx","html_url":"https://github.com/ThiagoCodecov/example-python/pull/15","diff_url":"https://github.com/ThiagoCodecov/example-python/pull/15.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/pull/15.patch","issue_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15","number":15,"state":"open","locked":false,"title":"Thiago/test
+ 1","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"body":"","created_at":"2019-12-09T12:19:01Z","updated_at":"2019-12-09T12:19:01Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits","review_comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/comments","review_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/comments{/number}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/2e2600aa09525e2e1e1d98b09de61454d29c94bb","head":{"label":"ThiagoCodecov:thiago/test-1","ref":"thiago/test-1","sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"repo":{"id":156617777,"node_id":"MDEwOlJlcG9zaXRvcnkxNTY2MTc3Nzc=","name":"example-python","full_name":"ThiagoCodecov/example-python","private":false,"owner":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"html_url":"https://github.com/ThiagoCodecov/example-python","description":"Python
+ coverage example","fork":true,"url":"https://api.github.com/repos/ThiagoCodecov/example-python","forks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/forks","keys_url":"https://api.github.com/repos/ThiagoCodecov/example-python/keys{/key_id}","collaborators_url":"https://api.github.com/repos/ThiagoCodecov/example-python/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/ThiagoCodecov/example-python/teams","hooks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/hooks","issue_events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/events{/number}","events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/events","assignees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/assignees{/user}","branches_url":"https://api.github.com/repos/ThiagoCodecov/example-python/branches{/branch}","tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/tags","blobs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/refs{/sha}","trees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees{/sha}","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/{sha}","languages_url":"https://api.github.com/repos/ThiagoCodecov/example-python/languages","stargazers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/stargazers","contributors_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contributors","subscribers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscribers","subscription_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscription","commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits{/sha}","git_commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits{/sha}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/comments{/number}","issue_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments{/number}","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/{+path}","compare_url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/{base}...{head}","merges_url":"https://api.github.com/repos/ThiagoCodecov/example-python/merges","archive_url":"https://api.github.com/repos/ThiagoCodecov/example-python/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/ThiagoCodecov/example-python/downloads","issues_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues{/number}","pulls_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls{/number}","milestones_url":"https://api.github.com/repos/ThiagoCodecov/example-python/milestones{/number}","notifications_url":"https://api.github.com/repos/ThiagoCodecov/example-python/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/ThiagoCodecov/example-python/labels{/name}","releases_url":"https://api.github.com/repos/ThiagoCodecov/example-python/releases{/id}","deployments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/deployments","created_at":"2018-11-07T22:40:20Z","updated_at":"2019-12-31T03:24:04Z","pushed_at":"2019-12-31T03:24:02Z","git_url":"git://github.com/ThiagoCodecov/example-python.git","ssh_url":"git@github.com:ThiagoCodecov/example-python.git","clone_url":"https://github.com/ThiagoCodecov/example-python.git","svn_url":"https://github.com/ThiagoCodecov/example-python","homepage":"https://codecov.io","size":161,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":4,"license":null,"forks":0,"open_issues":4,"watchers":0,"default_branch":"master"}},"base":{"label":"ThiagoCodecov:master","ref":"master","sha":"d723f5cb5c9c9f48c47f2df97c47de20457d3fdc","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"repo":{"id":156617777,"node_id":"MDEwOlJlcG9zaXRvcnkxNTY2MTc3Nzc=","name":"example-python","full_name":"ThiagoCodecov/example-python","private":false,"owner":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"html_url":"https://github.com/ThiagoCodecov/example-python","description":"Python
+ coverage example","fork":true,"url":"https://api.github.com/repos/ThiagoCodecov/example-python","forks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/forks","keys_url":"https://api.github.com/repos/ThiagoCodecov/example-python/keys{/key_id}","collaborators_url":"https://api.github.com/repos/ThiagoCodecov/example-python/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/ThiagoCodecov/example-python/teams","hooks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/hooks","issue_events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/events{/number}","events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/events","assignees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/assignees{/user}","branches_url":"https://api.github.com/repos/ThiagoCodecov/example-python/branches{/branch}","tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/tags","blobs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/refs{/sha}","trees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees{/sha}","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/{sha}","languages_url":"https://api.github.com/repos/ThiagoCodecov/example-python/languages","stargazers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/stargazers","contributors_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contributors","subscribers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscribers","subscription_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscription","commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits{/sha}","git_commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits{/sha}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/comments{/number}","issue_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments{/number}","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/{+path}","compare_url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/{base}...{head}","merges_url":"https://api.github.com/repos/ThiagoCodecov/example-python/merges","archive_url":"https://api.github.com/repos/ThiagoCodecov/example-python/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/ThiagoCodecov/example-python/downloads","issues_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues{/number}","pulls_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls{/number}","milestones_url":"https://api.github.com/repos/ThiagoCodecov/example-python/milestones{/number}","notifications_url":"https://api.github.com/repos/ThiagoCodecov/example-python/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/ThiagoCodecov/example-python/labels{/name}","releases_url":"https://api.github.com/repos/ThiagoCodecov/example-python/releases{/id}","deployments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/deployments","created_at":"2018-11-07T22:40:20Z","updated_at":"2019-12-31T03:24:04Z","pushed_at":"2019-12-31T03:24:02Z","git_url":"git://github.com/ThiagoCodecov/example-python.git","ssh_url":"git@github.com:ThiagoCodecov/example-python.git","clone_url":"https://github.com/ThiagoCodecov/example-python.git","svn_url":"https://github.com/ThiagoCodecov/example-python","homepage":"https://codecov.io","size":161,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":4,"license":null,"forks":0,"open_issues":4,"watchers":0,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15"},"html":{"href":"https://github.com/ThiagoCodecov/example-python/pull/15"},"issue":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15"},"comments":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments"},"review_comments":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/comments"},"review_comment":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits"},"statuses":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/2e2600aa09525e2e1e1d98b09de61454d29c94bb"}},"author_association":"OWNER","merged":false,"mergeable":false,"rebaseable":false,"mergeable_state":"dirty","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":3,"additions":8,"deletions":0,"changed_files":1}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:19 GMT
+ Etag:
+ - W/"80de2d6223eb160b0cd153534733b75d"
+ Last-Modified:
+ - Fri, 03 Jan 2020 19:56:45 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D082:50AC:E05476:13C1D91:5E0F9D8E
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4994'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15
+- request:
+ body: '{"body": "## [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1)
+ Report\n> Merging [#15](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=desc)
+ into [master](None/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6&el=desc)
+ will **increase** coverage by `10.00%`.\n> The diff coverage is `n/a`.\n\n[](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\n\n```diff\n@@ Coverage
+ Diff @@\n## master #15 +/- ##\n===========================================\n+
+ Coverage 50.00% 60.00% +10.00% \n===========================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n===========================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| Flag | Coverage \u0394 |
+ |\n|---|---|---|\n| #unit | `100.00% <\u00f8> (?)` | |\n\n| [Impacted Files](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)
+ | Coverage \u0394 | |\n|---|---|---|\n| [file\\_2.py](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8yLnB5)
+ | `50.00% <0.00%> (\u00f8)` | :arrow_up: |\n| [file\\_1.go](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8xLmdv)
+ | `62.50% <0.00%> (+12.50%)` | :arrow_up: |\n\n------\n\n[Continue to review
+ full report at Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue).\n>
+ **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)\n>
+ `\u0394 = absolute (impact)`, `\u00f8 = not affected`, `? = missing
+ data`\n> Powered by [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=footer).
+ Last update [d723f5c...2e2600a](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=lastupdated).
+ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).\n"}'
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/570682170\"\
+ ,\"html_url\":\"https://github.com/ThiagoCodecov/example-python/pull/15#issuecomment-570682170\"\
+ ,\"issue_url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15\"\
+ ,\"id\":570682170,\"node_id\":\"MDEyOklzc3VlQ29tbWVudDU3MDY4MjE3MA==\",\"user\"\
+ :{\"login\":\"ThiagoCodecov\",\"id\":44376991,\"node_id\":\"MDQ6VXNlcjQ0Mzc2OTkx\"\
+ ,\"avatar_url\":\"https://avatars3.githubusercontent.com/u/44376991?v=4\",\"\
+ gravatar_id\":\"\",\"url\":\"https://api.github.com/users/ThiagoCodecov\",\"\
+ html_url\":\"https://github.com/ThiagoCodecov\",\"followers_url\":\"https://api.github.com/users/ThiagoCodecov/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/ThiagoCodecov/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/ThiagoCodecov/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/ThiagoCodecov/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/ThiagoCodecov/repos\",\"events_url\"\
+ :\"https://api.github.com/users/ThiagoCodecov/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/ThiagoCodecov/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2020-01-03T20:01:19Z\",\"updated_at\"\
+ :\"2020-01-03T20:01:19Z\",\"author_association\":\"OWNER\",\"body\":\"# [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1)\
+ \ Report\\n> Merging [#15](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=desc)\
+ \ into [master](None/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6&el=desc)\
+ \ will **increase** coverage by `10.00%`.\\n> The diff coverage is `n/a`.\\\
+ n\\n[](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## master\
+ \ #15 +/- ##\\n===========================================\\n+\
+ \ Coverage 50.00% 60.00% +10.00% \\n===========================================\\\
+ n Files 2 2 \\n Lines 6 10\
+ \ +4 \\n Branches 0 1 +1 \\n===========================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 3\
+ \ \\n- Partials 0 1 +1 \\n```\\n\\n|\
+ \ Flag | Coverage \u0394 | |\\n|---|---|---|\\n| #unit | `100.00% <\xF8> (?)`\
+ \ | |\\n\\n| [Impacted Files](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\
+ \ | Coverage \u0394 | |\\n|---|---|---|\\n| [file\\\\_2.py](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8yLnB5)\
+ \ | `50.00% <0.00%> (\xF8)` | :arrow_up: |\\n| [file\\\\_1.go](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8xLmdv)\
+ \ | `62.50% <0.00%> (+12.50%)` | :arrow_up: |\\n\\n------\\n\\n[Continue to\
+ \ review full report at Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue).\\\
+ n> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)\\\
+ n> `\u0394 = absolute (impact)`, `\xF8 = not affected`, `? = missing\
+ \ data`\\n> Powered by [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=footer).\
+ \ Last update [d723f5c...2e2600a](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=lastupdated).\
+ \ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).\\\
+ n\"}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Length:
+ - '3545'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:20 GMT
+ Etag:
+ - '"b9894b79f2e1d3509dad975b48cb566b"'
+ Location:
+ - https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/570682170
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 201 Created
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D083:50AA:26C744:38893C:5E0F9D8F
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4993'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 201
+ message: Created
+ status_code: 201
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits
+ response:
+ content: '[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmUwMjJmNTg4ZTQwMjY5ZjhhYjFjNTY4MDgwZGM0YTc2NjJmZjIzMzU=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"message":"69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA","tree":{"sha":"6188b6c086ebf6c63626abca649d2596b145c84c","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/6188b6c086ebf6c63626abca649d2596b145c84c"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd"}]},{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmMyYTA1YWExNWVjYWQ1YmVjMzdlMjliOWZlNTFlZjMwMTIwZjg2NDI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"message":"81905186-E567-4E4E-A5CB-3E5BFB820E57","tree":{"sha":"dd944d7739d5d429f264709345fe481c1e602be8","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/dd944d7739d5d429f264709345fe481c1e602be8"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335"}]},{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjJlMjYwMGFhMDk1MjVlMmUxZTFkOThiMDlkZTYxNDU0ZDI5Yzk0YmI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"message":"AE0A9904-3B05-405A-B8F7-A5DD3B38C92D","tree":{"sha":"f64d89541a0899881da6764081991e70ed43bfea","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/f64d89541a0899881da6764081991e70ed43bfea"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642"}]}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 17 Jan 2020 00:10:36 GMT
+ Etag:
+ - W/"ccf882511531ad012f6664fd8f73c441"
+ Last-Modified:
+ - Fri, 17 Jan 2020 00:10:01 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - 132D:1D4D:20EF65:2DD4BC:5E20FB7C
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4994'
+ X-Ratelimit-Reset:
+ - '1579222789'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/branches?per_page=100&page=1
+ response:
+ content: '[{"name":"master","commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654"},"protected":false},{"name":"random-branch","commit":{"sha":"b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6"},"protected":false},{"name":"thiago/base-no-base","commit":{"sha":"620b8042c2f846fcc6dda1c732dedd15cdbe76db","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/620b8042c2f846fcc6dda1c732dedd15cdbe76db"},"protected":false},{"name":"thiago/f/big-pt","commit":{"sha":"d55dc4ef748fd11537e50c9abed4ab1864fa1d94","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/d55dc4ef748fd11537e50c9abed4ab1864fa1d94"},"protected":false},{"name":"thiago/f/cool-branch","commit":{"sha":"89f3d20f2be3fb2d098e544814f8ce3636dc78c4","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/89f3d20f2be3fb2d098e544814f8ce3636dc78c4"},"protected":false},{"name":"thiago/f/something","commit":{"sha":"c9fb9262268f11b53c6b0682d4f9acac89bdeee5","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c9fb9262268f11b53c6b0682d4f9acac89bdeee5"},"protected":false},{"name":"thiago/test-1","commit":{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb"},"protected":false}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:24:01 GMT
+ ETag:
+ - W/"dab6303e47e5d12f9bb596bcb1e3fd8a7e482a11e1e543c0b1e05e279ea55fe3"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C46C:0919:18CCE0:32A952:64A24D61
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4997'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '3'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:93189ce...ThiagoCodecov:4535be1","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.patch","base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/ThiagoCodecov/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/ThiagoCodecov/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"merge_base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"status":"behind","ahead_by":0,"behind_by":52,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:24:01 GMT
+ ETag:
+ - W/"c625aec9cfb15b0fc585ff2dbcdaeef7a4e39d1881112104a691b9fb0f748c49"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C46D:8600:1BB361:387DFB:64A24D61
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4996'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '4'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:5b174c2...joseph-sentry:5601846","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.patch","base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"ahead","ahead_by":1,"behind_by":0,"total_commits":1,"commits":[{"sha":"5601846871b8142ab0df1e0b8774756c658bcc7d","node_id":"C_kwDOJu7MkNoAKDU2MDE4NDY4NzFiODE0MmFiMGRmMWUwYjg3NzQ3NTZjNjU4YmNjN2Q","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:16:17Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:23:50Z"},"message":"make
+ change","tree":{"sha":"3ca6632aeb641e579bea4391294d9ebe55c062c7","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/3ca6632aeb641e579bea4391294d9ebe55c062c7"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQAmeUxSTgmd3FlcaYtRmVpuCh0n6L0/2Oy7wnsoGF6zjLoBO+aDz22vOG+bywNp4bc\nlmOl+Tu5Jk9woOhH2Olg8=\n-----END
+ SSH SIGNATURE-----","payload":"tree 3ca6632aeb641e579bea4391294d9ebe55c062c7\nparent
+ 5b174c2b40d501a70c479e91025d5109b1ad5c1b\nauthor joseph-sentry
+ 1692026177 -0400\ncommitter joseph-sentry 1692026630
+ -0400\n\nmake change\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b"}]}],"files":[{"sha":"8664ca881cd0536ab32eac8dfee923a2b304167d","filename":"api/calculator/calculator.py","status":"modified","additions":1,"deletions":3,"changes":4,"blob_url":"https://github.com/joseph-sentry/codecov-demo/blob/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","raw_url":"https://github.com/joseph-sentry/codecov-demo/raw/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","contents_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/contents/api%2Fcalculator%2Fcalculator.py?ref=5601846871b8142ab0df1e0b8774756c658bcc7d","patch":"@@
+ -9,6 +9,4 @@ def multiply(x, y):\n return x * y\n \n def divide(x,
+ y):\n- if y == 0:\n- return ''Cannot divide by 0''\n- return
+ x * 1.0 / y\n\\ No newline at end of file\n+ return x * 1.0 / y"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"3bcbc91ba2aac4e70b8b74075bc06a0b7436e42a61c15cd11f8c28af48d74080"
+ Last-Modified:
+ - Mon, 14 Aug 2023 15:23:50 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E726:5597:D48B2:1B1274:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4987'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '13'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main
+ response:
+ content: '{"name":"main","commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"_links":{"self":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main","html":"https://github.com/joseph-sentry/codecov-demo/tree/main"},"protected":false,"protection":{"enabled":false,"required_status_checks":{"enforcement_level":"off","contexts":[],"checks":[]}},"protection_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main/protection"}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"28690f5aa55bcb187fc062a1ab6ad5901a1ccbe0225d6f96dacf712da8e22306"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E727:0526:134901:270CDD:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4986'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '14'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:38c2d02...joseph-sentry:5b174c2","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.patch","base_commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"behind","ahead_by":0,"behind_by":2,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"fb71dcba3dddf292e08598b556f7bcc763019abc6e1678ed3355d3d8ec57454f"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E728:44A9:1226D9:24CF0C:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4985'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '15'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ Report\n> Merging [#9](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ (5601846) into [main](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ (5b174c2) will **increase** coverage by `10.00%`.\n> Report is 2 commits behind
+ head on main.\n> The diff coverage is `n/a`.\n\n:exclamation: Your organization
+ is not using the GitHub App Integration. As a result you may experience degraded
+ service beginning May 15th. Please [install the GitHub App Integration](https://github.com/apps/codecov)
+ for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n\n```diff\n@@ Coverage
+ Diff @@\n## main #9 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | Coverage \u0394 | Complexity \u0394 | |\n|---|---|---|---|\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | `?` | `?` | |\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried forward
+ coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n\n------\n\n[Continue
+ to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n>
+ **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n>
+ `\u0394 = absolute (impact)`, `\u00f8 = not affected`, `? = missing
+ data`\n> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).
+ Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).
+ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '4719'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry/codecov-demo/pull/9#issuecomment-1699669247\"\
+ ,\"issue_url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9\"\
+ ,\"id\":1699669247,\"node_id\":\"IC_kwDOJu7MkM5lTuT_\",\"user\":{\"login\":\"\
+ joseph-sentry\",\"id\":136376984,\"node_id\":\"U_kgDOCCDymA\",\"avatar_url\"\
+ :\"https://avatars.githubusercontent.com/u/136376984?u=8154f2067e6b4966acba9c27358d6e49cfbbf45d&v=4\"\
+ ,\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/joseph-sentry\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry\",\"followers_url\":\"https://api.github.com/users/joseph-sentry/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/joseph-sentry/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/joseph-sentry/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/joseph-sentry/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/joseph-sentry/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/joseph-sentry/repos\",\"events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2023-08-30T18:44:43Z\",\"updated_at\"\
+ :\"2023-08-30T18:44:43Z\",\"author_association\":\"OWNER\",\"body\":\"## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ Report\\n> Merging [#9](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ (5601846) into [main](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ (5b174c2) will **increase** coverage by `10.00%`.\\n> Report is 2 commits\
+ \ behind head on main.\\n> The diff coverage is `n/a`.\\n\\n:exclamation: Your\
+ \ organization is not using the GitHub App Integration. As a result you may\
+ \ experience degraded service beginning May 15th. Please [install the GitHub\
+ \ App Integration](https://github.com/apps/codecov) for your organization. [Read\
+ \ more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n\\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ main #9 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | `?` | `?` | |\\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | `100.00% <\xF8> (?)` | `0.00 <\xF8> (?)` | |\\n\\nFlags with carried forward\
+ \ coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n\\n------\\n\\n[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n> `\u0394 = absolute (impact)`, `\xF8 = not affected`, `? = missing\
+ \ data`\\n> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\
+ \ Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\
+ \ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '6356'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:43 GMT
+ ETag:
+ - '"a3267b6fdb96428f156fb276116e0b4c918da9a371be840fc5573f132fbe1dbe"'
+ Location:
+ - https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E72A:35CC:109BE2:21B617:64EF8E1B
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4984'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '16'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_gitlab.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_gitlab.yaml
new file mode 100644
index 0000000000..713498b998
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_gitlab.yaml
@@ -0,0 +1,379 @@
+interactions:
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - gitlab.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://gitlab.com/api/v4/projects/47404140/repository/compare/?from=0fc784af11c401449e56b24a174bae7b9af86c98&to=0b6a213fc300cd328c0625f38f30432ee6e066e5
+ response:
+ content: '{"commit":{"id":"0b6a213fc300cd328c0625f38f30432ee6e066e5","short_id":"0b6a213f","created_at":"2023-06-27T12:30:01.000-04:00","parent_ids":["0c11f75d3f08db4bdf9a9f077e9e513990cd7a18"],"title":"5","message":"5\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-27T11:14:35.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-27T12:30:01.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/0b6a213fc300cd328c0625f38f30432ee6e066e5"},"commits":[{"id":"0c11f75d3f08db4bdf9a9f077e9e513990cd7a18","short_id":"0c11f75d","created_at":"2023-06-27T12:30:00.000-04:00","parent_ids":["5ad8e6ce797491865e815667384f93c87b1c7245"],"title":"4","message":"4\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-27T11:13:52.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-27T12:30:00.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/0c11f75d3f08db4bdf9a9f077e9e513990cd7a18"},{"id":"0b6a213fc300cd328c0625f38f30432ee6e066e5","short_id":"0b6a213f","created_at":"2023-06-27T12:30:01.000-04:00","parent_ids":["0c11f75d3f08db4bdf9a9f077e9e513990cd7a18"],"title":"5","message":"5\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-27T11:14:35.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-27T12:30:01.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/0b6a213fc300cd328c0625f38f30432ee6e066e5"}],"diffs":[{"diff":"","new_path":"4.txt","old_path":"4.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false},{"diff":"@@
+ -24,7 +24,4 @@ def test_multiply():\n assert Calculator.multiply(-4, 2.0)
+ == -8.0\n \n def test_divide():\n- assert Calculator.divide(1, 2) == 0.5\n- assert
+ Calculator.divide(1.0, 2.0) == 0.5\n- assert Calculator.divide(0, 2.0) ==
+ 0\n- assert Calculator.divide(-4, 2.0) == -2.0\n\\ No newline at end of file\n+ assert
+ Calculator.divide(1, 2) == 0.5\n\\ No newline at end of file\n","new_path":"api/calculator/test_calculator.py","old_path":"api/calculator/test_calculator.py","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false}],"compare_timeout":false,"compare_same_ref":false,"web_url":"https://gitlab.com/joseph-sentry/example-python/-/compare/5ad8e6ce797491865e815667384f93c87b1c7245...0b6a213fc300cd328c0625f38f30432ee6e066e5"}'
+ headers:
+ CF-Cache-Status:
+ - MISS
+ CF-RAY:
+ - 7e193b306ca136d8-YYZ
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ Connection:
+ - keep-alive
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 04 Jul 2023 17:54:03 GMT
+ Etag:
+ - W/"bb9ffde5bcafb3a779105cac464ca1f3"
+ GitLab-LB:
+ - fe-09-lb-gprd
+ GitLab-SV:
+ - api-gke-us-east1-b
+ NEL:
+ - '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ Report-To:
+ - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=JC6qR2pHaLG0QU%2B32UiLFIzlAktwMU3phdIK9YHtb%2Fqwb9VQUDg5NhBlrT0w%2BNxE4bI3xYU5sLUX4A6p9xGLx%2FqhSUOIbv6k6QbBDZSXrzl00VpN79NU90RWuvJdan8yD%2BTkrjaFAc0%3D"}],"group":"cf-nel","max_age":604800}'
+ Server:
+ - cloudflare
+ Set-Cookie:
+ - _cfuvid=3EG7MrO_vdP3KgZ_aLy9NpP3MSknAnAoW5OFYhLqsAQ-1688493243130-0-604800000;
+ path=/; domain=.gitlab.com; HttpOnly; Secure; SameSite=None
+ Strict-Transport-Security:
+ - max-age=31536000
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Origin, Accept-Encoding
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"6a5cbc836eac371c289f44dbc2313eb3","version":"1"}'
+ X-Request-Id:
+ - 6a5cbc836eac371c289f44dbc2313eb3
+ X-Runtime:
+ - '0.090368'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - gitlab.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://gitlab.com/api/v4/projects/47404140/repository/branches
+ response:
+ content: '[{"name":"behind","commit":{"id":"0b6a213fc300cd328c0625f38f30432ee6e066e5","short_id":"0b6a213f","created_at":"2023-06-27T12:30:01.000-04:00","parent_ids":["0c11f75d3f08db4bdf9a9f077e9e513990cd7a18"],"title":"5","message":"5\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-27T11:14:35.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-27T12:30:01.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/0b6a213fc300cd328c0625f38f30432ee6e066e5"},"merged":false,"protected":false,"developers_can_push":false,"developers_can_merge":false,"can_push":true,"default":false,"web_url":"https://gitlab.com/joseph-sentry/example-python/-/tree/behind"},{"name":"main","commit":{"id":"0fc784af11c401449e56b24a174bae7b9af86c98","short_id":"0fc784af","created_at":"2023-06-27T11:13:34.000-04:00","parent_ids":["b64b9b4c25c6c088781ead5eae424f156ee98d8c"],"title":"3","message":"3\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-27T11:13:34.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-27T11:13:34.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/0fc784af11c401449e56b24a174bae7b9af86c98"},"merged":false,"protected":true,"developers_can_push":false,"developers_can_merge":false,"can_push":true,"default":true,"web_url":"https://gitlab.com/joseph-sentry/example-python/-/tree/main"},{"name":"step3","commit":{"id":"61089a89aa216c556a17f5648bd89ef17cb54f63","short_id":"61089a89","created_at":"2023-06-13T12:17:26.000-04:00","parent_ids":["50e2fabc371fa2c8ba66295862df53deec038ac8"],"title":"use
+ token?","message":"use token?\n","author_name":"joseph-sentry","author_email":"joseph.sawaya@sentry.io","authored_date":"2023-06-13T12:17:26.000-04:00","committer_name":"joseph-sentry","committer_email":"joseph.sawaya@sentry.io","committed_date":"2023-06-13T12:17:26.000-04:00","trailers":{},"web_url":"https://gitlab.com/joseph-sentry/example-python/-/commit/61089a89aa216c556a17f5648bd89ef17cb54f63"},"merged":false,"protected":false,"developers_can_push":false,"developers_can_merge":false,"can_push":true,"default":false,"web_url":"https://gitlab.com/joseph-sentry/example-python/-/tree/step3"}]'
+ headers:
+ CF-Cache-Status:
+ - MISS
+ CF-RAY:
+ - 7e193b32190236d5-YYZ
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ Connection:
+ - keep-alive
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 04 Jul 2023 17:54:03 GMT
+ Etag:
+ - W/"ed688f50c71ff83cb17dadf6e336e69a"
+ GitLab-LB:
+ - fe-12-lb-gprd
+ GitLab-SV:
+ - api-gke-us-east1-b
+ Link:
+ - ;
+ rel="first", ;
+ rel="last"
+ NEL:
+ - '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ Report-To:
+ - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=TDqA%2Fh0ao1DGnX2YxEo3WW0wN13XAfEDbtjDd87%2BsNBpIUZULCm47m9y887VfLIIo1jDoPDoK3OQrs5d%2F2oSBAoU3%2F6JCkaJPlCssfBTfIoxt%2Ffr5RxsTf9TOJtqWSyu50Pi6fYBQ%2BU%3D"}],"group":"cf-nel","max_age":604800}'
+ Server:
+ - cloudflare
+ Set-Cookie:
+ - _cfuvid=lQx4LdgHz2oZpgnFLpvobY2Y.1L_u3FR11V.jRGMEY0-1688493243431-0-604800000;
+ path=/; domain=.gitlab.com; HttpOnly; Secure; SameSite=None
+ Strict-Transport-Security:
+ - max-age=31536000
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Origin, Accept-Encoding
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"4353ac9cc0ef373e12b71a68f1612cb6","version":"1"}'
+ X-Next-Page:
+ - ''
+ X-Page:
+ - '1'
+ X-Per-Page:
+ - '20'
+ X-Prev-Page:
+ - ''
+ X-Request-Id:
+ - 4353ac9cc0ef373e12b71a68f1612cb6
+ X-Runtime:
+ - '0.113054'
+ X-Total:
+ - '3'
+ X-Total-Pages:
+ - '1'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=h1&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)
+ Report\n> Merging [#1](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=desc&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)
+ (0b6a213) into [main](https://app.codecov.io/gl/joseph-sentry/example-python/commit/0fc784af11c401449e56b24a174bae7b9af86c98?el=desc&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)
+ (0fc784a) will **increase** coverage by `10.00%`.\n> The diff coverage is `n/a`.\n\n[](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=tree&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)\n\n```diff\n@@ Coverage
+ Diff @@\n## main #1 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| Flag | Coverage \u0394
+ | Complexity \u0394 | |\n|---|---|---|---|\n| integration | `?` | `?` | |\n|
+ unit | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried
+ forward coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)\n\n------\n\n[Continue
+ to review full report in Codecov by Sentry](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=continue&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French).\n>
+ **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French)\n>
+ `\u0394 = absolute (impact)`, `\u00f8 = not affected`, `? = missing
+ data`\n> Powered by [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=footer&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French).
+ Last update [0fc784a...0b6a213](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr&el=lastupdated&utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French).
+ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=gitlab&utm_content=comment&utm_campaign=pr+comments&utm_term=Gina+French).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '3644'
+ content-type:
+ - application/json
+ host:
+ - gitlab.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://gitlab.com/api/v4/projects/47404140/merge_requests/5/notes
+ response:
+ content: "{\"id\":1457135397,\"type\":null,\"body\":\"## [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=h1\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French) Report\\n\\u003e Merging\
+ \ [#1](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=desc\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French) (0b6a213) into [main](https://app.codecov.io/gl/joseph-sentry/example-python/commit/0fc784af11c401449e56b24a174bae7b9af86c98?el=desc\\\
+ u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French) (0fc784a) will **increase**\
+ \ coverage by `10.00%`.\\n\\u003e The diff coverage is `n/a`.\\n\\n[](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=tree\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French)\\n\\n```diff\\n@@\
+ \ Coverage Diff @@\\n## main #1\
+ \ +/- ##\\n=============================================\\n+ Coverage\
+ \ 50.00% 60.00% +10.00% \\n+ Complexity 11 10 \
+ \ -1 \\n=============================================\\n Files \
+ \ 2 2 \\n Lines 6 10 +4\
+ \ \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| Flag | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n|\
+ \ integration | `?` | `?` | |\\n| unit | `100.00% \\u003c\xF8\\u003e (?)` |\
+ \ `0.00 \\u003c\xF8\\u003e (?)` | |\\n\\nFlags with carried forward coverage\
+ \ won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral\\\
+ u0026utm_source=gitlab\\u0026utm_content=comment\\u0026utm_campaign=pr+comments\\\
+ u0026utm_term=Gina+French#carryforward-flags-in-the-pull-request-comment) to\
+ \ find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1/indirect-changes?src=pr\\\
+ u0026el=tree-more\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French)\\n\\n------\\n\\n[Continue\
+ \ to review full report in Codecov by Sentry](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=continue\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French).\\n\\u003e **Legend**\
+ \ - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral\\\
+ u0026utm_source=gitlab\\u0026utm_content=comment\\u0026utm_campaign=pr+comments\\\
+ u0026utm_term=Gina+French)\\n\\u003e `\u0394 = absolute \\u003crelative\\u003e\
+ \ (impact)`, `\xF8 = not affected`, `? = missing data`\\n\\u003e Powered by\
+ \ [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=footer\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French). Last update [0fc784a...0b6a213](https://app.codecov.io/gl/joseph-sentry/example-python/pull/1?src=pr\\\
+ u0026el=lastupdated\\u0026utm_medium=referral\\u0026utm_source=gitlab\\u0026utm_content=comment\\\
+ u0026utm_campaign=pr+comments\\u0026utm_term=Gina+French). Read the [comment\
+ \ docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral\\\
+ u0026utm_source=gitlab\\u0026utm_content=comment\\u0026utm_campaign=pr+comments\\\
+ u0026utm_term=Gina+French).\",\"attachment\":null,\"author\":{\"id\":15014576,\"\
+ username\":\"joseph-sentry\",\"name\":\"joseph-sentry\",\"state\":\"active\"\
+ ,\"avatar_url\":\"https://secure.gravatar.com/avatar/b8db468edebbae1ffa228d3095c230c5?s=80\\\
+ u0026d=identicon\",\"web_url\":\"https://gitlab.com/joseph-sentry\"},\"created_at\"\
+ :\"2023-07-04T17:54:03.693Z\",\"updated_at\":\"2023-07-04T17:54:03.693Z\",\"\
+ system\":false,\"noteable_id\":234535703,\"noteable_type\":\"MergeRequest\"\
+ ,\"project_id\":47404140,\"resolvable\":false,\"confidential\":false,\"internal\"\
+ :false,\"noteable_iid\":1,\"commands_changes\":{}}"
+ headers:
+ CF-Cache-Status:
+ - DYNAMIC
+ CF-RAY:
+ - 7e193b34483fa1d8-YYZ
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '4563'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 04 Jul 2023 17:54:03 GMT
+ Etag:
+ - W/"c385bbb79f9cd8c8ef5c8c20956b1d23"
+ GitLab-LB:
+ - fe-13-lb-gprd
+ GitLab-SV:
+ - gke-cny-api
+ NEL:
+ - '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ Report-To:
+ - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=ddX79d3f6rJGXEtRolagciIjbg9zjjbYpg65sv%2FAUNyXUtdEJIuthFhXPuYZVDi8byimBpfkor6ypIrXF07bWt331SxRnOdeKU6Imreqkfkite2Nx8%2FqRVRiMrH9r8vBFGe167NFSmc%3D"}],"group":"cf-nel","max_age":604800}'
+ Server:
+ - cloudflare
+ Set-Cookie:
+ - _cfuvid=YKIxWaDzvazpjHge8Q9AjvELoaYcqF5jWIN.5ym_YyQ-1688493243878-0-604800000;
+ path=/; domain=.gitlab.com; HttpOnly; Secure; SameSite=None
+ Strict-Transport-Security:
+ - max-age=31536000
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"a6cddfff67cb7349491082b18e233049","version":"1"}'
+ X-Request-Id:
+ - a6cddfff67cb7349491082b18e233049
+ X-Runtime:
+ - '0.228506'
+ http_version: HTTP/1.1
+ status_code: 201
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - gitlab.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://gitlab.com/api/v4/projects/47404140/repository/branches/main
+ response:
+ content: '{"message":"401 Unauthorized"}'
+ headers:
+ CF-Cache-Status:
+ - MISS
+ CF-RAY:
+ - 7fef2fd0182436fe-YYZ
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '30'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json
+ Date:
+ - Wed, 30 Aug 2023 18:44:44 GMT
+ GitLab-LB:
+ - fe-05-lb-gprd
+ GitLab-SV:
+ - localhost
+ NEL:
+ - '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
+ RateLimit-Limit:
+ - '2000'
+ RateLimit-Observed:
+ - '2'
+ RateLimit-Remaining:
+ - '1998'
+ RateLimit-Reset:
+ - '1693421144'
+ RateLimit-ResetTime:
+ - Wed, 30 Aug 2023 18:45:44 GMT
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ Report-To:
+ - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=lBgieih4yF%2BSFqCVeTidu8Y1w18hb7BV63ktECkxzQPrULx2APCFXvtqQDV6ZmRnfTxppDY%2F15MRyjs064ja6G4RylNKxJlsQ8C7GUpx67j3H%2BfnNW7ugAHn574%3D"}],"group":"cf-nel","max_age":604800}'
+ Server:
+ - cloudflare
+ Set-Cookie:
+ - _cfuvid=0RVizv.CnCUUI1sQDhOigxGHiJY1v6BbpkmTQn7RUNM-1693421084336-0-604800000;
+ path=/; domain=.gitlab.com; HttpOnly; Secure; SameSite=None
+ Strict-Transport-Security:
+ - max-age=31536000
+ Vary:
+ - Origin, Accept-Encoding
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"0a55ef3f31f3613df4edf90f82a78c20","version":"1"}'
+ X-Request-Id:
+ - 0a55ef3f31f3613df4edf90f82a78c20
+ X-Runtime:
+ - '0.034387'
+ http_version: HTTP/1.1
+ status_code: 401
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_new_layout.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_new_layout.yaml
new file mode 100644
index 0000000000..f3fc010da8
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_new_layout.yaml
@@ -0,0 +1,821 @@
+interactions:
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:4535be1...ThiagoCodecov:2e2600a","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.patch","base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"merge_base_commit":{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjMwY2MxZWQ3NTFhNTlmYTllN2FkOGU3OWZmZjQxYTZmZTExZWY1ZGQ=","commit":{"author":{"name":"Thiago","email":"44376991+ThiagoCodecov@users.noreply.github.com","date":"2019-12-09T12:02:11Z"},"committer":{"name":"GitHub","email":"noreply@github.com","date":"2019-12-09T12:02:11Z"},"message":"Update
+ README.md","tree":{"sha":"c901b3cb75fd85aebf03f254494d5ad06b86777e","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/c901b3cb75fd85aebf03f254494d5ad06b86777e"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ PGP SIGNATURE-----\n\nwsBcBAABCAAQBQJd7jfDCRBK7hj4Ov3rIwAAdHIIAF8TrpEbraVNLuNObBiQefch\nVHGpc6MNxOnnRgGPZZl/egrosAxCJ2og+ArrjQG+D/7eYUhWeb73YPR8lWvVf6YN\nIkNSwv1o/2bmH3pma9DR4wDXnKeO/w/UPhNZaGC1w3dcvsHSJReSRkl/ipLxtPep\n3XpmC8RdIwFNU1hG8Dd5iKqJz3d3iA3mdkb3vrvYmKq2imQigSbXpOvL1o/Du9DN\nxE29+ZeGqTZf0Z5QHgJT7THw8P1bf/UJUado7nn6cIon/slDbP/ZirN3rgEJWXU6\neH9/bdS+HMcCwK5TZwJBXgRkbtzRIgc7mj5cTWOSX8XFuUnOS/QCyfkAIvajseU=\n=3PXD\n-----END
+ PGP SIGNATURE-----\n","payload":"tree c901b3cb75fd85aebf03f254494d5ad06b86777e\nparent
+ 2dfb36626ca60a8aa6d2bc624ad0f54b473bb436\nauthor Thiago <44376991+ThiagoCodecov@users.noreply.github.com>
+ 1575892931 -0300\ncommitter GitHub 1575892931 -0300\n\nUpdate
+ README.md"}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"web-flow","id":19864447,"node_id":"MDQ6VXNlcjE5ODY0NDQ3","avatar_url":"https://avatars.githubusercontent.com/u/19864447?v=4","gravatar_id":"","url":"https://api.github.com/users/web-flow","html_url":"https://github.com/web-flow","followers_url":"https://api.github.com/users/web-flow/followers","following_url":"https://api.github.com/users/web-flow/following{/other_user}","gists_url":"https://api.github.com/users/web-flow/gists{/gist_id}","starred_url":"https://api.github.com/users/web-flow/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/web-flow/subscriptions","organizations_url":"https://api.github.com/users/web-flow/orgs","repos_url":"https://api.github.com/users/web-flow/repos","events_url":"https://api.github.com/users/web-flow/events{/privacy}","received_events_url":"https://api.github.com/users/web-flow/received_events","type":"User","site_admin":false},"parents":[{"sha":"2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436"}]},"status":"diverged","ahead_by":3,"behind_by":11,"total_commits":3,"commits":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmUwMjJmNTg4ZTQwMjY5ZjhhYjFjNTY4MDgwZGM0YTc2NjJmZjIzMzU=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"message":"69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA","tree":{"sha":"6188b6c086ebf6c63626abca649d2596b145c84c","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/6188b6c086ebf6c63626abca649d2596b145c84c"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd"}]},{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmMyYTA1YWExNWVjYWQ1YmVjMzdlMjliOWZlNTFlZjMwMTIwZjg2NDI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"message":"81905186-E567-4E4E-A5CB-3E5BFB820E57","tree":{"sha":"dd944d7739d5d429f264709345fe481c1e602be8","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/dd944d7739d5d429f264709345fe481c1e602be8"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335"}]},{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjJlMjYwMGFhMDk1MjVlMmUxZTFkOThiMDlkZTYxNDU0ZDI5Yzk0YmI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"message":"AE0A9904-3B05-405A-B8F7-A5DD3B38C92D","tree":{"sha":"f64d89541a0899881da6764081991e70ed43bfea","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/f64d89541a0899881da6764081991e70ed43bfea"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642"}]}],"files":[{"sha":"657d4c0ca181f2df83261d3ab1922d6ec47ece1e","filename":"README.md","status":"modified","additions":8,"deletions":0,"changes":8,"blob_url":"https://github.com/ThiagoCodecov/example-python/blob/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","raw_url":"https://github.com/ThiagoCodecov/example-python/raw/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/README.md?ref=2e2600aa09525e2e1e1d98b09de61454d29c94bb","patch":"@@
+ -1 +1,9 @@\n Now this\n+\n+\n+\n+80EC0808-DD33-42DF-8873-B2A605A82432\n+63FE7DEF-8367-47BD-B864-D86DE9F94358\n+69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA\n+81905186-E567-4E4E-A5CB-3E5BFB820E57\n+AE0A9904-3B05-405A-B8F7-A5DD3B38C92D"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 16 Mar 2022 17:44:08 GMT
+ ETag:
+ - W/"08ab477429ee039b54be11e41e8f9cd8f35e3d9d10b48fce439ddc60be77c167"
+ Last-Modified:
+ - Tue, 31 Dec 2019 03:23:55 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C646:267B:2392731:243022B:623221E7
+ X-OAuth-Scopes:
+ - public_repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4999'
+ X-RateLimit-Reset:
+ - '1647456248'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '1'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2022-03-17 03:00:00 UTC
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)
+ Report\nPatch coverage has no change and project coverage change: **`+10.00%`**
+ :tada:\n> Comparison is base [(`4535be1`)](https://app.codecov.io/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)
+ 50.00% compared to head [(`2e2600a`)](https://app.codecov.io/gh/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)
+ 60.00%.\n\n:exclamation: Your organization is not using the GitHub App Integration.
+ As a result you may experience degraded service beginning May 15th. Please [install
+ the GitHub App Integration](https://github.com/apps/codecov) for your organization.
+ [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper).\n\nAdditional
+ details and impacted files
\n\n\n[](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\n\n```diff\n@@ Coverage
+ Diff @@\n## master #15 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| Flag | Coverage \u0394
+ | Complexity \u0394 | |\n|---|---|---|---|\n| integration | `?` | `?` | |\n|
+ unit | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried
+ forward coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\n\n \n\n[:umbrella:
+ View full report in Codecov by Sentry](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper). \n:loudspeaker:
+ Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '3553'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/1620423805\"\
+ ,\"html_url\":\"https://github.com/ThiagoCodecov/example-python/pull/15#issuecomment-1620423805\"\
+ ,\"issue_url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15\"\
+ ,\"id\":1620423805,\"node_id\":\"IC_kwDOCVXMMc5glbR9\",\"user\":{\"login\":\"\
+ joseph-sentry\",\"id\":136376984,\"node_id\":\"U_kgDOCCDymA\",\"avatar_url\"\
+ :\"https://avatars.githubusercontent.com/u/136376984?v=4\",\"gravatar_id\":\"\
+ \",\"url\":\"https://api.github.com/users/joseph-sentry\",\"html_url\":\"https://github.com/joseph-sentry\"\
+ ,\"followers_url\":\"https://api.github.com/users/joseph-sentry/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/joseph-sentry/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/joseph-sentry/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/joseph-sentry/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/joseph-sentry/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/joseph-sentry/repos\",\"events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2023-07-04T15:10:22Z\",\"updated_at\"\
+ :\"2023-07-04T15:10:22Z\",\"author_association\":\"NONE\",\"body\":\"## [Codecov](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\
+ \ Report\\nPatch coverage has no change and project coverage change: **`+10.00%`**\
+ \ :tada:\\n> Comparison is base [(`4535be1`)](https://app.codecov.io/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\
+ \ 50.00% compared to head [(`2e2600a`)](https://app.codecov.io/gh/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\
+ \ 60.00%.\\n\\n:exclamation: Your organization is not using the GitHub App Integration.\
+ \ As a result you may experience degraded service beginning May 15th. Please\
+ \ [install the GitHub App Integration](https://github.com/apps/codecov) for\
+ \ your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper).\\\
+ n\\nAdditional details and impacted files
\\n\\n\\\
+ n[](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ master #15 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| Flag | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n|\
+ \ integration | `?` | `?` | |\\n| unit | `100.00% <\xF8> (?)` | `0.00 <\xF8\
+ > (?)` | |\\n\\nFlags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper)\\\
+ n\\n \\n\\n[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper).\
+ \ \\n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Vernon+Cooper).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/1620423805/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '5164'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Tue, 04 Jul 2023 15:10:23 GMT
+ ETag:
+ - '"d914e81787c93a02fb56a26985bd567b55e4e586cac7b137e25c96d35143464c"'
+ Location:
+ - https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/1620423805
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - D49F:7F4F:123714A:24DC770:64A4365E
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4992'
+ X-RateLimit-Reset:
+ - '1688485927'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '8'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-07-11 14:51:11 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/branches?per_page=100&page=1
+ response:
+ content: '[{"name":"master","commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654"},"protected":false},{"name":"random-branch","commit":{"sha":"b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6"},"protected":false},{"name":"thiago/base-no-base","commit":{"sha":"620b8042c2f846fcc6dda1c732dedd15cdbe76db","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/620b8042c2f846fcc6dda1c732dedd15cdbe76db"},"protected":false},{"name":"thiago/f/big-pt","commit":{"sha":"d55dc4ef748fd11537e50c9abed4ab1864fa1d94","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/d55dc4ef748fd11537e50c9abed4ab1864fa1d94"},"protected":false},{"name":"thiago/f/cool-branch","commit":{"sha":"89f3d20f2be3fb2d098e544814f8ce3636dc78c4","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/89f3d20f2be3fb2d098e544814f8ce3636dc78c4"},"protected":false},{"name":"thiago/f/something","commit":{"sha":"c9fb9262268f11b53c6b0682d4f9acac89bdeee5","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c9fb9262268f11b53c6b0682d4f9acac89bdeee5"},"protected":false},{"name":"thiago/test-1","commit":{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb"},"protected":false}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:34:08 GMT
+ ETag:
+ - W/"dab6303e47e5d12f9bb596bcb1e3fd8a7e482a11e1e543c0b1e05e279ea55fe3"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C4D7:5028:3DC84CA:7E41E48:64A24FC0
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4995'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '5'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:93189ce...ThiagoCodecov:4535be1","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.patch","base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/ThiagoCodecov/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/ThiagoCodecov/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"merge_base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"status":"behind","ahead_by":0,"behind_by":52,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:34:09 GMT
+ ETag:
+ - W/"c625aec9cfb15b0fc585ff2dbcdaeef7a4e39d1881112104a691b9fb0f748c49"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C4D8:2BE9:3486A51:6BBC360:64A24FC0
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4994'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '6'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:5b174c2...joseph-sentry:5601846","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.patch","base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"ahead","ahead_by":1,"behind_by":0,"total_commits":1,"commits":[{"sha":"5601846871b8142ab0df1e0b8774756c658bcc7d","node_id":"C_kwDOJu7MkNoAKDU2MDE4NDY4NzFiODE0MmFiMGRmMWUwYjg3NzQ3NTZjNjU4YmNjN2Q","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:16:17Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:23:50Z"},"message":"make
+ change","tree":{"sha":"3ca6632aeb641e579bea4391294d9ebe55c062c7","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/3ca6632aeb641e579bea4391294d9ebe55c062c7"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQAmeUxSTgmd3FlcaYtRmVpuCh0n6L0/2Oy7wnsoGF6zjLoBO+aDz22vOG+bywNp4bc\nlmOl+Tu5Jk9woOhH2Olg8=\n-----END
+ SSH SIGNATURE-----","payload":"tree 3ca6632aeb641e579bea4391294d9ebe55c062c7\nparent
+ 5b174c2b40d501a70c479e91025d5109b1ad5c1b\nauthor joseph-sentry
+ 1692026177 -0400\ncommitter joseph-sentry 1692026630
+ -0400\n\nmake change\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b"}]}],"files":[{"sha":"8664ca881cd0536ab32eac8dfee923a2b304167d","filename":"api/calculator/calculator.py","status":"modified","additions":1,"deletions":3,"changes":4,"blob_url":"https://github.com/joseph-sentry/codecov-demo/blob/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","raw_url":"https://github.com/joseph-sentry/codecov-demo/raw/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","contents_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/contents/api%2Fcalculator%2Fcalculator.py?ref=5601846871b8142ab0df1e0b8774756c658bcc7d","patch":"@@
+ -9,6 +9,4 @@ def multiply(x, y):\n return x * y\n \n def divide(x,
+ y):\n- if y == 0:\n- return ''Cannot divide by 0''\n- return
+ x * 1.0 / y\n\\ No newline at end of file\n+ return x * 1.0 / y"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:44 GMT
+ ETag:
+ - W/"3bcbc91ba2aac4e70b8b74075bc06a0b7436e42a61c15cd11f8c28af48d74080"
+ Last-Modified:
+ - Mon, 14 Aug 2023 15:23:50 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E72C:7693:10E3C0:22520F:64EF8E1C
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4983'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '17'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main
+ response:
+ content: '{"name":"main","commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"_links":{"self":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main","html":"https://github.com/joseph-sentry/codecov-demo/tree/main"},"protected":false,"protection":{"enabled":false,"required_status_checks":{"enforcement_level":"off","contexts":[],"checks":[]}},"protection_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main/protection"}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:44 GMT
+ ETag:
+ - W/"28690f5aa55bcb187fc062a1ab6ad5901a1ccbe0225d6f96dacf712da8e22306"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E72D:052A:11B090:23E126:64EF8E1C
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4982'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '18'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:38c2d02...joseph-sentry:5b174c2","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.patch","base_commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"behind","ahead_by":0,"behind_by":2,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:45 GMT
+ ETag:
+ - W/"fb71dcba3dddf292e08598b556f7bcc763019abc6e1678ed3355d3d8ec57454f"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E72E:16C4:FF10B:20679C:64EF8E1C
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4981'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '19'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ Report\nPatch coverage has no change and project coverage change: **`+10.00%`**
+ :tada:\n> Comparison is base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ 50.00% compared to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ 60.00%.\n> Report is 2 commits behind head on main.\n\n:exclamation: Your organization
+ is not using the GitHub App Integration. As a result you may experience degraded
+ service beginning May 15th. Please [install the GitHub App Integration](https://github.com/apps/codecov)
+ for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD).\n\nAdditional
+ details and impacted files
\n\n\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\n\n```diff\n@@ Coverage
+ Diff @@\n## main #9 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ | Coverage \u0394 | Complexity \u0394 | |\n|---|---|---|---|\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ | `?` | `?` | |\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)
+ | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried forward
+ coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\n\n \n\n[:umbrella:
+ View full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD). \n:loudspeaker:
+ Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '4148'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669290\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry/codecov-demo/pull/9#issuecomment-1699669290\"\
+ ,\"issue_url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9\"\
+ ,\"id\":1699669290,\"node_id\":\"IC_kwDOJu7MkM5lTuUq\",\"user\":{\"login\":\"\
+ joseph-sentry\",\"id\":136376984,\"node_id\":\"U_kgDOCCDymA\",\"avatar_url\"\
+ :\"https://avatars.githubusercontent.com/u/136376984?u=8154f2067e6b4966acba9c27358d6e49cfbbf45d&v=4\"\
+ ,\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/joseph-sentry\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry\",\"followers_url\":\"https://api.github.com/users/joseph-sentry/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/joseph-sentry/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/joseph-sentry/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/joseph-sentry/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/joseph-sentry/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/joseph-sentry/repos\",\"events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2023-08-30T18:44:45Z\",\"updated_at\"\
+ :\"2023-08-30T18:44:45Z\",\"author_association\":\"OWNER\",\"body\":\"## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ Report\\nPatch coverage has no change and project coverage change: **`+10.00%`**\
+ \ :tada:\\n> Comparison is base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ 50.00% compared to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ 60.00%.\\n> Report is 2 commits behind head on main.\\n\\n:exclamation: Your\
+ \ organization is not using the GitHub App Integration. As a result you may\
+ \ experience degraded service beginning May 15th. Please [install the GitHub\
+ \ App Integration](https://github.com/apps/codecov) for your organization. [Read\
+ \ more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD).\\\
+ n\\nAdditional details and impacted files
\\n\\n\\\
+ n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ main #9 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ | `?` | `?` | |\\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\
+ \ | `100.00% <\xF8> (?)` | `0.00 <\xF8> (?)` | |\\n\\nFlags with carried forward\
+ \ coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD)\\\
+ n\\n \\n\\n[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD).\
+ \ \\n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tara+Grant+MD).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669290/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '5793'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:45 GMT
+ ETag:
+ - '"69ff846c9b55de3f6af22231c0b2754e020dda53e7d719ba917e41faf23d5f4d"'
+ Location:
+ - https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669290
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E730:2F64:165FAC:2D5DB0:64EF8E1D
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4980'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '20'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_test_results_error.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_test_results_error.yaml
new file mode 100644
index 0000000000..8a4421905f
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_test_results_error.yaml
@@ -0,0 +1,960 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:4535be1...ThiagoCodecov:2e2600a","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb.patch","base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"merge_base_commit":{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjMwY2MxZWQ3NTFhNTlmYTllN2FkOGU3OWZmZjQxYTZmZTExZWY1ZGQ=","commit":{"author":{"name":"Thiago","email":"44376991+ThiagoCodecov@users.noreply.github.com","date":"2019-12-09T12:02:11Z"},"committer":{"name":"GitHub","email":"noreply@github.com","date":"2019-12-09T12:02:11Z"},"message":"Update
+ README.md","tree":{"sha":"c901b3cb75fd85aebf03f254494d5ad06b86777e","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/c901b3cb75fd85aebf03f254494d5ad06b86777e"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ PGP SIGNATURE-----\n\nwsBcBAABCAAQBQJd7jfDCRBK7hj4Ov3rIwAAdHIIAF8TrpEbraVNLuNObBiQefch\nVHGpc6MNxOnnRgGPZZl/egrosAxCJ2og+ArrjQG+D/7eYUhWeb73YPR8lWvVf6YN\nIkNSwv1o/2bmH3pma9DR4wDXnKeO/w/UPhNZaGC1w3dcvsHSJReSRkl/ipLxtPep\n3XpmC8RdIwFNU1hG8Dd5iKqJz3d3iA3mdkb3vrvYmKq2imQigSbXpOvL1o/Du9DN\nxE29+ZeGqTZf0Z5QHgJT7THw8P1bf/UJUado7nn6cIon/slDbP/ZirN3rgEJWXU6\neH9/bdS+HMcCwK5TZwJBXgRkbtzRIgc7mj5cTWOSX8XFuUnOS/QCyfkAIvajseU=\n=3PXD\n-----END
+ PGP SIGNATURE-----\n","payload":"tree c901b3cb75fd85aebf03f254494d5ad06b86777e\nparent
+ 2dfb36626ca60a8aa6d2bc624ad0f54b473bb436\nauthor Thiago <44376991+ThiagoCodecov@users.noreply.github.com>
+ 1575892931 -0300\ncommitter GitHub 1575892931 -0300\n\nUpdate
+ README.md"}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"web-flow","id":19864447,"node_id":"MDQ6VXNlcjE5ODY0NDQ3","avatar_url":"https://avatars3.githubusercontent.com/u/19864447?v=4","gravatar_id":"","url":"https://api.github.com/users/web-flow","html_url":"https://github.com/web-flow","followers_url":"https://api.github.com/users/web-flow/followers","following_url":"https://api.github.com/users/web-flow/following{/other_user}","gists_url":"https://api.github.com/users/web-flow/gists{/gist_id}","starred_url":"https://api.github.com/users/web-flow/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/web-flow/subscriptions","organizations_url":"https://api.github.com/users/web-flow/orgs","repos_url":"https://api.github.com/users/web-flow/repos","events_url":"https://api.github.com/users/web-flow/events{/privacy}","received_events_url":"https://api.github.com/users/web-flow/received_events","type":"User","site_admin":false},"parents":[{"sha":"2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2dfb36626ca60a8aa6d2bc624ad0f54b473bb436"}]},"status":"diverged","ahead_by":3,"behind_by":11,"total_commits":3,"commits":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmUwMjJmNTg4ZTQwMjY5ZjhhYjFjNTY4MDgwZGM0YTc2NjJmZjIzMzU=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"message":"69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA","tree":{"sha":"6188b6c086ebf6c63626abca649d2596b145c84c","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/6188b6c086ebf6c63626abca649d2596b145c84c"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd"}]},{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmMyYTA1YWExNWVjYWQ1YmVjMzdlMjliOWZlNTFlZjMwMTIwZjg2NDI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"message":"81905186-E567-4E4E-A5CB-3E5BFB820E57","tree":{"sha":"dd944d7739d5d429f264709345fe481c1e602be8","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/dd944d7739d5d429f264709345fe481c1e602be8"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335"}]},{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjJlMjYwMGFhMDk1MjVlMmUxZTFkOThiMDlkZTYxNDU0ZDI5Yzk0YmI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"message":"AE0A9904-3B05-405A-B8F7-A5DD3B38C92D","tree":{"sha":"f64d89541a0899881da6764081991e70ed43bfea","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/f64d89541a0899881da6764081991e70ed43bfea"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642"}]}],"files":[{"sha":"657d4c0ca181f2df83261d3ab1922d6ec47ece1e","filename":"README.md","status":"modified","additions":8,"deletions":0,"changes":8,"blob_url":"https://github.com/ThiagoCodecov/example-python/blob/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","raw_url":"https://github.com/ThiagoCodecov/example-python/raw/2e2600aa09525e2e1e1d98b09de61454d29c94bb/README.md","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/README.md?ref=2e2600aa09525e2e1e1d98b09de61454d29c94bb","patch":"@@
+ -1 +1,9 @@\n Now this\n+\n+\n+\n+80EC0808-DD33-42DF-8873-B2A605A82432\n+63FE7DEF-8367-47BD-B864-D86DE9F94358\n+69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA\n+81905186-E567-4E4E-A5CB-3E5BFB820E57\n+AE0A9904-3B05-405A-B8F7-A5DD3B38C92D"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:18 GMT
+ Etag:
+ - W/"44cd18200d235728506114fd6b930cca"
+ Last-Modified:
+ - Tue, 31 Dec 2019 03:23:55 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D081:309E:10C87AE:17A2BD6:5E0F9D8E
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4995'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/compare/4535be18e90467d6d9a99c0ce651becec7f7eba6...2e2600aa09525e2e1e1d98b09de61454d29c94bb
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15","id":350683791,"node_id":"MDExOlB1bGxSZXF1ZXN0MzUwNjgzNzkx","html_url":"https://github.com/ThiagoCodecov/example-python/pull/15","diff_url":"https://github.com/ThiagoCodecov/example-python/pull/15.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/pull/15.patch","issue_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15","number":15,"state":"open","locked":false,"title":"Thiago/test
+ 1","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"body":"","created_at":"2019-12-09T12:19:01Z","updated_at":"2019-12-09T12:19:01Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits","review_comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/comments","review_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/comments{/number}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/2e2600aa09525e2e1e1d98b09de61454d29c94bb","head":{"label":"ThiagoCodecov:thiago/test-1","ref":"thiago/test-1","sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"repo":{"id":156617777,"node_id":"MDEwOlJlcG9zaXRvcnkxNTY2MTc3Nzc=","name":"example-python","full_name":"ThiagoCodecov/example-python","private":false,"owner":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"html_url":"https://github.com/ThiagoCodecov/example-python","description":"Python
+ coverage example","fork":true,"url":"https://api.github.com/repos/ThiagoCodecov/example-python","forks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/forks","keys_url":"https://api.github.com/repos/ThiagoCodecov/example-python/keys{/key_id}","collaborators_url":"https://api.github.com/repos/ThiagoCodecov/example-python/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/ThiagoCodecov/example-python/teams","hooks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/hooks","issue_events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/events{/number}","events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/events","assignees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/assignees{/user}","branches_url":"https://api.github.com/repos/ThiagoCodecov/example-python/branches{/branch}","tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/tags","blobs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/refs{/sha}","trees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees{/sha}","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/{sha}","languages_url":"https://api.github.com/repos/ThiagoCodecov/example-python/languages","stargazers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/stargazers","contributors_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contributors","subscribers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscribers","subscription_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscription","commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits{/sha}","git_commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits{/sha}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/comments{/number}","issue_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments{/number}","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/{+path}","compare_url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/{base}...{head}","merges_url":"https://api.github.com/repos/ThiagoCodecov/example-python/merges","archive_url":"https://api.github.com/repos/ThiagoCodecov/example-python/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/ThiagoCodecov/example-python/downloads","issues_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues{/number}","pulls_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls{/number}","milestones_url":"https://api.github.com/repos/ThiagoCodecov/example-python/milestones{/number}","notifications_url":"https://api.github.com/repos/ThiagoCodecov/example-python/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/ThiagoCodecov/example-python/labels{/name}","releases_url":"https://api.github.com/repos/ThiagoCodecov/example-python/releases{/id}","deployments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/deployments","created_at":"2018-11-07T22:40:20Z","updated_at":"2019-12-31T03:24:04Z","pushed_at":"2019-12-31T03:24:02Z","git_url":"git://github.com/ThiagoCodecov/example-python.git","ssh_url":"git@github.com:ThiagoCodecov/example-python.git","clone_url":"https://github.com/ThiagoCodecov/example-python.git","svn_url":"https://github.com/ThiagoCodecov/example-python","homepage":"https://codecov.io","size":161,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":4,"license":null,"forks":0,"open_issues":4,"watchers":0,"default_branch":"master"}},"base":{"label":"ThiagoCodecov:master","ref":"master","sha":"d723f5cb5c9c9f48c47f2df97c47de20457d3fdc","user":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"repo":{"id":156617777,"node_id":"MDEwOlJlcG9zaXRvcnkxNTY2MTc3Nzc=","name":"example-python","full_name":"ThiagoCodecov/example-python","private":false,"owner":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"html_url":"https://github.com/ThiagoCodecov/example-python","description":"Python
+ coverage example","fork":true,"url":"https://api.github.com/repos/ThiagoCodecov/example-python","forks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/forks","keys_url":"https://api.github.com/repos/ThiagoCodecov/example-python/keys{/key_id}","collaborators_url":"https://api.github.com/repos/ThiagoCodecov/example-python/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/ThiagoCodecov/example-python/teams","hooks_url":"https://api.github.com/repos/ThiagoCodecov/example-python/hooks","issue_events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/events{/number}","events_url":"https://api.github.com/repos/ThiagoCodecov/example-python/events","assignees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/assignees{/user}","branches_url":"https://api.github.com/repos/ThiagoCodecov/example-python/branches{/branch}","tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/tags","blobs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/refs{/sha}","trees_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees{/sha}","statuses_url":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/{sha}","languages_url":"https://api.github.com/repos/ThiagoCodecov/example-python/languages","stargazers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/stargazers","contributors_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contributors","subscribers_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscribers","subscription_url":"https://api.github.com/repos/ThiagoCodecov/example-python/subscription","commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits{/sha}","git_commits_url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits{/sha}","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/comments{/number}","issue_comment_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments{/number}","contents_url":"https://api.github.com/repos/ThiagoCodecov/example-python/contents/{+path}","compare_url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/{base}...{head}","merges_url":"https://api.github.com/repos/ThiagoCodecov/example-python/merges","archive_url":"https://api.github.com/repos/ThiagoCodecov/example-python/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/ThiagoCodecov/example-python/downloads","issues_url":"https://api.github.com/repos/ThiagoCodecov/example-python/issues{/number}","pulls_url":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls{/number}","milestones_url":"https://api.github.com/repos/ThiagoCodecov/example-python/milestones{/number}","notifications_url":"https://api.github.com/repos/ThiagoCodecov/example-python/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/ThiagoCodecov/example-python/labels{/name}","releases_url":"https://api.github.com/repos/ThiagoCodecov/example-python/releases{/id}","deployments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/deployments","created_at":"2018-11-07T22:40:20Z","updated_at":"2019-12-31T03:24:04Z","pushed_at":"2019-12-31T03:24:02Z","git_url":"git://github.com/ThiagoCodecov/example-python.git","ssh_url":"git@github.com:ThiagoCodecov/example-python.git","clone_url":"https://github.com/ThiagoCodecov/example-python.git","svn_url":"https://github.com/ThiagoCodecov/example-python","homepage":"https://codecov.io","size":161,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":4,"license":null,"forks":0,"open_issues":4,"watchers":0,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15"},"html":{"href":"https://github.com/ThiagoCodecov/example-python/pull/15"},"issue":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15"},"comments":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments"},"review_comments":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/comments"},"review_comment":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits"},"statuses":{"href":"https://api.github.com/repos/ThiagoCodecov/example-python/statuses/2e2600aa09525e2e1e1d98b09de61454d29c94bb"}},"author_association":"OWNER","merged":false,"mergeable":false,"rebaseable":false,"mergeable_state":"dirty","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":3,"additions":8,"deletions":0,"changed_files":1}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:19 GMT
+ Etag:
+ - W/"80de2d6223eb160b0cd153534733b75d"
+ Last-Modified:
+ - Fri, 03 Jan 2020 19:56:45 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D082:50AC:E05476:13C1D91:5E0F9D8E
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4994'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15
+- request:
+ body: '{"body": "## [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1)
+ Report\n> Merging [#15](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=desc)
+ into [master](None/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6&el=desc)
+ will **increase** coverage by `10.00%`.\n> The diff coverage is `n/a`.\n\n[](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\n\n```diff\n@@ Coverage
+ Diff @@\n## master #15 +/- ##\n===========================================\n+
+ Coverage 50.00% 60.00% +10.00% \n===========================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n===========================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| Flag | Coverage \u0394 |
+ |\n|---|---|---|\n| #unit | `100.00% <\u00f8> (?)` | |\n\n| [Impacted Files](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)
+ | Coverage \u0394 | |\n|---|---|---|\n| [file\\_2.py](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8yLnB5)
+ | `50.00% <0.00%> (\u00f8)` | :arrow_up: |\n| [file\\_1.go](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8xLmdv)
+ | `62.50% <0.00%> (+12.50%)` | :arrow_up: |\n\n------\n\n[Continue to review
+ full report at Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue).\n>
+ **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)\n>
+ `\u0394 = absolute (impact)`, `\u00f8 = not affected`, `? = missing
+ data`\n> Powered by [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=footer).
+ Last update [d723f5c...2e2600a](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=lastupdated).
+ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).\n"}'
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/570682170\"\
+ ,\"html_url\":\"https://github.com/ThiagoCodecov/example-python/pull/15#issuecomment-570682170\"\
+ ,\"issue_url\":\"https://api.github.com/repos/ThiagoCodecov/example-python/issues/15\"\
+ ,\"id\":570682170,\"node_id\":\"MDEyOklzc3VlQ29tbWVudDU3MDY4MjE3MA==\",\"user\"\
+ :{\"login\":\"ThiagoCodecov\",\"id\":44376991,\"node_id\":\"MDQ6VXNlcjQ0Mzc2OTkx\"\
+ ,\"avatar_url\":\"https://avatars3.githubusercontent.com/u/44376991?v=4\",\"\
+ gravatar_id\":\"\",\"url\":\"https://api.github.com/users/ThiagoCodecov\",\"\
+ html_url\":\"https://github.com/ThiagoCodecov\",\"followers_url\":\"https://api.github.com/users/ThiagoCodecov/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/ThiagoCodecov/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/ThiagoCodecov/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/ThiagoCodecov/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/ThiagoCodecov/repos\",\"events_url\"\
+ :\"https://api.github.com/users/ThiagoCodecov/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/ThiagoCodecov/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2020-01-03T20:01:19Z\",\"updated_at\"\
+ :\"2020-01-03T20:01:19Z\",\"author_association\":\"OWNER\",\"body\":\"# [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=h1)\
+ \ Report\\n> Merging [#15](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=desc)\
+ \ into [master](None/gh/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6&el=desc)\
+ \ will **increase** coverage by `10.00%`.\\n> The diff coverage is `n/a`.\\\
+ n\\n[](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## master\
+ \ #15 +/- ##\\n===========================================\\n+\
+ \ Coverage 50.00% 60.00% +10.00% \\n===========================================\\\
+ n Files 2 2 \\n Lines 6 10\
+ \ +4 \\n Branches 0 1 +1 \\n===========================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 3\
+ \ \\n- Partials 0 1 +1 \\n```\\n\\n|\
+ \ Flag | Coverage \u0394 | |\\n|---|---|---|\\n| #unit | `100.00% <\xF8> (?)`\
+ \ | |\\n\\n| [Impacted Files](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree)\
+ \ | Coverage \u0394 | |\\n|---|---|---|\\n| [file\\\\_2.py](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8yLnB5)\
+ \ | `50.00% <0.00%> (\xF8)` | :arrow_up: |\\n| [file\\\\_1.go](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=tree#diff-ZmlsZV8xLmdv)\
+ \ | `62.50% <0.00%> (+12.50%)` | :arrow_up: |\\n\\n------\\n\\n[Continue to\
+ \ review full report at Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=continue).\\\
+ n> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)\\\
+ n> `\u0394 = absolute (impact)`, `\xF8 = not affected`, `? = missing\
+ \ data`\\n> Powered by [Codecov](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=footer).\
+ \ Last update [d723f5c...2e2600a](None/gh/ThiagoCodecov/example-python/pull/15?src=pr&el=lastupdated).\
+ \ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).\\\
+ n\"}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Length:
+ - '3545'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 03 Jan 2020 20:01:20 GMT
+ Etag:
+ - '"b9894b79f2e1d3509dad975b48cb566b"'
+ Location:
+ - https://api.github.com/repos/ThiagoCodecov/example-python/issues/comments/570682170
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 201 Created
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - D083:50AA:26C744:38893C:5E0F9D8F
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4993'
+ X-Ratelimit-Reset:
+ - '1578085278'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 201
+ message: Created
+ status_code: 201
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/issues/15/comments
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ User-Agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits
+ response:
+ content: '[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmUwMjJmNTg4ZTQwMjY5ZjhhYjFjNTY4MDgwZGM0YTc2NjJmZjIzMzU=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:44Z"},"message":"69E76A1D-FD86-4A90-A6CE-6A66BEEE75CA","tree":{"sha":"6188b6c086ebf6c63626abca649d2596b145c84c","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/6188b6c086ebf6c63626abca649d2596b145c84c"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd","html_url":"https://github.com/ThiagoCodecov/example-python/commit/30cc1ed751a59fa9e7ad8e79fff41a6fe11ef5dd"}]},{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OmMyYTA1YWExNWVjYWQ1YmVjMzdlMjliOWZlNTFlZjMwMTIwZjg2NDI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:47Z"},"message":"81905186-E567-4E4E-A5CB-3E5BFB820E57","tree":{"sha":"dd944d7739d5d429f264709345fe481c1e602be8","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/dd944d7739d5d429f264709345fe481c1e602be8"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"e022f588e40269f8ab1c568080dc4a7662ff2335","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/e022f588e40269f8ab1c568080dc4a7662ff2335","html_url":"https://github.com/ThiagoCodecov/example-python/commit/e022f588e40269f8ab1c568080dc4a7662ff2335"}]},{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjJlMjYwMGFhMDk1MjVlMmUxZTFkOThiMDlkZTYxNDU0ZDI5Yzk0YmI=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-09T12:17:49Z"},"message":"AE0A9904-3B05-405A-B8F7-A5DD3B38C92D","tree":{"sha":"f64d89541a0899881da6764081991e70ed43bfea","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/f64d89541a0899881da6764081991e70ed43bfea"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb","html_url":"https://github.com/ThiagoCodecov/example-python/commit/2e2600aa09525e2e1e1d98b09de61454d29c94bb","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars3.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"c2a05aa15ecad5bec37e29b9fe51ef30120f8642","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c2a05aa15ecad5bec37e29b9fe51ef30120f8642","html_url":"https://github.com/ThiagoCodecov/example-python/commit/c2a05aa15ecad5bec37e29b9fe51ef30120f8642"}]}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval,
+ X-GitHub-Media-Type
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Connection:
+ - close
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Fri, 17 Jan 2020 00:10:36 GMT
+ Etag:
+ - W/"ccf882511531ad012f6664fd8f73c441"
+ Last-Modified:
+ - Fri, 17 Jan 2020 00:10:01 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Status:
+ - 200 OK
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding
+ X-Accepted-Oauth-Scopes:
+ - ''
+ X-Consumed-Content-Encoding:
+ - gzip
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-Github-Media-Type:
+ - github.v3
+ X-Github-Request-Id:
+ - 132D:1D4D:20EF65:2DD4BC:5E20FB7C
+ X-Oauth-Scopes:
+ - admin:org, admin:public_key, admin:repo_hook, repo, user, write:discussion
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4994'
+ X-Ratelimit-Reset:
+ - '1579222789'
+ X-Xss-Protection:
+ - 1; mode=block
+ status:
+ code: 200
+ message: OK
+ status_code: 200
+ url: https://api.github.com/repos/ThiagoCodecov/example-python/pulls/15/commits
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/branches?per_page=100&page=1
+ response:
+ content: '[{"name":"master","commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654"},"protected":false},{"name":"random-branch","commit":{"sha":"b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6"},"protected":false},{"name":"thiago/base-no-base","commit":{"sha":"620b8042c2f846fcc6dda1c732dedd15cdbe76db","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/620b8042c2f846fcc6dda1c732dedd15cdbe76db"},"protected":false},{"name":"thiago/f/big-pt","commit":{"sha":"d55dc4ef748fd11537e50c9abed4ab1864fa1d94","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/d55dc4ef748fd11537e50c9abed4ab1864fa1d94"},"protected":false},{"name":"thiago/f/cool-branch","commit":{"sha":"89f3d20f2be3fb2d098e544814f8ce3636dc78c4","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/89f3d20f2be3fb2d098e544814f8ce3636dc78c4"},"protected":false},{"name":"thiago/f/something","commit":{"sha":"c9fb9262268f11b53c6b0682d4f9acac89bdeee5","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/c9fb9262268f11b53c6b0682d4f9acac89bdeee5"},"protected":false},{"name":"thiago/test-1","commit":{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb"},"protected":false}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:24:01 GMT
+ ETag:
+ - W/"dab6303e47e5d12f9bb596bcb1e3fd8a7e482a11e1e543c0b1e05e279ea55fe3"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C46C:0919:18CCE0:32A952:64A24D61
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4997'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '3'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6
+ response:
+ content: '{"url":"https://api.github.com/repos/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6","permalink_url":"https://github.com/ThiagoCodecov/example-python/compare/ThiagoCodecov:93189ce...ThiagoCodecov:4535be1","diff_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.diff","patch_url":"https://github.com/ThiagoCodecov/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...4535be18e90467d6d9a99c0ce651becec7f7eba6.patch","base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/ThiagoCodecov/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/ThiagoCodecov/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"merge_base_commit":{"sha":"4535be18e90467d6d9a99c0ce651becec7f7eba6","node_id":"MDY6Q29tbWl0MTU2NjE3Nzc3OjQ1MzViZTE4ZTkwNDY3ZDZkOWE5OWMwY2U2NTFiZWNlYzdmN2ViYTY=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2019-12-31T03:23:55Z"},"message":"20EE4D09-F49E-474B-B7AF-4E4916EF82FA","tree":{"sha":"7da71bf52d31d24403d4208835b5d1333ba54695","url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/trees/7da71bf52d31d24403d4208835b5d1333ba54695"},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/git/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6","html_url":"https://github.com/ThiagoCodecov/example-python/commit/4535be18e90467d6d9a99c0ce651becec7f7eba6","comments_url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/4535be18e90467d6d9a99c0ce651becec7f7eba6/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"66567ec66e07440e3170910df61cf458812323fc","url":"https://api.github.com/repos/ThiagoCodecov/example-python/commits/66567ec66e07440e3170910df61cf458812323fc","html_url":"https://github.com/ThiagoCodecov/example-python/commit/66567ec66e07440e3170910df61cf458812323fc"}]},"status":"behind","ahead_by":0,"behind_by":52,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:24:01 GMT
+ ETag:
+ - W/"c625aec9cfb15b0fc585ff2dbcdaeef7a4e39d1881112104a691b9fb0f748c49"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C46D:8600:1BB361:387DFB:64A24D61
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4996'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '4'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:5b174c2...joseph-sentry:5601846","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.patch","base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"ahead","ahead_by":1,"behind_by":0,"total_commits":1,"commits":[{"sha":"5601846871b8142ab0df1e0b8774756c658bcc7d","node_id":"C_kwDOJu7MkNoAKDU2MDE4NDY4NzFiODE0MmFiMGRmMWUwYjg3NzQ3NTZjNjU4YmNjN2Q","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:16:17Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:23:50Z"},"message":"make
+ change","tree":{"sha":"3ca6632aeb641e579bea4391294d9ebe55c062c7","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/3ca6632aeb641e579bea4391294d9ebe55c062c7"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQAmeUxSTgmd3FlcaYtRmVpuCh0n6L0/2Oy7wnsoGF6zjLoBO+aDz22vOG+bywNp4bc\nlmOl+Tu5Jk9woOhH2Olg8=\n-----END
+ SSH SIGNATURE-----","payload":"tree 3ca6632aeb641e579bea4391294d9ebe55c062c7\nparent
+ 5b174c2b40d501a70c479e91025d5109b1ad5c1b\nauthor joseph-sentry
+ 1692026177 -0400\ncommitter joseph-sentry 1692026630
+ -0400\n\nmake change\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b"}]}],"files":[{"sha":"8664ca881cd0536ab32eac8dfee923a2b304167d","filename":"api/calculator/calculator.py","status":"modified","additions":1,"deletions":3,"changes":4,"blob_url":"https://github.com/joseph-sentry/codecov-demo/blob/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","raw_url":"https://github.com/joseph-sentry/codecov-demo/raw/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","contents_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/contents/api%2Fcalculator%2Fcalculator.py?ref=5601846871b8142ab0df1e0b8774756c658bcc7d","patch":"@@
+ -9,6 +9,4 @@ def multiply(x, y):\n return x * y\n \n def divide(x,
+ y):\n- if y == 0:\n- return ''Cannot divide by 0''\n- return
+ x * 1.0 / y\n\\ No newline at end of file\n+ return x * 1.0 / y"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"3bcbc91ba2aac4e70b8b74075bc06a0b7436e42a61c15cd11f8c28af48d74080"
+ Last-Modified:
+ - Mon, 14 Aug 2023 15:23:50 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E726:5597:D48B2:1B1274:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4987'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '13'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main
+ response:
+ content: '{"name":"main","commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"_links":{"self":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main","html":"https://github.com/joseph-sentry/codecov-demo/tree/main"},"protected":false,"protection":{"enabled":false,"required_status_checks":{"enforcement_level":"off","contexts":[],"checks":[]}},"protection_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main/protection"}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"28690f5aa55bcb187fc062a1ab6ad5901a1ccbe0225d6f96dacf712da8e22306"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E727:0526:134901:270CDD:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4986'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '14'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:38c2d02...joseph-sentry:5b174c2","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.patch","base_commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"behind","ahead_by":0,"behind_by":2,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:42 GMT
+ ETag:
+ - W/"fb71dcba3dddf292e08598b556f7bcc763019abc6e1678ed3355d3d8ec57454f"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E728:44A9:1226D9:24CF0C:64EF8E1A
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4985'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '15'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ Report\n> Merging [#9](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ (5601846) into [main](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ (5b174c2) will **increase** coverage by `10.00%`.\n> Report is 2 commits behind
+ head on main.\n> The diff coverage is `n/a`.\n\n:exclamation: Your organization
+ is not using the GitHub App Integration. As a result you may experience degraded
+ service beginning May 15th. Please [install the GitHub App Integration](https://github.com/apps/codecov)
+ for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n\n```diff\n@@ Coverage
+ Diff @@\n## main #9 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | Coverage \u0394 | Complexity \u0394 | |\n|---|---|---|---|\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | `?` | `?` | |\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)
+ | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried forward
+ coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n\n------\n\n[Continue
+ to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n>
+ **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\n>
+ `\u0394 = absolute (impact)`, `\u00f8 = not affected`, `? = missing
+ data`\n> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).
+ Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).
+ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '4719'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry/codecov-demo/pull/9#issuecomment-1699669247\"\
+ ,\"issue_url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9\"\
+ ,\"id\":1699669247,\"node_id\":\"IC_kwDOJu7MkM5lTuT_\",\"user\":{\"login\":\"\
+ joseph-sentry\",\"id\":136376984,\"node_id\":\"U_kgDOCCDymA\",\"avatar_url\"\
+ :\"https://avatars.githubusercontent.com/u/136376984?u=8154f2067e6b4966acba9c27358d6e49cfbbf45d&v=4\"\
+ ,\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/joseph-sentry\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry\",\"followers_url\":\"https://api.github.com/users/joseph-sentry/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/joseph-sentry/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/joseph-sentry/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/joseph-sentry/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/joseph-sentry/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/joseph-sentry/repos\",\"events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2023-08-30T18:44:43Z\",\"updated_at\"\
+ :\"2023-08-30T18:44:43Z\",\"author_association\":\"OWNER\",\"body\":\"## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ Report\\n> Merging [#9](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ (5601846) into [main](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ (5b174c2) will **increase** coverage by `10.00%`.\\n> Report is 2 commits\
+ \ behind head on main.\\n> The diff coverage is `n/a`.\\n\\n:exclamation: Your\
+ \ organization is not using the GitHub App Integration. As a result you may\
+ \ experience degraded service beginning May 15th. Please [install the GitHub\
+ \ App Integration](https://github.com/apps/codecov) for your organization. [Read\
+ \ more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n\\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ main #9 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | `?` | `?` | |\\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\
+ \ | `100.00% <\xF8> (?)` | `0.00 <\xF8> (?)` | |\\n\\nFlags with carried forward\
+ \ coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n\\n------\\n\\n[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell)\\\
+ n> `\u0394 = absolute (impact)`, `\xF8 = not affected`, `? = missing\
+ \ data`\\n> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\
+ \ Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\
+ \ Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=James+Mitchell).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '6356'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:43 GMT
+ ETag:
+ - '"a3267b6fdb96428f156fb276116e0b4c918da9a371be840fc5573f132fbe1dbe"'
+ Location:
+ - https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669247
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E72A:35CC:109BE2:21B617:64EF8E1B
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4984'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '16'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upgrade.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upgrade.yaml
new file mode 100644
index 0000000000..52d838ec51
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upgrade.yaml
@@ -0,0 +1,93 @@
+interactions:
+- request:
+ body: '{"body": "The author of this PR, codecove2e, is not an activated member
+ of this organization on Codecov.\nPlease [activate this user on Codecov](https://app.codecov.io/members/gh/codecove2e?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bryan+Brewer)
+ to display this PR comment.\nCoverage data is still being uploaded to Codecov.io
+ for purposes of overall coverage calculations.\nPlease don''t hesitate to email
+ us at support@codecov.io with any questions."}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '502'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/codecove2e/example-python/issues/2/comments
+ response:
+ content: '{"url":"https://api.github.com/repos/codecove2e/example-python/issues/comments/1361234119","html_url":"https://github.com/codecove2e/example-python/pull/4#issuecomment-1361234119","issue_url":"https://api.github.com/repos/codecove2e/example-python/issues/4","id":1361234119,"node_id":"IC_kwDOHrbKcs5RIsjH","user":{"login":"codecove2e","id":93560619,"node_id":"U_kgDOBZOfKw","avatar_url":"https://avatars.githubusercontent.com/u/93560619?v=4","gravatar_id":"","url":"https://api.github.com/users/codecove2e","html_url":"https://github.com/codecove2e","followers_url":"https://api.github.com/users/codecove2e/followers","following_url":"https://api.github.com/users/codecove2e/following{/other_user}","gists_url":"https://api.github.com/users/codecove2e/gists{/gist_id}","starred_url":"https://api.github.com/users/codecove2e/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/codecove2e/subscriptions","organizations_url":"https://api.github.com/users/codecove2e/orgs","repos_url":"https://api.github.com/users/codecove2e/repos","events_url":"https://api.github.com/users/codecove2e/events{/privacy}","received_events_url":"https://api.github.com/users/codecove2e/received_events","type":"User","site_admin":false},"created_at":"2022-12-21T12:07:36Z","updated_at":"2022-12-21T12:07:36Z","author_association":"OWNER","body":"The
+ author of this PR, codecove2e, is not an activated member of this organization
+ on Codecov.\nPlease [activate this user on Codecov](https://app.codecov.io/members/gh/codecove2e?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bryan+Brewer)
+ to display this PR comment.\nCoverage data is still being uploaded to Codecov.io
+ for purposes of overall coverage calculations.\nPlease don''t hesitate to email
+ us at support@codecov.io with any questions.","reactions":{"url":"https://api.github.com/repos/codecove2e/example-python/issues/comments/1361234119/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":null}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '2078'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 21 Dec 2022 12:07:37 GMT
+ ETag:
+ - '"bd60879875bf26300655202f2977cfa5b7c96c86445e0f87e8ddf3fa06a94292"'
+ Location:
+ - https://api.github.com/repos/codecove2e/example-python/issues/comments/1361234119
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - DC1C:BDA8:357399A:3629679:63A2F708
+ X-OAuth-Scopes:
+ - repo, user
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4996'
+ X-RateLimit-Reset:
+ - '1671627083'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '4'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-02-18 19:09:58 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upload_limited.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upload_limited.yaml
new file mode 100644
index 0000000000..0ca7f8eda1
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_upload_limited.yaml
@@ -0,0 +1,93 @@
+interactions:
+- request:
+ body: '{"body": "## [Codecov](None/account/gh/test-acc9/billing) upload limit reached
+ :warning:\nThis org is currently on the free Basic Plan; which includes 250
+ free private repo uploads each month. This month''s limit has
+ been reached and additional reports cannot be generated. For unlimited uploads, upgrade
+ to our [pro plan](None/account/gh/test-acc9/billing).\n\n**Do you have questions
+ or need help?** Connect with our sales team today at ` sales@codecov.io `"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '495'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/test-acc9/priv_example/issues/3/comments
+ response:
+ content: '{"url":"https://api.github.com/repos/test-acc9/priv_example/issues/comments/1111984446","html_url":"https://github.com/test-acc9/priv_example/pull/1#issuecomment-1111984446","issue_url":"https://api.github.com/repos/test-acc9/priv_example/issues/1","id":1111984446,"node_id":"IC_kwDOHP_eEc5CR4k-","user":{"login":"test-acc9","id":104562106,"node_id":"U_kgDOBjt9ug","avatar_url":"https://avatars.githubusercontent.com/u/104562106?v=4","gravatar_id":"","url":"https://api.github.com/users/test-acc9","html_url":"https://github.com/test-acc9","followers_url":"https://api.github.com/users/test-acc9/followers","following_url":"https://api.github.com/users/test-acc9/following{/other_user}","gists_url":"https://api.github.com/users/test-acc9/gists{/gist_id}","starred_url":"https://api.github.com/users/test-acc9/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/test-acc9/subscriptions","organizations_url":"https://api.github.com/users/test-acc9/orgs","repos_url":"https://api.github.com/users/test-acc9/repos","events_url":"https://api.github.com/users/test-acc9/events{/privacy}","received_events_url":"https://api.github.com/users/test-acc9/received_events","type":"User","site_admin":false},"created_at":"2022-04-28T09:42:44Z","updated_at":"2022-04-28T09:42:44Z","author_association":"OWNER","body":"#
+ [Codecov](None/account/gh/test-acc9/billing) upload limit reached :warning:\nThis
+ org is currently on the free Basic Plan; which includes 250 free private repo
+ uploads each rolling month. This limit has been reached and
+ additional reports cannot be generated. For unlimited uploads, upgrade
+ to our [pro plan](None/account/gh/test-acc9/billing).\n\n**Do you have questions
+ or need help?** Connect with our sales team today at ` sales@codecov.io `","reactions":{"url":"https://api.github.com/repos/test-acc9/priv_example/issues/comments/1111984446/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":null}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '2049'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Thu, 28 Apr 2022 09:42:45 GMT
+ ETag:
+ - '"58258dde9e030e0de7c45373103c8d3fa593b19403d53a0ad35289df6dc5a682"'
+ Location:
+ - https://api.github.com/repos/test-acc9/priv_example/issues/comments/1111984446
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - EA34:E167:23A263:244D3D:626A6194
+ X-OAuth-Scopes:
+ - notifications, repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4999'
+ X-RateLimit-Reset:
+ - '1651142564'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '1'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2022-04-28 21:00:00 UTC
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_with_components.yaml b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_with_components.yaml
new file mode 100644
index 0000000000..338cc3e1f1
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/cassetes/test_comment/TestCommentNotifierIntegration/test_notify_with_components.yaml
@@ -0,0 +1,826 @@
+interactions:
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...8589c19ce95a2b13cf7b3272cbf275ca9651ae9c
+ response:
+ content: '{"url":"https://api.github.com/repos/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","html_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","permalink_url":"https://github.com/codecove2e/example-python/compare/codecove2e:93189ce...codecove2e:8589c19","diff_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...8589c19ce95a2b13cf7b3272cbf275ca9651ae9c.diff","patch_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...8589c19ce95a2b13cf7b3272cbf275ca9651ae9c.patch","base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0NTE1Mjk1ODU4OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/codecove2e/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/codecove2e/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/codecove2e/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/codecove2e/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"merge_base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0NTE1Mjk1ODU4OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/codecove2e/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/codecove2e/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/codecove2e/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/codecove2e/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"status":"ahead","ahead_by":1,"behind_by":0,"total_commits":1,"commits":[{"sha":"8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","node_id":"C_kwDOHrbKctoAKDg1ODljMTljZTk1YTJiMTNjZjdiMzI3MmNiZjI3NWNhOTY1MWFlOWM","commit":{"author":{"name":"codecove2e","email":"93560619+codecove2e@users.noreply.github.com","date":"2022-08-16T19:38:32Z"},"committer":{"name":"GitHub","email":"noreply@github.com","date":"2022-08-16T19:38:32Z"},"message":"Update
+ __init__.py","tree":{"sha":"7c5d795e95bf1dcf12ebe45e3f2abf071e8bcaf3","url":"https://api.github.com/repos/codecove2e/example-python/git/trees/7c5d795e95bf1dcf12ebe45e3f2abf071e8bcaf3"},"url":"https://api.github.com/repos/codecove2e/example-python/git/commits/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ PGP SIGNATURE-----\n\nwsBcBAABCAAQBQJi+/I4CRBK7hj4Ov3rIwAApVwIACMRcC8711GZMH7nXKZH1tFK\ny7tOt6/WfCZsqEMtH5aQ5Px45MKT03wp+uPNBDnTJcdPLwZLfIdf7u9OpB9F4aVP\nsoLxW7IkUo9hT+E5i4YdldQOlmBF6pRwXA1AYYUAy1EgMchjf7kRkVCi3owJRXOV\nTP7cnM7ocP/RV46TzOgkhIh4wa0xbR8+LpFU7/+pRTbK+0u++UOB02Saq1RLvGyb\n8g8bHAtSNtTIRPfqI0EzE3piYjp6EFtoVmjDoTWn3z+fNw/6jB9SO9J9lyWFr62r\nbAoWgl8TuKuGogunswjsoWzs+13oFikh+4x2+V/ze0nk983D1ykXnMHQO2fIZV4=\n=i4b6\n-----END
+ PGP SIGNATURE-----\n","payload":"tree 7c5d795e95bf1dcf12ebe45e3f2abf071e8bcaf3\nparent
+ 93189ce50f224296d6412e2884b93dcc3c7c8654\nauthor codecove2e <93560619+codecove2e@users.noreply.github.com>
+ 1660678712 -0300\ncommitter GitHub 1660678712 -0300\n\nUpdate
+ __init__.py"}},"url":"https://api.github.com/repos/codecove2e/example-python/commits/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","html_url":"https://github.com/codecove2e/example-python/commit/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","comments_url":"https://api.github.com/repos/codecove2e/example-python/commits/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c/comments","author":{"login":"codecove2e","id":93560619,"node_id":"U_kgDOBZOfKw","avatar_url":"https://avatars.githubusercontent.com/u/93560619?v=4","gravatar_id":"","url":"https://api.github.com/users/codecove2e","html_url":"https://github.com/codecove2e","followers_url":"https://api.github.com/users/codecove2e/followers","following_url":"https://api.github.com/users/codecove2e/following{/other_user}","gists_url":"https://api.github.com/users/codecove2e/gists{/gist_id}","starred_url":"https://api.github.com/users/codecove2e/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/codecove2e/subscriptions","organizations_url":"https://api.github.com/users/codecove2e/orgs","repos_url":"https://api.github.com/users/codecove2e/repos","events_url":"https://api.github.com/users/codecove2e/events{/privacy}","received_events_url":"https://api.github.com/users/codecove2e/received_events","type":"User","site_admin":false},"committer":{"login":"web-flow","id":19864447,"node_id":"MDQ6VXNlcjE5ODY0NDQ3","avatar_url":"https://avatars.githubusercontent.com/u/19864447?v=4","gravatar_id":"","url":"https://api.github.com/users/web-flow","html_url":"https://github.com/web-flow","followers_url":"https://api.github.com/users/web-flow/followers","following_url":"https://api.github.com/users/web-flow/following{/other_user}","gists_url":"https://api.github.com/users/web-flow/gists{/gist_id}","starred_url":"https://api.github.com/users/web-flow/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/web-flow/subscriptions","organizations_url":"https://api.github.com/users/web-flow/orgs","repos_url":"https://api.github.com/users/web-flow/repos","events_url":"https://api.github.com/users/web-flow/events{/privacy}","received_events_url":"https://api.github.com/users/web-flow/received_events","type":"User","site_admin":false},"parents":[{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654"}]}],"files":[{"sha":"5fc28eefb09af57ad8253d7d2141d0f77ba22ee7","filename":"awesome/__init__.py","status":"modified","additions":1,"deletions":1,"changes":2,"blob_url":"https://github.com/codecove2e/example-python/blob/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c/awesome%2F__init__.py","raw_url":"https://github.com/codecove2e/example-python/raw/8589c19ce95a2b13cf7b3272cbf275ca9651ae9c/awesome%2F__init__.py","contents_url":"https://api.github.com/repos/codecove2e/example-python/contents/awesome%2F__init__.py?ref=8589c19ce95a2b13cf7b3272cbf275ca9651ae9c","patch":"@@
+ -3,7 +3,7 @@ def smile():\n \n \n def frown():\n- return \":(\"\n+ return
+ \"=(\"\n \n \n def shieeee(g):"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 21 Sep 2022 15:11:06 GMT
+ ETag:
+ - W/"cd843e8086c843e2f0f12e6978fa4b298126c8171d778dcb8397b878ee926eae"
+ Last-Modified:
+ - Tue, 16 Aug 2022 19:38:32 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - F195:046E:6EEB6D:76ACFC:632B2989
+ X-OAuth-Scopes:
+ - repo, user
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4999'
+ X-RateLimit-Reset:
+ - '1663776666'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '1'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2022-10-21 13:48:23 UTC
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](None/gh/codecove2e/example-python/pull/4?src=pr&el=h1)
+ Report\nBase: **50.00**% // Head: **60.00**% // Increases project coverage by
+ **`+10.00%`** :tada:\n> Coverage data is based on head [(`8589c19`)](None/gh/codecove2e/example-python/pull/4?src=pr&el=desc)
+ compared to base [(`93189ce`)](None/gh/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654?el=desc).\n>
+ Patch has no changes to coverable lines.\n\nAdditional details
+ and impacted files
\n\n\n[](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree)\n\n```diff\n@@ Coverage
+ Diff @@\n## master #4 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| Flag | Coverage \u0394
+ | Complexity \u0394 | |\n|---|---|---|---|\n| integration | `?` | `?` | |\n|
+ unit | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried
+ forward coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Molly+Taylor#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n| [Impacted Files](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree)
+ | Coverage \u0394 | Complexity \u0394 | |\n|---|---|---|---|\n| [file\\_2.py](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree#diff-ZmlsZV8yLnB5)
+ | `50.00% <0.00%> (\u00f8)` | `0.00% <0.00%> (\u00f8%)` | |\n| [file\\_1.go](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree#diff-ZmlsZV8xLmdv)
+ | `62.50% <0.00%> (+12.50%)` | `10.00% <0.00%> (-1.00%)` | :arrow_up: |\n\n \n\n[:umbrella:
+ View full report in Codecov by Sentry](None/gh/codecove2e/example-python/pull/4?src=pr&el=continue). \n:loudspeaker:
+ Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Molly+Taylor).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '2630'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/codecove2e/example-python/issues/4/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/codecove2e/example-python/issues/comments/1253851153\"\
+ ,\"html_url\":\"https://github.com/codecove2e/example-python/pull/4#issuecomment-1253851153\"\
+ ,\"issue_url\":\"https://api.github.com/repos/codecove2e/example-python/issues/4\"\
+ ,\"id\":1253851153,\"node_id\":\"IC_kwDOHrbKcs5KvEAR\",\"user\":{\"login\":\"\
+ codecove2e\",\"id\":93560619,\"node_id\":\"U_kgDOBZOfKw\",\"avatar_url\":\"\
+ https://avatars.githubusercontent.com/u/93560619?v=4\",\"gravatar_id\":\"\"\
+ ,\"url\":\"https://api.github.com/users/codecove2e\",\"html_url\":\"https://github.com/codecove2e\"\
+ ,\"followers_url\":\"https://api.github.com/users/codecove2e/followers\",\"\
+ following_url\":\"https://api.github.com/users/codecove2e/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/codecove2e/gists{/gist_id}\",\"\
+ starred_url\":\"https://api.github.com/users/codecove2e/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/codecove2e/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/codecove2e/orgs\",\"repos_url\"\
+ :\"https://api.github.com/users/codecove2e/repos\",\"events_url\":\"https://api.github.com/users/codecove2e/events{/privacy}\"\
+ ,\"received_events_url\":\"https://api.github.com/users/codecove2e/received_events\"\
+ ,\"type\":\"User\",\"site_admin\":false},\"created_at\":\"2022-09-21T15:11:06Z\"\
+ ,\"updated_at\":\"2022-09-21T15:11:06Z\",\"author_association\":\"OWNER\",\"\
+ body\":\"## [Codecov](None/gh/codecove2e/example-python/pull/4?src=pr&el=h1)\
+ \ Report\\nBase: **50.00**% // Head: **60.00**% // Increases project coverage\
+ \ by **`+10.00%`** :tada:\\n> Coverage data is based on head [(`8589c19`)](None/gh/codecove2e/example-python/pull/4?src=pr&el=desc)\
+ \ compared to base [(`93189ce`)](None/gh/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654?el=desc).\\\
+ n> Patch has no changes to coverable lines.\\n\\nAdditional\
+ \ details and impacted files
\\n\\n\\n[](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ master #4 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| Flag | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n|\
+ \ integration | `?` | `?` | |\\n| unit | `100.00% <\xF8> (?)` | `0.00 <\xF8\
+ > (?)` | |\\n\\nFlags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Molly+Taylor#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n| [Impacted Files](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree)\
+ \ | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n| [file\\\\\
+ _2.py](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree#diff-ZmlsZV8yLnB5)\
+ \ | `50.00% <0.00%> (\xF8)` | `0.00% <0.00%> (\xF8%)` | |\\n| [file\\\\_1.go](None/gh/codecove2e/example-python/pull/4?src=pr&el=tree#diff-ZmlsZV8xLmdv)\
+ \ | `62.50% <0.00%> (+12.50%)` | `10.00% <0.00%> (-1.00%)` | :arrow_up: |\\\
+ n\\n \\n\\n[:umbrella: View full report in Codecov by Sentry](None/gh/codecove2e/example-python/pull/4?src=pr&el=continue).\
+ \ \\n:loudspeaker: Do you have feedback about the report comment? [Let us\
+ \ know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Molly+Taylor).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/codecove2e/example-python/issues/comments/1253851153/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '4174'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 21 Sep 2022 15:11:07 GMT
+ ETag:
+ - '"70a2f797a0545b4426c2591967246285228ad4f9021cd8669a1e9903253cdcec"'
+ Location:
+ - https://api.github.com/repos/codecove2e/example-python/issues/comments/1253851153
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - F196:7870:7198A4:795698:632B298A
+ X-OAuth-Scopes:
+ - repo, user
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4998'
+ X-RateLimit-Reset:
+ - '1663776666'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '2'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2022-10-21 13:48:23 UTC
+ http_version: HTTP/1.1
+ status_code: 201
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/codecove2e/example-python/branches?per_page=100&page=1
+ response:
+ content: '[{"name":"codecove2e-patch-1","commit":{"sha":"dd2085468f3d09d3e277c04bec61aeb84a782dee","url":"https://api.github.com/repos/codecove2e/example-python/commits/dd2085468f3d09d3e277c04bec61aeb84a782dee"},"protected":false},{"name":"codecove2e-patch-2","commit":{"sha":"12f3e9a05310ed583cd1b0ad683d373f88a84053","url":"https://api.github.com/repos/codecove2e/example-python/commits/12f3e9a05310ed583cd1b0ad683d373f88a84053"},"protected":false},{"name":"codecove2e-patch-3","commit":{"sha":"0206296b1424912cc05069a9bf4025cbb95f5ecc","url":"https://api.github.com/repos/codecove2e/example-python/commits/0206296b1424912cc05069a9bf4025cbb95f5ecc"},"protected":false},{"name":"master","commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654"},"protected":false},{"name":"random-branch","commit":{"sha":"b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6","url":"https://api.github.com/repos/codecove2e/example-python/commits/b12b8ba6046c1738c5c89325b8a63a6f51bc4ff6"},"protected":false},{"name":"test-branch","commit":{"sha":"bec77909e0a9b04603d2cdddcba62f99592d6579","url":"https://api.github.com/repos/codecove2e/example-python/commits/bec77909e0a9b04603d2cdddcba62f99592d6579"},"protected":false},{"name":"thiago/base-no-base","commit":{"sha":"620b8042c2f846fcc6dda1c732dedd15cdbe76db","url":"https://api.github.com/repos/codecove2e/example-python/commits/620b8042c2f846fcc6dda1c732dedd15cdbe76db"},"protected":false},{"name":"thiago/f/big-pt","commit":{"sha":"d55dc4ef748fd11537e50c9abed4ab1864fa1d94","url":"https://api.github.com/repos/codecove2e/example-python/commits/d55dc4ef748fd11537e50c9abed4ab1864fa1d94"},"protected":false},{"name":"thiago/f/something","commit":{"sha":"c9fb9262268f11b53c6b0682d4f9acac89bdeee5","url":"https://api.github.com/repos/codecove2e/example-python/commits/c9fb9262268f11b53c6b0682d4f9acac89bdeee5"},"protected":false},{"name":"thiago/test-1","commit":{"sha":"2e2600aa09525e2e1e1d98b09de61454d29c94bb","url":"https://api.github.com/repos/codecove2e/example-python/commits/2e2600aa09525e2e1e1d98b09de61454d29c94bb"},"protected":false}]'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:38:35 GMT
+ ETag:
+ - W/"a73b85e14f3662ed4e2f7350a4352806cf22f390684fc1f3fa4182ebfa79e8d4"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C50B:64A0:3572E4C:6D8129C:64A250CB
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4993'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '7'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...93189ce50f224296d6412e2884b93dcc3c7c8654
+ response:
+ content: '{"url":"https://api.github.com/repos/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...93189ce50f224296d6412e2884b93dcc3c7c8654","permalink_url":"https://github.com/codecove2e/example-python/compare/codecove2e:93189ce...codecove2e:93189ce","diff_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...93189ce50f224296d6412e2884b93dcc3c7c8654.diff","patch_url":"https://github.com/codecove2e/example-python/compare/93189ce50f224296d6412e2884b93dcc3c7c8654...93189ce50f224296d6412e2884b93dcc3c7c8654.patch","base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0NTE1Mjk1ODU4OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/codecove2e/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/codecove2e/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/codecove2e/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/codecove2e/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"merge_base_commit":{"sha":"93189ce50f224296d6412e2884b93dcc3c7c8654","node_id":"MDY6Q29tbWl0NTE1Mjk1ODU4OjkzMTg5Y2U1MGYyMjQyOTZkNjQxMmUyODg0YjkzZGNjM2M3Yzg2NTQ=","commit":{"author":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"committer":{"name":"Thiago
+ Ramos","email":"thiago@codecov.io","date":"2020-12-04T04:21:18Z"},"message":"Adding
+ some encoding","tree":{"sha":"966e4dc888b0828c5dea75f21e82eb2daa9a4485","url":"https://api.github.com/repos/codecove2e/example-python/git/trees/966e4dc888b0828c5dea75f21e82eb2daa9a4485"},"url":"https://api.github.com/repos/codecove2e/example-python/git/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null}},"url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654","html_url":"https://github.com/codecove2e/example-python/commit/93189ce50f224296d6412e2884b93dcc3c7c8654","comments_url":"https://api.github.com/repos/codecove2e/example-python/commits/93189ce50f224296d6412e2884b93dcc3c7c8654/comments","author":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"committer":{"login":"ThiagoCodecov","id":44376991,"node_id":"MDQ6VXNlcjQ0Mzc2OTkx","avatar_url":"https://avatars.githubusercontent.com/u/44376991?v=4","gravatar_id":"","url":"https://api.github.com/users/ThiagoCodecov","html_url":"https://github.com/ThiagoCodecov","followers_url":"https://api.github.com/users/ThiagoCodecov/followers","following_url":"https://api.github.com/users/ThiagoCodecov/following{/other_user}","gists_url":"https://api.github.com/users/ThiagoCodecov/gists{/gist_id}","starred_url":"https://api.github.com/users/ThiagoCodecov/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ThiagoCodecov/subscriptions","organizations_url":"https://api.github.com/users/ThiagoCodecov/orgs","repos_url":"https://api.github.com/users/ThiagoCodecov/repos","events_url":"https://api.github.com/users/ThiagoCodecov/events{/privacy}","received_events_url":"https://api.github.com/users/ThiagoCodecov/received_events","type":"User","site_admin":false},"parents":[{"sha":"6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","url":"https://api.github.com/repos/codecove2e/example-python/commits/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67","html_url":"https://github.com/codecove2e/example-python/commit/6f87a6bba8b0151bb78064a0dba9fa4f13b76a67"}]},"status":"identical","ahead_by":0,"behind_by":0,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Mon, 03 Jul 2023 04:38:36 GMT
+ ETag:
+ - W/"1ee7ec16be9404907626e70c31409eff643bd43d57aba766d1b696b5a81ef722"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - C50C:4061:348EFE3:6BE942E:64A250CB
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4992'
+ X-RateLimit-Reset:
+ - '1688361672'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '8'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-08-02 04:17:36 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:5b174c2...joseph-sentry:5601846","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/5b174c2b40d501a70c479e91025d5109b1ad5c1b...5601846871b8142ab0df1e0b8774756c658bcc7d.patch","base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"ahead","ahead_by":1,"behind_by":0,"total_commits":1,"commits":[{"sha":"5601846871b8142ab0df1e0b8774756c658bcc7d","node_id":"C_kwDOJu7MkNoAKDU2MDE4NDY4NzFiODE0MmFiMGRmMWUwYjg3NzQ3NTZjNjU4YmNjN2Q","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:16:17Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:23:50Z"},"message":"make
+ change","tree":{"sha":"3ca6632aeb641e579bea4391294d9ebe55c062c7","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/3ca6632aeb641e579bea4391294d9ebe55c062c7"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQAmeUxSTgmd3FlcaYtRmVpuCh0n6L0/2Oy7wnsoGF6zjLoBO+aDz22vOG+bywNp4bc\nlmOl+Tu5Jk9woOhH2Olg8=\n-----END
+ SSH SIGNATURE-----","payload":"tree 3ca6632aeb641e579bea4391294d9ebe55c062c7\nparent
+ 5b174c2b40d501a70c479e91025d5109b1ad5c1b\nauthor joseph-sentry
+ 1692026177 -0400\ncommitter joseph-sentry 1692026630
+ -0400\n\nmake change\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5601846871b8142ab0df1e0b8774756c658bcc7d/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b"}]}],"files":[{"sha":"8664ca881cd0536ab32eac8dfee923a2b304167d","filename":"api/calculator/calculator.py","status":"modified","additions":1,"deletions":3,"changes":4,"blob_url":"https://github.com/joseph-sentry/codecov-demo/blob/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","raw_url":"https://github.com/joseph-sentry/codecov-demo/raw/5601846871b8142ab0df1e0b8774756c658bcc7d/api%2Fcalculator%2Fcalculator.py","contents_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/contents/api%2Fcalculator%2Fcalculator.py?ref=5601846871b8142ab0df1e0b8774756c658bcc7d","patch":"@@
+ -9,6 +9,4 @@ def multiply(x, y):\n return x * y\n \n def divide(x,
+ y):\n- if y == 0:\n- return ''Cannot divide by 0''\n- return
+ x * 1.0 / y\n\\ No newline at end of file\n+ return x * 1.0 / y"}]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:46 GMT
+ ETag:
+ - W/"3bcbc91ba2aac4e70b8b74075bc06a0b7436e42a61c15cd11f8c28af48d74080"
+ Last-Modified:
+ - Mon, 14 Aug 2023 15:23:50 GMT
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E731:28BF:EE6EF:1E528B:64EF8E1E
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4979'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '21'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main
+ response:
+ content: '{"name":"main","commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"_links":{"self":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main","html":"https://github.com/joseph-sentry/codecov-demo/tree/main"},"protected":false,"protection":{"enabled":false,"required_status_checks":{"enforcement_level":"off","contexts":[],"checks":[]}},"protection_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/branches/main/protection"}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:46 GMT
+ ETag:
+ - W/"28690f5aa55bcb187fc062a1ab6ad5901a1ccbe0225d6f96dacf712da8e22306"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E732:13D8:116896:235695:64EF8E1E
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4978'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '22'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: ''
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: GET
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b
+ response:
+ content: '{"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b","permalink_url":"https://github.com/joseph-sentry/codecov-demo/compare/joseph-sentry:38c2d02...joseph-sentry:5b174c2","diff_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.diff","patch_url":"https://github.com/joseph-sentry/codecov-demo/compare/38c2d0214f2a48c9212a140f5311977059a15b35...5b174c2b40d501a70c479e91025d5109b1ad5c1b.patch","base_commit":{"sha":"38c2d0214f2a48c9212a140f5311977059a15b35","node_id":"C_kwDOJu7MkNoAKDM4YzJkMDIxNGYyYTQ4YzkyMTJhMTQwZjUzMTE5NzcwNTlhMTViMzU","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-18T16:48:10Z"},"message":"make
+ change to app\n\nSigned-off-by: joseph-sentry ","tree":{"sha":"9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/38c2d0214f2a48c9212a140f5311977059a15b35","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQJe3OYXkY7fA1+RZ130Zrab06lH36l2XC9HMqjHDAW8iCW5VUbvNhOxNpKSokDItdd\nroqDRnazyFj3+oWVCvxgM=\n-----END
+ SSH SIGNATURE-----","payload":"tree 9ba2c9dd228af0bbaf34cc8dcac85a1bdeed6043\nparent
+ 1713006782821e36912c23baa98901c7361ee83c\nauthor joseph-sentry
+ 1692377290 -0400\ncommitter joseph-sentry 1692377290
+ -0400\n\nmake change to app\n\nSigned-off-by: joseph-sentry \n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/38c2d0214f2a48c9212a140f5311977059a15b35","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/38c2d0214f2a48c9212a140f5311977059a15b35/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"1713006782821e36912c23baa98901c7361ee83c","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/1713006782821e36912c23baa98901c7361ee83c","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/1713006782821e36912c23baa98901c7361ee83c"}]},"merge_base_commit":{"sha":"5b174c2b40d501a70c479e91025d5109b1ad5c1b","node_id":"C_kwDOJu7MkNoAKDViMTc0YzJiNDBkNTAxYTcwYzQ3OWU5MTAyNWQ1MTA5YjFhZDVjMWI","commit":{"author":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"committer":{"name":"joseph-sentry","email":"joseph.sawaya@sentry.io","date":"2023-08-14T15:19:08Z"},"message":"make
+ change on main","tree":{"sha":"a9d6c66e50012a6d42f3f4c4e6d6de845230e73b","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/trees/a9d6c66e50012a6d42f3f4c4e6d6de845230e73b"},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/git/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
+ SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZAgzn4ZBqwutNsBP2EqViyU9PP\n/bJINxsDu+12+Wr9EAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQCSXJE75ylD7PwNrXf6ITnsvh9gIP5b/JASjGlKqB7f37XPt9wLvT2YaV3HcSyTRyt\nCY9zBOZT+tELiyZcpqKwY=\n-----END
+ SSH SIGNATURE-----","payload":"tree a9d6c66e50012a6d42f3f4c4e6d6de845230e73b\nparent
+ 3fe10c352faae434e0cca7c1c5984c1c98000288\nauthor joseph-sentry
+ 1692026348 -0400\ncommitter joseph-sentry 1692026348
+ -0400\n\nmake change on main\n"}},"url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b","comments_url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/5b174c2b40d501a70c479e91025d5109b1ad5c1b/comments","author":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"committer":{"login":"joseph-sentry","id":136376984,"node_id":"U_kgDOCCDymA","avatar_url":"https://avatars.githubusercontent.com/u/136376984?v=4","gravatar_id":"","url":"https://api.github.com/users/joseph-sentry","html_url":"https://github.com/joseph-sentry","followers_url":"https://api.github.com/users/joseph-sentry/followers","following_url":"https://api.github.com/users/joseph-sentry/following{/other_user}","gists_url":"https://api.github.com/users/joseph-sentry/gists{/gist_id}","starred_url":"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/joseph-sentry/subscriptions","organizations_url":"https://api.github.com/users/joseph-sentry/orgs","repos_url":"https://api.github.com/users/joseph-sentry/repos","events_url":"https://api.github.com/users/joseph-sentry/events{/privacy}","received_events_url":"https://api.github.com/users/joseph-sentry/received_events","type":"User","site_admin":false},"parents":[{"sha":"3fe10c352faae434e0cca7c1c5984c1c98000288","url":"https://api.github.com/repos/joseph-sentry/codecov-demo/commits/3fe10c352faae434e0cca7c1c5984c1c98000288","html_url":"https://github.com/joseph-sentry/codecov-demo/commit/3fe10c352faae434e0cca7c1c5984c1c98000288"}]},"status":"behind","ahead_by":0,"behind_by":2,"total_commits":0,"commits":[],"files":[]}'
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Encoding:
+ - gzip
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:46 GMT
+ ETag:
+ - W/"fb71dcba3dddf292e08598b556f7bcc763019abc6e1678ed3355d3d8ec57454f"
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Transfer-Encoding:
+ - chunked
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E733:16C4:FF333:206BFB:64EF8E1E
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4977'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '23'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"body": "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ Report\nPatch coverage has no change and project coverage change: **`+10.00%`**
+ :tada:\n> Comparison is base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ 50.00% compared to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ 60.00%.\n> Report is 2 commits behind head on main.\n\n:exclamation: Your organization
+ is not using the GitHub App Integration. As a result you may experience degraded
+ service beginning May 15th. Please [install the GitHub App Integration](https://github.com/apps/codecov)
+ for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal).\n\nAdditional
+ details and impacted files
\n\n\n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\n\n```diff\n@@ Coverage
+ Diff @@\n## main #9 +/- ##\n=============================================\n+
+ Coverage 50.00% 60.00% +10.00% \n+ Complexity 11 10 -1 \n=============================================\n Files 2 2 \n Lines 6 10 +4 \n Branches 0 1 +1 \n=============================================\n+
+ Hits 3 6 +3 \n Misses 3 3 \n-
+ Partials 0 1 +1 \n```\n\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ | Coverage \u0394 | Complexity \u0394 | |\n|---|---|---|---|\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ | `?` | `?` | |\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ | `100.00% <\u00f8> (?)` | `0.00 <\u00f8> (?)` | |\n\nFlags with carried forward
+ coverage won''t be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal#carryforward-flags-in-the-pull-request-comment)
+ to find out more.\n\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\n\n|
+ [Components](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ | Coverage \u0394 | |\n|---|---|---|\n| [go_files](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)
+ | `62.50% <\u00f8> (+12.50%)` | :arrow_up: |\n\n \n\n[:umbrella: View
+ full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal). \n:loudspeaker:
+ Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal).\n"}'
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '4689'
+ content-type:
+ - application/json
+ host:
+ - api.github.com
+ user-agent:
+ - Default
+ method: POST
+ uri: https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9/comments
+ response:
+ content: "{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669323\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry/codecov-demo/pull/9#issuecomment-1699669323\"\
+ ,\"issue_url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/9\"\
+ ,\"id\":1699669323,\"node_id\":\"IC_kwDOJu7MkM5lTuVL\",\"user\":{\"login\":\"\
+ joseph-sentry\",\"id\":136376984,\"node_id\":\"U_kgDOCCDymA\",\"avatar_url\"\
+ :\"https://avatars.githubusercontent.com/u/136376984?u=8154f2067e6b4966acba9c27358d6e49cfbbf45d&v=4\"\
+ ,\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/joseph-sentry\"\
+ ,\"html_url\":\"https://github.com/joseph-sentry\",\"followers_url\":\"https://api.github.com/users/joseph-sentry/followers\"\
+ ,\"following_url\":\"https://api.github.com/users/joseph-sentry/following{/other_user}\"\
+ ,\"gists_url\":\"https://api.github.com/users/joseph-sentry/gists{/gist_id}\"\
+ ,\"starred_url\":\"https://api.github.com/users/joseph-sentry/starred{/owner}{/repo}\"\
+ ,\"subscriptions_url\":\"https://api.github.com/users/joseph-sentry/subscriptions\"\
+ ,\"organizations_url\":\"https://api.github.com/users/joseph-sentry/orgs\",\"\
+ repos_url\":\"https://api.github.com/users/joseph-sentry/repos\",\"events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/events{/privacy}\",\"received_events_url\"\
+ :\"https://api.github.com/users/joseph-sentry/received_events\",\"type\":\"\
+ User\",\"site_admin\":false},\"created_at\":\"2023-08-30T18:44:47Z\",\"updated_at\"\
+ :\"2023-08-30T18:44:47Z\",\"author_association\":\"OWNER\",\"body\":\"## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ Report\\nPatch coverage has no change and project coverage change: **`+10.00%`**\
+ \ :tada:\\n> Comparison is base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ 50.00% compared to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ 60.00%.\\n> Report is 2 commits behind head on main.\\n\\n:exclamation: Your\
+ \ organization is not using the GitHub App Integration. As a result you may\
+ \ experience degraded service beginning May 15th. Please [install the GitHub\
+ \ App Integration](https://github.com/apps/codecov) for your organization. [Read\
+ \ more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal).\\\
+ n\\nAdditional details and impacted files
\\n\\n\\\
+ n[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\\\
+ n\\n```diff\\n@@ Coverage Diff @@\\n## \
+ \ main #9 +/- ##\\n=============================================\\\
+ n+ Coverage 50.00% 60.00% +10.00% \\n+ Complexity 11 \
+ \ 10 -1 \\n=============================================\\n Files\
+ \ 2 2 \\n Lines 6 10 \
+ \ +4 \\n Branches 0 1 +1 \\n=============================================\\\
+ n+ Hits 3 6 +3 \\n Misses 3 \
+ \ 3 \\n- Partials 0 1 +1 \\n```\\\
+ n\\n| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ | Coverage \u0394 | Complexity \u0394 | |\\n|---|---|---|---|\\n| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ | `?` | `?` | |\\n| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ | `100.00% <\xF8> (?)` | `0.00 <\xF8> (?)` | |\\n\\nFlags with carried forward\
+ \ coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal#carryforward-flags-in-the-pull-request-comment)\
+ \ to find out more.\\n\\n[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\\\
+ n\\n| [Components](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ | Coverage \u0394 | |\\n|---|---|---|\\n| [go_files](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal)\
+ \ | `62.50% <\xF8> (+12.50%)` | :arrow_up: |\\n\\n \\n\\n[:umbrella:\
+ \ View full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal).\
+ \ \\n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Juan+Villarreal).\\\
+ n\",\"reactions\":{\"url\":\"https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669323/reactions\"\
+ ,\"total_count\":0,\"+1\":0,\"-1\":0,\"laugh\":0,\"hooray\":0,\"confused\":0,\"\
+ heart\":0,\"rocket\":0,\"eyes\":0},\"performed_via_github_app\":null}"
+ headers:
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
+ X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
+ X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
+ X-GitHub-Request-Id, Deprecation, Sunset
+ Cache-Control:
+ - private, max-age=60, s-maxage=60
+ Content-Length:
+ - '6326'
+ Content-Security-Policy:
+ - default-src 'none'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Wed, 30 Aug 2023 18:44:47 GMT
+ ETag:
+ - '"dbd1e35f816e49a046e87cc8afcaff84664ea90b7cdc670b21e81765f512f702"'
+ Location:
+ - https://api.github.com/repos/joseph-sentry/codecov-demo/issues/comments/1699669323
+ Referrer-Policy:
+ - origin-when-cross-origin, strict-origin-when-cross-origin
+ Server:
+ - GitHub.com
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubdomains; preload
+ Vary:
+ - Accept, Authorization, Cookie, X-GitHub-OTP
+ - Accept-Encoding, Accept, X-Requested-With
+ X-Accepted-OAuth-Scopes:
+ - ''
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - deny
+ X-GitHub-Media-Type:
+ - github.v3
+ X-GitHub-Request-Id:
+ - E734:35CC:10A06A:21BF7A:64EF8E1E
+ X-OAuth-Scopes:
+ - repo
+ X-RateLimit-Limit:
+ - '5000'
+ X-RateLimit-Remaining:
+ - '4976'
+ X-RateLimit-Reset:
+ - '1693424656'
+ X-RateLimit-Resource:
+ - core
+ X-RateLimit-Used:
+ - '24'
+ X-XSS-Protection:
+ - '0'
+ github-authentication-token-expiration:
+ - 2023-09-29 15:36:05 UTC
+ x-github-api-version-selected:
+ - '2022-11-28'
+ http_version: HTTP/1.1
+ status_code: 201
+version: 1
diff --git a/apps/worker/services/notification/notifiers/tests/integration/test_comment.py b/apps/worker/services/notification/notifiers/tests/integration/test_comment.py
new file mode 100644
index 0000000000..f6c7b90fe6
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/integration/test_comment.py
@@ -0,0 +1,788 @@
+from unittest.mock import PropertyMock
+
+import pytest
+from shared.reports.readonly import ReadOnlyReport
+
+from database.tests.factories import CommitFactory, PullFactory, RepositoryFactory
+from services.comparison import ComparisonContext, ComparisonProxy
+from services.comparison.types import Comparison, EnrichedPull, FullCommit
+from services.decoration import Decoration
+from services.notification.notifiers.comment import CommentNotifier
+from tests.helpers import mock_all_plans_and_tiers
+
+
+@pytest.fixture
+def is_not_first_pull(mocker):
+ mocker.patch(
+ "database.models.core.Pull.is_first_coverage_pull",
+ return_value=False,
+ new_callable=PropertyMock,
+ )
+
+
+@pytest.fixture
+def codecove2e_comparison(dbsession, request, sample_report, small_report):
+ repository = RepositoryFactory.create(
+ owner__service="github",
+ owner__username="codecove2e",
+ name="example-python",
+ owner__unencrypted_oauth_token="ghp_testxh25kbya8pcenroaxwqsiq23ff9xzr0u",
+ image_token="abcdefghij",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(
+ repository=repository, commitid="93189ce50f224296d6412e2884b93dcc3c7c8654"
+ )
+ head_commit = CommitFactory.create(
+ repository=repository,
+ branch="new_branch",
+ commitid="8589c19ce95a2b13cf7b3272cbf275ca9651ae9c",
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ pullid=4,
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(small_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecove2e"},
+ "base": {
+ "branch": "main",
+ "commitid": "93189ce50f224296d6412e2884b93dcc3c7c8654",
+ },
+ "head": {
+ "branch": "codecove2e-patch-3",
+ "commitid": "8589c19ce95a2b13cf7b3272cbf275ca9651ae9c",
+ },
+ "state": "open",
+ "title": "Update __init__.py",
+ "id": "4",
+ "number": "4",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison(dbsession, request, sample_report, small_report):
+ repository = RepositoryFactory.create(
+ owner__service="github",
+ owner__username="joseph-sentry",
+ name="codecov-demo",
+ owner__unencrypted_oauth_token="ghp_testmgzs9qm7r27wp376fzv10aobbpva7hd3",
+ image_token="abcdefghij",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(
+ repository=repository, commitid="5b174c2b40d501a70c479e91025d5109b1ad5c1b"
+ )
+ head_commit = CommitFactory.create(
+ repository=repository,
+ branch="test",
+ commitid="5601846871b8142ab0df1e0b8774756c658bcc7d",
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ pullid=9,
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(small_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "joseph-sentry"},
+ "base": {
+ "branch": "main",
+ "commitid": "5b174c2b40d501a70c479e91025d5109b1ad5c1b",
+ },
+ "head": {
+ "branch": "test",
+ "commitid": "5601846871b8142ab0df1e0b8774756c658bcc7d",
+ },
+ "state": "open",
+ "title": "make change",
+ "id": "9",
+ "number": "9",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_gitlab(dbsession, request, sample_report, small_report):
+ repository = RepositoryFactory.create(
+ owner__username="joseph-sentry",
+ owner__service="gitlab",
+ owner__unencrypted_oauth_token="test1nioqi3p3681oa43",
+ service_id="47404140",
+ name="example-python",
+ image_token="abcdefghij",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(
+ repository=repository, commitid="0fc784af11c401449e56b24a174bae7b9af86c98"
+ )
+ head_commit = CommitFactory.create(
+ repository=repository,
+ branch="behind",
+ commitid="0b6a213fc300cd328c0625f38f30432ee6e066e5",
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ pullid=5,
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(small_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "15014576", "username": "joseph-sentry"},
+ "base": {
+ "branch": "main",
+ "commitid": "0fc784af11c401449e56b24a174bae7b9af86c98",
+ },
+ "head": {
+ "branch": "behind",
+ "commitid": "0b6a213fc300cd328c0625f38f30432ee6e066e5",
+ },
+ "state": "open",
+ "title": "Behind",
+ "id": "1",
+ "number": "1",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_for_upgrade(dbsession, request, sample_report, small_report):
+ repository = RepositoryFactory.create(
+ owner__service="github",
+ owner__username="codecove2e",
+ name="example-python",
+ owner__unencrypted_oauth_token="ghp_testgkdo1u8jqexy9wabk1n0puoetf9ziam5",
+ image_token="abcdefghij",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(
+ repository=repository, commitid="93189ce50f224296d6412e2884b93dcc3c7c8654"
+ )
+ head_commit = CommitFactory.create(
+ repository=repository,
+ branch="new_branch",
+ commitid="8589c19ce95a2b13cf7b3272cbf275ca9651ae9c",
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ pullid=2,
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(small_report)
+ )
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(sample_report)
+ )
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecove2e"},
+ "base": {
+ "branch": "master",
+ "commitid": "93189ce50f224296d6412e2884b93dcc3c7c8654",
+ },
+ "head": {
+ "branch": "codecove2e-patch-3",
+ "commitid": "8589c19ce95a2b13cf7b3272cbf275ca9651ae9c",
+ },
+ "state": "open",
+ "title": "Update __init__.py",
+ "id": "4",
+ "number": "4",
+ },
+ ),
+ )
+ )
+
+
+@pytest.fixture
+def sample_comparison_for_limited_upload(
+ dbsession, request, sample_report, small_report
+):
+ repository = RepositoryFactory.create(
+ owner__username="test-acc9",
+ owner__service="github",
+ name="priv_example",
+ owner__unencrypted_oauth_token="ghp_test1xwr5rxl12dbm97a7r4anr6h67uw0thf",
+ image_token="abcdefghij",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(
+ repository=repository, commitid="ef6edf5ae6643d53a7971fb8823d3f7b2ac65619"
+ )
+ head_commit = CommitFactory.create(
+ repository=repository,
+ branch="featureA",
+ commitid="610ada9fa2bbc49f1a08917da3f73bef2d03709c",
+ )
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ pullid=3,
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(commit=base_commit, report=small_report)
+ head_full_commit = FullCommit(commit=head_commit, report=sample_report)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "dana-yaish"},
+ "base": {
+ "branch": "main",
+ "commitid": "ef6edf5ae6643d53a7971fb8823d3f7b2ac65619",
+ },
+ "head": {
+ "branch": "featureA",
+ "commitid": "610ada9fa2bbc49f1a08917da3f73bef2d03709c",
+ },
+ "state": "open",
+ "title": "Create randomcommit.me",
+ "id": "1",
+ "number": "1",
+ },
+ ),
+ )
+ )
+
+
+@pytest.mark.usefixtures("is_not_first_pull")
+class TestCommentNotifierIntegration(object):
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ mock_all_plans_and_tiers()
+
+ @pytest.mark.django_db
+ def test_notify(self, sample_comparison, codecov_vcr, mock_configuration):
+ sample_comparison.context = ComparisonContext(
+ all_tests_passed=True, test_results_error=None
+ )
+ mock_configuration._params["setup"] = {
+ "codecov_url": None,
+ "codecov_dashboard_url": None,
+ }
+ comparison = sample_comparison
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ message = [
+ "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "> Project coverage is 60.00%. Comparing base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?dropdown=coverage&el=desc) to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?dropdown=coverage&el=desc).",
+ "> Report is 2 commits behind head on main.",
+ "",
+ ":white_check_mark: All tests successful. No failed tests found.",
+ "",
+ ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.",
+ "",
+ "[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ "## main #9 +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ "| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ "| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `?` | `?` | |",
+ "| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ "[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ "[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ "> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=footer). Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(result.data_sent["message"], message):
+ assert exp == res
+ assert result.data_sent["message"] == message
+ assert result.data_sent == {"commentid": None, "message": message, "pullid": 9}
+ assert result.data_received == {"id": 1699669247}
+
+ @pytest.mark.django_db
+ def test_notify_test_results_error(
+ self, sample_comparison, codecov_vcr, mock_configuration
+ ):
+ sample_comparison.context = ComparisonContext(
+ all_tests_passed=False,
+ test_results_error=":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ )
+ mock_configuration._params["setup"] = {
+ "codecov_url": None,
+ "codecov_dashboard_url": None,
+ }
+ comparison = sample_comparison
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ message = [
+ "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "> Project coverage is 60.00%. Comparing base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?dropdown=coverage&el=desc) to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?dropdown=coverage&el=desc).",
+ "> Report is 2 commits behind head on main.",
+ "",
+ ":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ "",
+ ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.",
+ "",
+ "[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ "## main #9 +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ "| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ "| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `?` | `?` | |",
+ "| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ "[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ "[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ "> Powered by [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=footer). Last update [5b174c2...5601846](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(result.data_sent["message"], message):
+ assert exp == res
+ assert result.data_sent["message"] == message
+ assert result.data_sent == {"commentid": None, "message": message, "pullid": 9}
+ assert result.data_received == {"id": 1699669247}
+
+ @pytest.mark.django_db
+ def test_notify_upgrade(
+ self, dbsession, sample_comparison_for_upgrade, codecov_vcr, mock_configuration
+ ):
+ mock_configuration._params["setup"] = {"codecov_dashboard_url": None}
+ comparison = sample_comparison_for_upgrade
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ decoration_type=Decoration.upgrade,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ expected_message = [
+ "The author of this PR, codecove2e, is not an activated member of this organization on Codecov.",
+ "Please [activate this user on Codecov](https://app.codecov.io/members/gh/codecove2e) to display this PR comment.",
+ "Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.",
+ "Please don't hesitate to email us at support@codecov.io with any questions.",
+ ]
+ for exp, res in zip(result.data_sent["message"], expected_message):
+ assert exp == res
+ assert result.data_sent["message"] == expected_message
+ assert result.data_sent == {
+ "commentid": None,
+ "message": expected_message,
+ "pullid": 2,
+ }
+ assert result.data_received == {"id": 1361234119}
+
+ @pytest.mark.django_db
+ def test_notify_upload_limited(
+ self,
+ dbsession,
+ sample_comparison_for_limited_upload,
+ codecov_vcr,
+ mock_configuration,
+ ):
+ mock_configuration._params["setup"] = {
+ "codecov_url": None,
+ "codecov_dashboard_url": "https://app.codecov.io",
+ }
+ comparison = sample_comparison_for_limited_upload
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ decoration_type=Decoration.upload_limit,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ expected_message = [
+ "## [Codecov](https://app.codecov.io/plan/gh/test-acc9) upload limit reached :warning:",
+ "This org is currently on the free Basic Plan; which includes 250 free private repo uploads each rolling month.\
+ This limit has been reached and additional reports cannot be generated. For unlimited uploads,\
+ upgrade to our [pro plan](https://app.codecov.io/plan/gh/test-acc9).",
+ "",
+ "**Do you have questions or need help?** Connect with our sales team today at ` sales@codecov.io `",
+ ]
+ for exp, res in zip(result.data_sent["message"], expected_message):
+ assert exp == res
+ assert result.data_sent["message"] == expected_message
+ assert result.data_sent == {
+ "commentid": None,
+ "message": expected_message,
+ "pullid": 3,
+ }
+ assert result.data_received == {"id": 1111984446}
+
+ @pytest.mark.django_db
+ def test_notify_gitlab(
+ self, sample_comparison_gitlab, codecov_vcr, mock_configuration
+ ):
+ mock_configuration._params["setup"] = {
+ "codecov_url": None,
+ "codecov_dashboard_url": None,
+ }
+ comparison = sample_comparison_gitlab
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ message = [
+ "## [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "> Project coverage is 60.00%. Comparing base [(`0fc784a`)](https://app.codecov.io/gl/joseph-sentry/example-python/commit/0fc784af11c401449e56b24a174bae7b9af86c98?dropdown=coverage&el=desc) to head [(`0b6a213`)](https://app.codecov.io/gl/joseph-sentry/example-python/commit/0b6a213fc300cd328c0625f38f30432ee6e066e5?dropdown=coverage&el=desc).",
+ "",
+ "[](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ "## main #5 +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ "| [Flag](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ "| [integration](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5/flags?src=pr&el=flag) | `?` | `?` | |",
+ "| [unit](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ "[see 2 files with indirect coverage changes](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ "[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ "> Powered by [Codecov](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5?dropdown=coverage&src=pr&el=footer). Last update [0fc784a...0b6a213](https://app.codecov.io/gl/joseph-sentry/example-python/pull/5?dropdown=coverage&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(result.data_sent["message"], message):
+ assert exp == res
+ assert result.data_sent["message"] == message
+ assert result.data_sent == {"commentid": None, "message": message, "pullid": 5}
+ assert result.data_received == {"id": 1457135397}
+
+ @pytest.mark.django_db
+ def test_notify_new_layout(
+ self, sample_comparison, codecov_vcr, mock_configuration
+ ):
+ mock_configuration._params["setup"] = {"codecov_dashboard_url": None}
+ comparison = sample_comparison
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, files, newfooter",
+ "hide_comment_details": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=comparison.repository_service,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ message = [
+ "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "> Project coverage is 60.00%. Comparing base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?dropdown=coverage&el=desc) to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?dropdown=coverage&el=desc).",
+ "> Report is 2 commits behind head on main.",
+ "",
+ ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ "[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ "## main #9 +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ "| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ "| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `?` | `?` | |",
+ "| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ "[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more)",
+ "",
+ " ",
+ "",
+ "[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(result.data_sent["message"], message):
+ assert exp == res
+
+ assert result.data_sent["message"] == message
+ assert result.data_sent == {"commentid": None, "message": message, "pullid": 9}
+ assert result.data_received == {"id": 1699669290}
+
+ @pytest.mark.django_db
+ def test_notify_with_components(
+ self, sample_comparison, codecov_vcr, mock_configuration
+ ):
+ mock_configuration._params["setup"] = {"codecov_dashboard_url": None}
+ comparison = sample_comparison
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, files, components, newfooter",
+ "hide_comment_details": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]}
+ ]
+ }
+ },
+ repository_service=comparison.repository_service,
+ )
+ result = notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ message = [
+ "## [Codecov](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "> Project coverage is 60.00%. Comparing base [(`5b174c2`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5b174c2b40d501a70c479e91025d5109b1ad5c1b?dropdown=coverage&el=desc) to head [(`5601846`)](https://app.codecov.io/gh/joseph-sentry/codecov-demo/commit/5601846871b8142ab0df1e0b8774756c658bcc7d?dropdown=coverage&el=desc).",
+ "> Report is 2 commits behind head on main.",
+ "",
+ ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ "[](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ "## main #9 +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ "| [Flag](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ "| [integration](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `?` | `?` | |",
+ "| [unit](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ "[see 2 files with indirect coverage changes](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "| [Components](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=components) | Coverage Δ | |",
+ "|---|---|---|",
+ "| [go_files](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9/components?src=pr&el=component) | `62.50% <ø> (+12.50%)` | :arrow_up: |",
+ "",
+ " ",
+ "",
+ "[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/joseph-sentry/codecov-demo/pull/9?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(result.data_sent["message"], message):
+ assert exp == res
+
+ assert result.data_sent["message"] == message
+ assert result.data_sent == {"commentid": None, "message": message, "pullid": 9}
+ assert result.data_received == {"id": 1699669323}
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_checks.py b/apps/worker/services/notification/notifiers/tests/unit/test_checks.py
new file mode 100644
index 0000000000..564b0e8966
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_checks.py
@@ -0,0 +1,2381 @@
+from copy import deepcopy
+from unittest.mock import Mock
+from urllib.parse import quote_plus
+
+import pytest
+from shared.reports.readonly import ReadOnlyReport
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportLine
+from shared.torngit.exceptions import TorngitClientGeneralError, TorngitError
+from shared.torngit.status import Status
+from shared.yaml.user_yaml import UserYaml
+
+from services.decoration import Decoration
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.checks import (
+ ChangesChecksNotifier,
+ PatchChecksNotifier,
+ ProjectChecksNotifier,
+)
+from services.notification.notifiers.checks.base import ChecksNotifier
+from services.notification.notifiers.checks.checks_with_fallback import (
+ ChecksWithFallback,
+)
+from services.notification.notifiers.mixins.status import (
+ HelperTextKey,
+ HelperTextTemplate,
+)
+from services.notification.notifiers.status import PatchStatusNotifier
+from tests.helpers import mock_all_plans_and_tiers
+
+
+@pytest.fixture
+def mock_repo_provider(mock_repo_provider):
+ compare_result = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["46", "12", "47", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include`` to your ``.coveragerc``",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["150", "5", "158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. Please contact email our Support at [support@codecov.io](mailto:support@codecov.io)",
+ "-",
+ "+We are happy to help if you have any questions. Please contact email our Support at `support@codecov.io `_.",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ },
+ "commits": [
+ {
+ "commitid": "b92edba44fdd29fcc506317cc3ddeae1a723dd08",
+ "message": "Update README.rst",
+ "timestamp": "2018-07-09T23:51:16Z",
+ "author": {
+ "id": 8398772,
+ "username": "jerrode",
+ "name": "Jerrod",
+ "email": "jerrod@fundersclub.com",
+ },
+ },
+ {
+ "commitid": "6ae5f1795a441884ed2847bb31154814ac01ef38",
+ "message": "Update README.rst",
+ "timestamp": "2018-04-26T08:35:58Z",
+ "author": {
+ "id": 11602092,
+ "username": "TomPed",
+ "name": "Thomas Pedbereznak",
+ "email": "tom@tomped.com",
+ },
+ },
+ ],
+ }
+ mock_repo_provider.get_compare.return_value = compare_result
+ return mock_repo_provider
+
+
+@pytest.fixture
+def comparison_with_multiple_changes(sample_comparison):
+ first_report = Report()
+ second_report = Report()
+ # DELETED FILE
+ first_deleted_file = ReportFile("deleted.py")
+ first_deleted_file.append(10, ReportLine.create(coverage=1))
+ first_deleted_file.append(12, ReportLine.create(coverage=0))
+ first_report.append(first_deleted_file)
+ # ADDED FILE
+ second_added_file = ReportFile("added.py")
+ second_added_file.append(99, ReportLine.create(coverage=1))
+ second_added_file.append(101, ReportLine.create(coverage=0))
+ second_report.append(second_added_file)
+ # MODIFIED FILE
+ first_modified_file = ReportFile("modified.py")
+ first_modified_file.append(17, ReportLine.create(coverage=1))
+ first_modified_file.append(18, ReportLine.create(coverage=1))
+ first_modified_file.append(19, ReportLine.create(coverage=1))
+ first_modified_file.append(20, ReportLine.create(coverage=0))
+ first_modified_file.append(21, ReportLine.create(coverage=1))
+ first_modified_file.append(22, ReportLine.create(coverage=1))
+ first_modified_file.append(23, ReportLine.create(coverage=1))
+ first_modified_file.append(24, ReportLine.create(coverage=1))
+ first_report.append(first_modified_file)
+ second_modified_file = ReportFile("modified.py")
+ second_modified_file.append(18, ReportLine.create(coverage=1))
+ second_modified_file.append(19, ReportLine.create(coverage=0))
+ second_modified_file.append(20, ReportLine.create(coverage=0))
+ second_modified_file.append(21, ReportLine.create(coverage=1))
+ second_modified_file.append(22, ReportLine.create(coverage=0))
+ second_modified_file.append(23, ReportLine.create(coverage=0))
+ second_modified_file.append(24, ReportLine.create(coverage=1))
+ second_report.append(second_modified_file)
+ # RENAMED WITHOUT CHANGES
+ first_renamed_without_changes_file = ReportFile("old_renamed.py")
+ first_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ first_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ first_report.append(first_renamed_without_changes_file)
+ second_renamed_without_changes_file = ReportFile("renamed.py")
+ second_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ second_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_without_changes_file)
+ # RENAMED WITH COVERAGE CHANGES FILE
+ first_renamed_file = ReportFile("old_renamed_with_changes.py")
+ first_renamed_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_file.append(3, ReportLine.create(coverage=1))
+ first_renamed_file.append(5, ReportLine.create(coverage=0))
+ first_renamed_file.append(8, ReportLine.create(coverage=1))
+ first_renamed_file.append(13, ReportLine.create(coverage=1))
+ first_report.append(first_renamed_file)
+ second_renamed_file = ReportFile("renamed_with_changes.py")
+ second_renamed_file.append(5, ReportLine.create(coverage=1))
+ second_renamed_file.append(8, ReportLine.create(coverage=0))
+ second_renamed_file.append(13, ReportLine.create(coverage=1))
+ second_renamed_file.append(21, ReportLine.create(coverage=1))
+ second_renamed_file.append(34, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_file)
+ # UNRELATED FILE
+ first_unrelated_file = ReportFile("unrelated.py")
+ first_unrelated_file.append(1, ReportLine.create(coverage=1))
+ first_unrelated_file.append(2, ReportLine.create(coverage=1))
+ first_unrelated_file.append(4, ReportLine.create(coverage=1))
+ first_unrelated_file.append(16, ReportLine.create(coverage=0))
+ first_unrelated_file.append(256, ReportLine.create(coverage=1))
+ first_unrelated_file.append(65556, ReportLine.create(coverage=1))
+ first_report.append(first_unrelated_file)
+ second_unrelated_file = ReportFile("unrelated.py")
+ second_unrelated_file.append(2, ReportLine.create(coverage=1))
+ second_unrelated_file.append(4, ReportLine.create(coverage=0))
+ second_unrelated_file.append(8, ReportLine.create(coverage=0))
+ second_unrelated_file.append(16, ReportLine.create(coverage=1))
+ second_unrelated_file.append(32, ReportLine.create(coverage=0))
+ second_report.append(second_unrelated_file)
+ sample_comparison.project_coverage_base.report = ReadOnlyReport.create_from_report(
+ first_report
+ )
+ sample_comparison.head.report = ReadOnlyReport.create_from_report(second_report)
+ return sample_comparison
+
+
+@pytest.fixture
+def multiple_diff_changes():
+ return {
+ "files": {
+ "modified.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["20", "8", "20", "8"],
+ "lines": [
+ " return k * k",
+ " ",
+ " ",
+ "-def k(l):",
+ "- return 2 * l",
+ "+def k(var):",
+ "+ return 2 * var",
+ " ",
+ " ",
+ " def sample_function():",
+ ],
+ }
+ ],
+ "stats": {"added": 2, "removed": 2},
+ "type": "modified",
+ },
+ "renamed.py": {
+ "before": "old_renamed.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "renamed_with_changes.py": {
+ "before": "old_renamed_with_changes.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "added.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["0", "0", "1", ""],
+ "lines": ["+This is an explanation"],
+ }
+ ],
+ "stats": {"added": 1, "removed": 0},
+ "type": "new",
+ },
+ "deleted.py": {
+ "before": "tests/test_sample.py",
+ "stats": {"added": 0, "removed": 0},
+ "type": "deleted",
+ },
+ }
+ }
+
+
+class TestChecksWithFallback(object):
+ def test_checks_403_failure(self, sample_comparison, mocker, mock_repo_provider):
+ mock_repo_provider.create_check_run = Mock(
+ side_effect=TorngitClientGeneralError(
+ 403, response_data="No Access", message="No Access"
+ )
+ )
+
+ checks_notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ status_notifier = mocker.MagicMock(
+ PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ )
+ status_notifier.notify.return_value = "success"
+ fallback_notifier = ChecksWithFallback(
+ checks_notifier=checks_notifier, status_notifier=status_notifier
+ )
+ assert fallback_notifier.name == "checks-patch-with-fallback"
+ assert fallback_notifier.title == "title"
+ assert fallback_notifier.is_enabled() == True
+ assert fallback_notifier.notification_type.value == "checks_patch"
+ assert fallback_notifier.decoration_type is None
+
+ res = fallback_notifier.notify(sample_comparison)
+ fallback_notifier.store_results(sample_comparison, res)
+ assert status_notifier.notify.call_count == 1
+ assert fallback_notifier.name == "checks-patch-with-fallback"
+ assert fallback_notifier.title == "title"
+ assert fallback_notifier.is_enabled() == True
+ assert fallback_notifier.notification_type.value == "checks_patch"
+ assert fallback_notifier.decoration_type is None
+ assert res == "success"
+
+ def test_checks_failure(self, sample_comparison, mocker, mock_repo_provider):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.create_check_run = Mock(
+ side_effect=TorngitClientGeneralError(
+ 409, response_data="No Access", message="No Access"
+ )
+ )
+
+ checks_notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ status_notifier = mocker.MagicMock(
+ PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ )
+ status_notifier.notify.return_value = "success"
+ fallback_notifier = ChecksWithFallback(
+ checks_notifier=checks_notifier, status_notifier=status_notifier
+ )
+ assert fallback_notifier.name == "checks-patch-with-fallback"
+ assert fallback_notifier.title == "title"
+ assert fallback_notifier.is_enabled() == True
+ assert fallback_notifier.notification_type.value == "checks_patch"
+ assert fallback_notifier.decoration_type is None
+
+ res = fallback_notifier.notify(sample_comparison)
+ assert res.notification_successful == False
+ assert res.explanation == "client_side_error_provider"
+
+ mock_repo_provider.create_check_run = Mock(side_effect=TorngitError())
+
+ res = fallback_notifier.notify(sample_comparison)
+ assert res.notification_successful == False
+ assert res.explanation == "server_side_error_provider"
+
+ mock_repo_provider.create_check_run.return_value = 1234
+ mock_repo_provider.update_check_run = Mock(side_effect=TorngitError())
+ res = fallback_notifier.notify(sample_comparison)
+ assert res.notification_successful == False
+ assert res.explanation == "server_side_error_provider"
+
+ def test_checks_no_pull(self, sample_comparison_without_pull, mocker):
+ comparison = sample_comparison_without_pull
+ checks_notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ status_notifier = mocker.MagicMock(
+ PatchStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ )
+ status_notifier.notify.return_value = "success"
+ fallback_notifier = ChecksWithFallback(
+ checks_notifier=checks_notifier, status_notifier=status_notifier
+ )
+ result = fallback_notifier.notify(sample_comparison_without_pull)
+ assert result == "success"
+ assert status_notifier.notify.call_count == 1
+
+ def test_notify_pull_request_not_in_provider(
+ self, dbsession, sample_comparison_database_pull_without_provider, mocker
+ ):
+ comparison = sample_comparison_database_pull_without_provider
+ checks_notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ status_notifier = mocker.MagicMock(
+ PatchStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ )
+ status_notifier.notify.return_value = "success"
+ fallback_notifier = ChecksWithFallback(
+ checks_notifier=checks_notifier, status_notifier=status_notifier
+ )
+ result = fallback_notifier.notify(comparison)
+ assert result == "success"
+ assert status_notifier.notify.call_count == 1
+
+ def test_notify_closed_pull_request(self, dbsession, sample_comparison, mocker):
+ sample_comparison.pull.state = "closed"
+
+ checks_notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ status_notifier = mocker.MagicMock(
+ PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ )
+ status_notifier.notify.return_value = "success"
+ fallback_notifier = ChecksWithFallback(
+ checks_notifier=checks_notifier, status_notifier=status_notifier
+ )
+ result = fallback_notifier.notify(sample_comparison)
+ assert result == "success"
+ assert status_notifier.notify.call_count == 1
+
+
+class TestBaseChecksNotifier(object):
+ def test_create_annotations_single_segment(self, sample_comparison):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ diff = {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": "None",
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ }
+ ],
+ "totals": True,
+ }
+ }
+ }
+ expected_annotations = [
+ {
+ "path": "file_1.go",
+ "start_line": 10,
+ "end_line": 10,
+ "annotation_level": "warning",
+ "message": "Added line #L10 was not covered by tests",
+ }
+ ]
+ annotations = notifier.create_annotations(sample_comparison, diff)
+ assert expected_annotations == annotations
+
+ def test_create_annotations_multiple_segments(self, sample_comparison):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ diff = {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": "None",
+ "segments": [
+ {
+ "header": ["1", "1", "1", "1"],
+ "lines": [
+ " ",
+ "+ You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["150", "5", "158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. Please contact email our Support at [support@codecov.io](mailto:support@codecov.io)",
+ "-",
+ "+We are happy to help if you have any questions. Please contact email our Support at `support@codecov.io `_.",
+ ],
+ },
+ ],
+ "totals": True,
+ }
+ }
+ }
+ expected_annotations = [
+ {
+ "path": "file_1.go",
+ "start_line": 2,
+ "end_line": 2,
+ "annotation_level": "warning",
+ "message": "Added line #L2 was not covered by tests",
+ },
+ {
+ "path": "file_1.go",
+ "start_line": 10,
+ "end_line": 10,
+ "annotation_level": "warning",
+ "message": "Added line #L10 was not covered by tests",
+ },
+ ]
+ annotations = notifier.create_annotations(sample_comparison, diff)
+ assert expected_annotations == annotations
+
+ def test_get_lines_to_annotate_no_consecutive_lines(self, sample_comparison):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ files_with_change = [
+ {
+ "type": "modified",
+ "path": "file_1.go",
+ "additions": [
+ {"head_line": 1},
+ {"head_line": 2},
+ {"head_line": 3},
+ {"head_line": 5},
+ {"head_line": 6},
+ {"head_line": 8},
+ ],
+ }
+ ]
+ expected_result = [
+ {
+ "type": "new_line",
+ "line": 2,
+ "coverage": 0,
+ "path": "file_1.go",
+ "end_line": 2,
+ },
+ {
+ "type": "new_line",
+ "line": 6,
+ "coverage": 0,
+ "path": "file_1.go",
+ "end_line": 6,
+ },
+ ]
+ result = notifier.get_lines_to_annotate(sample_comparison, files_with_change)
+ assert expected_result == result
+
+ def test_get_lines_to_annotate_consecutive_lines(self, sample_comparison):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ report = Report()
+ first_deleted_file = ReportFile("file_1.go")
+ first_deleted_file.append(1, ReportLine.create(coverage=0))
+ first_deleted_file.append(2, ReportLine.create(coverage=0))
+ first_deleted_file.append(3, ReportLine.create(coverage=0))
+ first_deleted_file.append(5, ReportLine.create(coverage=0))
+ report.append(first_deleted_file)
+ sample_comparison.head.report = report
+ files_with_change = [
+ {
+ "type": "modified",
+ "path": "file_1.go",
+ "additions": [
+ {"head_line": 1},
+ {"head_line": 2},
+ {"head_line": 3},
+ {"head_line": 5},
+ {"head_line": 6},
+ {"head_line": 8},
+ ],
+ }
+ ]
+ expected_result = [
+ {
+ "type": "new_line",
+ "line": 1,
+ "coverage": 0,
+ "path": "file_1.go",
+ "end_line": 3,
+ },
+ {
+ "type": "new_line",
+ "line": 5,
+ "coverage": 0,
+ "path": "file_1.go",
+ "end_line": 5,
+ },
+ ]
+ result = notifier.get_lines_to_annotate(sample_comparison, files_with_change)
+ assert expected_result == result
+
+
+class TestPatchChecksNotifier(object):
+ def test_paginate_annotations(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ sample_array = list(range(1, 61, 1))
+ expected_result = [list(range(1, 51, 1)), list(range(51, 61, 1))]
+ result = list(notifier.paginate_annotations(sample_array))
+ assert expected_result == result
+
+ def test_build_flag_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "66.67% of diff hit (target 50.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_flag_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n66.67% of diff hit (target 50.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "Codecov Report",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_upgrade_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nThe author of this PR, codecov-test-user, is not an activated member of this organization on Codecov.\nPlease [activate this user on Codecov](test.example.br/members/gh/test_build_upgrade_payload) to display a detailed status check.\nCoverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.\nPlease don't hesitate to email us at support@codecov.io with any questions.",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ @pytest.mark.django_db
+ def test_build_default_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_all_plans_and_tiers()
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "66.67% of diff hit (target 50.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_default_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n66.67% of diff hit (target 50.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result["output"]["summary"] == result["output"]["summary"]
+ assert expected_result == result
+
+ def test_build_payload_target_coverage_failure(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"target": "70%", "paths": ["pathone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "failure",
+ "output": {
+ "title": "66.67% of diff hit (target 70.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_target_coverage_failure/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n66.67% of diff hit (target 70.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PATCH: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="patch",
+ notification_type="check",
+ point_of_comparison="patch",
+ coverage=66.67,
+ target="70.00",
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_without_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({"github_checks": {"annotations": True}}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "No report found to compare against",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_without_base_report/{sample_comparison_without_base_report.head.commit.repository.name}/pull/{sample_comparison_without_base_report.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo report found to compare against",
+ "annotations": [
+ {
+ "path": "file_1.go",
+ "start_line": 10,
+ "end_line": 10,
+ "annotation_level": "warning",
+ "message": "Added line #L10 was not covered by tests",
+ }
+ ],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_build_payload_target_coverage_failure_within_threshold(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ third_file = ReportFile("file_3.c")
+ third_file.append(100, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(101, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(102, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(103, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report = sample_comparison.project_coverage_base.report.inner_report
+ report.append(third_file)
+ sample_comparison.project_coverage_base.report = (
+ ReadOnlyReport.create_from_report(report)
+ )
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={"threshold": "5"},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "66.67% of diff hit (within 5.00% threshold of 70.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_target_coverage_failure_within_threshold/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n66.67% of diff hit (within 5.00% threshold of 70.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result["state"] == result["state"]
+ assert expected_result["output"]["summary"] == result["output"]["summary"]
+ assert expected_result["output"] == result["output"]
+ assert expected_result == result
+
+ def test_build_payload_with_multiple_changes(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ ):
+ json_diff = multiple_diff_changes
+ original_value = deepcopy(multiple_diff_changes)
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "failure",
+ "output": {
+ "title": "50.00% of diff hit (target 76.92%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_with_multiple_changes/{comparison_with_multiple_changes.head.commit.repository.name}/pull/{comparison_with_multiple_changes.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n50.00% of diff hit (target 76.92%)",
+ "annotations": [],
+ },
+ "included_helper_text": {}, # not a custom target, no helper text
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert expected_result["state"] == result["state"]
+ assert expected_result["output"] == result["output"]
+ assert expected_result == result
+ # assert that the value of diff was not changed
+ for filename in original_value["files"]:
+ assert original_value["files"][filename].get(
+ "segments"
+ ) == multiple_diff_changes["files"][filename].get("segments")
+
+ def test_github_checks_annotations_yaml(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ ):
+ mock_repo_provider.get_compare.return_value = {"diff": multiple_diff_changes}
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ empty_annotations = []
+
+ # checks_yaml_field can be None
+ notifier = PatchChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert empty_annotations == result["output"]["annotations"]
+
+ # checks_yaml_field can be dict
+ notifier = PatchChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({"github_checks": {"one": "two"}}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert empty_annotations == result["output"]["annotations"]
+
+ # checks_yaml_field can be bool
+ notifier = PatchChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({"github_checks": False}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert empty_annotations == result["output"]["annotations"]
+
+ # checks_yaml_field with annotations
+ notifier = PatchChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({"github_checks": {"annotations": True}}),
+ repository_service=mock_repo_provider,
+ )
+ expected_annotations = [
+ {
+ "path": "modified.py",
+ "start_line": 23,
+ "end_line": 23,
+ "annotation_level": "warning",
+ "message": "Added line #L23 was not covered by tests",
+ }
+ ]
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert expected_annotations == result["output"]["annotations"]
+
+ def test_build_payload_no_diff(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_compare.return_value = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["15", "8", "15", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ }
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ }
+ }
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ assert notifier.is_enabled()
+ notifier.name
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"Coverage not affected when comparing {base_commit.commitid[:7]}...{head_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_no_diff/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nCoverage not affected when comparing {base_commit.commitid[:7]}...{head_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert notifier.notification_type.value == "checks_patch"
+ assert expected_result == result
+
+ def test_send_notification(self, sample_comparison, mocker, mock_repo_provider):
+ comparison = sample_comparison
+ payload = {
+ "state": "success",
+ "output": {"title": "Codecov Report", "summary": "Summary"},
+ "url": "https://app.codecov.io/gh/codecov/worker/compare/100?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term=codecov",
+ }
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.send_notification(sample_comparison, payload)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {"title": "Codecov Report", "summary": "Summary"},
+ "url": "https://app.codecov.io/gh/codecov/worker/compare/100?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term=codecov",
+ }
+
+ def test_send_notification_annotations_paginations(
+ self, sample_comparison, mocker, mock_repo_provider
+ ):
+ comparison = sample_comparison
+ payload = {
+ "state": "success",
+ "output": {
+ "title": "Codecov Report",
+ "summary": "Summary",
+ "annotations": list(range(1, 61, 1)),
+ },
+ }
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_calls = [
+ {
+ "output": {
+ "title": "Codecov Report",
+ "summary": "Summary",
+ "annotations": list(range(1, 51, 1)),
+ },
+ "url": None,
+ },
+ {
+ "output": {
+ "title": "Codecov Report",
+ "summary": "Summary",
+ "annotations": list(range(51, 61, 1)),
+ },
+ "url": None,
+ },
+ ]
+ result = notifier.send_notification(sample_comparison, payload)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ calls = [call[1] for call in mock_repo_provider.update_check_run.call_args_list]
+ assert expected_calls == calls
+ assert mock_repo_provider.update_check_run.call_count == 2
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": "Codecov Report",
+ "summary": "Summary",
+ "annotations": list(range(1, 61, 1)),
+ },
+ }
+
+ def test_notify(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["pathone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": f"Coverage not affected when comparing {base_commit.commitid[:7]}...{head_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_notify/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nCoverage not affected when comparing {base_commit.commitid[:7]}...{head_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_notify/{sample_comparison.head.commit.repository.name}/pull/{comparison.pull.pullid}",
+ }
+
+ def test_notify_passing_empty_upload(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["pathone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.passing_empty_upload,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": "Empty Upload",
+ "summary": "Non-testable files changed.",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_notify_passing_empty_upload/{sample_comparison.head.commit.repository.name}/pull/{comparison.pull.pullid}",
+ }
+
+ def test_notify_failing_empty_upload(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = PatchChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["pathone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.failing_empty_upload,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "failure",
+ "output": {
+ "title": "Empty Upload",
+ "summary": "Testable files changed",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_notify_failing_empty_upload/{sample_comparison.head.commit.repository.name}/pull/{comparison.pull.pullid}",
+ }
+
+ def test_notification_exception(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+
+ # Test exception handling when there's a TorngitClientError
+ mock_repo_provider.get_compare = Mock(
+ side_effect=TorngitClientGeneralError(
+ 400, response_data="Error", message="Error"
+ )
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == False
+ assert result.explanation == "client_side_error_provider"
+ assert result.data_sent is None
+
+ # Test exception handling when there's a TorngitError
+ mock_repo_provider.get_compare = Mock(side_effect=TorngitError())
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == False
+ assert result.explanation == "server_side_error_provider"
+ assert result.data_sent is None
+
+ def test_notification_exception_not_fit(self, sample_comparison, mocker):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ mocker.patch.object(
+ ChecksNotifier, "can_we_set_this_status", return_value=False
+ )
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "not_fit_criteria"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ def test_notification_exception_preexisting_commit_status(
+ self, sample_comparison, mocker, mock_repo_provider
+ ):
+ comparison = sample_comparison
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ mock_repo_provider.get_commit_statuses.return_value = Status(
+ [
+ {
+ "time": "2024-10-01T22:34:52Z",
+ "state": "success",
+ "description": "42.85% (+0.00%) compared to 36be7f3",
+ "context": "codecov/project/title",
+ }
+ ]
+ )
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "preexisting_commit_status"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ def test_checks_with_after_n_builds(self, sample_comparison, mocker):
+ notifier = ChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml(
+ {
+ "coverage": {
+ "status": {"project": True, "patch": True, "changes": True}
+ },
+ "flag_management": {
+ "default_rules": {"carryforward": False},
+ "individual_flags": [
+ {
+ "name": "unit",
+ "statuses": [{"type": "patch"}],
+ "after_n_builds": 3,
+ },
+ ],
+ },
+ }
+ ),
+ repository_service=None,
+ )
+
+ mocker.patch.object(ChecksNotifier, "can_we_set_this_status", return_value=True)
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "need_more_builds"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+
+class TestChangesChecksNotifier(object):
+ def test_build_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "No indirect coverage changes found",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo indirect coverage changes found",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+ assert notifier.notification_type.value == "checks_changes"
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ notifier = ChangesChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "Codecov Report",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_upgrade_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nThe author of this PR, codecov-test-user, is not an activated member of this organization on Codecov.\nPlease [activate this user on Codecov](test.example.br/members/gh/test_build_upgrade_payload) to display a detailed status check.\nCoverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.\nPlease don't hesitate to email us at support@codecov.io with any questions.",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_with_multiple_changes(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesChecksNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "failure",
+ "output": {
+ "title": "3 files have indirect coverage changes not visible in diff",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_with_multiple_changes/{comparison_with_multiple_changes.head.commit.repository.name}/pull/{comparison_with_multiple_changes.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n3 files have indirect coverage changes not visible in diff",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert expected_result == result
+
+ def test_build_payload_without_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = ChangesChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "Unable to determine changes, no report found at pull request base",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_without_base_report/{sample_comparison_without_base_report.head.commit.repository.name}/pull/{sample_comparison_without_base_report.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nUnable to determine changes, no report found at pull request base",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_build_failing_empty_upload_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ notifier = ChangesChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.failing_empty_upload,
+ )
+ expected_result = {
+ "state": "failure",
+ "output": {
+ "title": "Empty Upload",
+ "summary": "Testable files changed",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+
+class TestProjectChecksNotifier(object):
+ def test_analytics_url(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "codecov.io"
+ repo = sample_comparison.head.commit.repository
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ payload = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "text": "\n".join(
+ [
+ f"## [Codecov](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ f"> Merging [#{sample_comparison.pull.pullid}](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=desc) ({head_commit.commitid[:7]}) into [master](codecov.io/gh/test_build_default_payload/{repo.name}/commit/{sample_comparison.project_coverage_base.commit.commitid}?el=desc) ({base_commit.commitid[:7]}) will **increase** coverage by `10.00%`.",
+ "> The diff coverage is `66.67%`.",
+ "",
+ f"| [Files with missing lines](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ f"| [file\\_2.py](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_2.py#diff-ZmlsZV8yLnB5) | `50.00% <0.00%> (ø)` | `0.00% <0.00%> (ø%)` | |",
+ "",
+ ]
+ ),
+ },
+ }
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"comment": {"layout": "files"}},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.send_notification(sample_comparison, payload)
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)})\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "text": "\n".join(
+ [
+ f"## [Codecov](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}) Report",
+ f"> Merging [#{sample_comparison.pull.pullid}](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}) ({head_commit.commitid[:7]}) into [master](codecov.io/gh/test_build_default_payload/{repo.name}/commit/{sample_comparison.project_coverage_base.commit.commitid}?el=desc&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}) ({base_commit.commitid[:7]}) will **increase** coverage by `10.00%`.",
+ "> The diff coverage is `66.67%`.",
+ "",
+ f"| [Files with missing lines](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ f"| [file\\_2.py](codecov.io/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_2.py&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term={quote_plus(repo.owner.name)}#diff-ZmlsZV8yLnB5) | `50.00% <0.00%> (ø)` | `0.00% <0.00%> (ø%)` | |",
+ "",
+ ]
+ ),
+ },
+ }
+ assert expected_result["output"]["text"].split("\n") == result.data_sent[
+ "output"
+ ]["text"].split("\n")
+ assert expected_result == result.data_sent
+
+ def test_build_flag_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison)
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_flag_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert result == expected_result
+ assert notifier.notification_type.value == "checks_project"
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "Codecov Report",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_upgrade_payload/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nThe author of this PR, codecov-test-user, is not an activated member of this organization on Codecov.\nPlease [activate this user on Codecov](test.example.br/members/gh/test_build_upgrade_payload) to display a detailed status check.\nCoverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.\nPlease don't hesitate to email us at support@codecov.io with any questions.",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_passing_empty_upload_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.passing_empty_upload,
+ )
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "Empty Upload",
+ "summary": "Non-testable files changed.",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ @pytest.mark.django_db
+ def test_build_default_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_all_plans_and_tiers()
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"comment": {"layout": "files"}},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison)
+ repo = sample_comparison.head.commit.repository
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "text": "\n".join(
+ [
+ f"## [Codecov](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{base_commit.commitid[:7]}`)](test.example.br/gh/test_build_default_payload/{repo.name}/commit/{base_commit.commitid}?dropdown=coverage&el=desc) to head [(`{head_commit.commitid[:7]}`)](test.example.br/gh/test_build_default_payload/{repo.name}/commit/{head_commit.commitid}?dropdown=coverage&el=desc)."
+ f"",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/test_build_default_payload/{repo.name}/pull/{sample_comparison.pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ ]
+ ),
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert expected_result["output"]["text"].split("\n") == result["output"][
+ "text"
+ ].split("\n")
+ assert expected_result == result
+
+ @pytest.mark.django_db
+ def test_build_default_payload_with_flags(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_all_plans_and_tiers()
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"comment": {"layout": "files, flags"}},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison)
+ repo = sample_comparison.head.commit.repository
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "text": "\n".join(
+ [
+ f"## [Codecov](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{base_commit.commitid[:7]}`)](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/commit/{base_commit.commitid}?dropdown=coverage&el=desc) to head [(`{head_commit.commitid[:7]}`)](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/commit/{head_commit.commitid}?dropdown=coverage&el=desc)."
+ f"",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/test_build_default_payload_with_flags/{repo.name}/pull/{sample_comparison.pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ ]
+ ),
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert expected_result["output"]["text"].split("\n") == result["output"][
+ "text"
+ ].split("\n")
+ assert expected_result == result
+
+ @pytest.mark.django_db
+ def test_build_default_payload_with_flags_and_footer(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_all_plans_and_tiers()
+ test_name = "test_build_default_payload_with_flags_and_footer"
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"comment": {"layout": "files, flags, footer"}},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison)
+ repo = sample_comparison.head.commit.repository
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "text": "\n".join(
+ [
+ f"## [Codecov](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{base_commit.commitid[:7]}`)](test.example.br/gh/{test_name}/{repo.name}/commit/{base_commit.commitid}?dropdown=coverage&el=desc) to head [(`{head_commit.commitid[:7]}`)](test.example.br/gh/{test_name}/{repo.name}/commit/{head_commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in Codecov by Sentry](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by [Codecov](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=footer). Last update [{base_commit.commitid[:7]}...{head_commit.commitid[:7]}](test.example.br/gh/{test_name}/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ],
+ ),
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert expected_result["output"]["text"].split("\n") == result["output"][
+ "text"
+ ].split("\n")
+ assert expected_result == result
+
+ def test_build_default_payload_comment_off(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"comment": False},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison)
+ repo = sample_comparison.head.commit.repository
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_default_payload_comment_off/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert expected_result == result
+
+ def test_build_default_payload_negative_change_comment_off(
+ self, sample_comparison_negative_change, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_negative_change.head.commit.repository,
+ title="default",
+ notifier_yaml_settings={"removed_code_behavior": "removals_only"},
+ notifier_site_settings=True,
+ current_yaml={"comment": False},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_payload(sample_comparison_negative_change)
+ repo = sample_comparison_negative_change.head.commit.repository
+ base_commit = sample_comparison_negative_change.project_coverage_base.commit
+ expected_result = {
+ "state": "failure",
+ "output": {
+ "title": f"50.00% (-10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_default_payload_negative_change_comment_off/{repo.name}/pull/{sample_comparison_negative_change.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n50.00% (-10.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ assert expected_result == result
+
+ def test_build_payload_not_auto(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"target": "57%", "flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ repo = sample_comparison.head.commit.repository
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "60.00% (target 57.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_not_auto/{repo.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (target 57.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_no_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ repo = sample_comparison_without_base_report.head.commit.repository
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": "No report found to compare against",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_build_payload_no_base_report/{repo.name}/pull/{sample_comparison_without_base_report.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo report found to compare against",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_check_notify_no_path_match(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["pathone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": "No coverage information found on head",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_check_notify_no_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo coverage information found on head",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_check_notify_no_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}",
+ }
+
+ def test_check_notify_single_path_match(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+
+ base_commit = sample_comparison.project_coverage_base.commit
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful is True
+ assert result.explanation is None
+ expected_result = {
+ "state": "success",
+ "output": {
+ "title": f"62.50% (+12.50%) compared to {base_commit.commitid[0:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_check_notify_single_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n62.50% (+12.50%) compared to {base_commit.commitid[0:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_check_notify_single_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}",
+ }
+ assert result.data_sent["state"] == expected_result["state"]
+ assert (
+ result.data_sent["output"]["summary"]
+ == expected_result["output"]["summary"]
+ )
+ assert result.data_sent["output"] == expected_result["output"]
+
+ def test_check_notify_multiple_path_match(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_2.py", "file_1.go"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+
+ base_commit = sample_comparison.project_coverage_base.commit
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[0:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_check_notify_multiple_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[0:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_check_notify_multiple_path_match/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}",
+ }
+
+ def test_check_notify_with_paths(
+ self, sample_comparison, mocker, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ payload = {
+ "state": "success",
+ "output": {"title": "Codecov Report", "summary": "Summary"},
+ }
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+
+ notifier = ProjectChecksNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation is None
+ assert result.data_sent == {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[0:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_check_notify_with_paths/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[0:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/test_check_notify_with_paths/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}",
+ }
+
+ def test_notify_pass_behavior_when_coverage_not_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "pass",
+ "flags": ["integration", "missing"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n25.00% (+0.00%) compared to {base_commit.commitid[:7]} [Auto passed due to carriedforward or missing coverage]",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert (
+ expected_result.data_sent["output"]["summary"]
+ == result.data_sent["output"]["summary"]
+ )
+ assert expected_result.data_sent["output"] == result.data_sent["output"]
+ assert expected_result == result
+
+ def test_notify_pass_behavior_when_coverage_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "pass",
+ "flags": ["unit"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_include_behavior_when_coverage_not_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "include",
+ "flags": ["integration", "enterprise"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_coverage_not_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": ["missing"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="exclude_flag_coverage_not_uploaded_checks",
+ data_sent=None,
+ data_received=None,
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_coverage_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": ["unit"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_some_coverage_uploaded(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": [
+ "unit",
+ "missing",
+ "integration",
+ ], # only "unit" was uploaded, but this should still notify
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_no_flags(
+ self,
+ sample_comparison_coverage_carriedforward,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": None,
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ head_commit = sample_comparison_coverage_carriedforward.head.commit
+
+ # should send the check as normal if there are no flags
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": f"65.38% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n65.38% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison_coverage_carriedforward.pull.pullid}",
+ },
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert (
+ expected_result.data_sent["output"]["summary"]
+ == result.data_sent["output"]["summary"]
+ )
+ assert expected_result.data_sent["output"] == result.data_sent["output"]
+ assert expected_result.data_sent == result.data_sent
+ assert expected_result == result
+
+ def test_build_payload_comments_true(self, sample_comparison, mock_configuration):
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings={},
+ current_yaml={"comment": True},
+ repository_service=None,
+ )
+ res = notifier.build_payload(sample_comparison)
+ assert res == {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
+
+ def test_build_payload_comments_false(self, sample_comparison, mock_configuration):
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings={},
+ current_yaml={"comment": False},
+ repository_service=None,
+ )
+ res = notifier.build_payload(sample_comparison)
+ assert res == {
+ "state": "success",
+ "output": {
+ "title": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/{head_commit.repository.owner.username}/{head_commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ }
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_codecov_slack_app.py b/apps/worker/services/notification/notifiers/tests/unit/test_codecov_slack_app.py
new file mode 100644
index 0000000000..cb6e6919ee
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_codecov_slack_app.py
@@ -0,0 +1,156 @@
+from unittest.mock import patch
+
+from database.enums import Notification
+from services.notification.notifiers.codecov_slack_app import CodecovSlackAppNotifier
+
+
+class TestCodecovSlackAppNotifier(object):
+ def test_is_enabled(self, dbsession, mock_configuration, sample_comparison):
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": True},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": True}},
+ repository_service=None,
+ )
+ assert notifier.is_enabled() == True
+
+ def test_is_enable_false(self, dbsession, mock_configuration, sample_comparison):
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": False},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": False}},
+ repository_service=None,
+ )
+
+ assert notifier.is_enabled() is False
+
+ def test_notification_type(self, dbsession, mock_configuration, sample_comparison):
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": True},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": True}},
+ repository_service=None,
+ )
+ assert notifier.notification_type == Notification.codecov_slack_app
+
+ @patch("requests.post")
+ def test_notify(
+ self, mock_requests_post, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_requests_post.return_value.status_code = 200
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": True},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": True}},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert result.explanation == "Successfully notified slack app"
+
+ @patch("requests.post")
+ def test_notify_failure(
+ self, mock_requests_post, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_requests_post.return_value.status_code = 500
+ mock_requests_post.return_value.reason = "Internal Server Error"
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": True},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": True}},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == False
+ assert (
+ result.explanation
+ == "Failed to notify slack app\nError 500: Internal Server Error."
+ )
+
+ @patch("requests.post")
+ def test_notify_request_being_called(
+ self, mock_requests_post, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_requests_post.return_value.status_code = 200
+ notifier = CodecovSlackAppNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"enabled": True},
+ notifier_site_settings=True,
+ current_yaml={"slack_app": {"enabled": True}},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_successful == True
+ assert mock_requests_post.call_count == 1
+ assert mock_requests_post.is_called_with(
+ {
+ "repository": "sing-outside-letter",
+ "owner": "test_notify",
+ "comparison": {
+ "url": "https://app.codecov.io/gh/test_notify/sing-outside-letter/pull/24",
+ "message": "increased",
+ "coverage": "10.00",
+ "notation": "+",
+ "head_commit": {
+ "commitid": "936b768a1057bbe8371083ab4ec96a196ec730b6",
+ "branch": "new_branch",
+ "message": "Company here customer page by player threat.",
+ "author": "benjaminford",
+ "timestamp": "2019-02-01T17:59:47",
+ "ci_passed": True,
+ "totals": {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "85.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ },
+ "pull": 1,
+ },
+ "base_commit": {
+ "commitid": "db89f0ead214b647fe7eef9e1c42b78279e234bb",
+ "branch": None,
+ "message": "Morning loss contain impact old.",
+ "author": "stewartbrendan",
+ "timestamp": "2019-02-01T17:59:47",
+ "ci_passed": True,
+ "totals": {
+ "C": 0,
+ "M": 0,
+ "N": 0,
+ "b": 0,
+ "c": "85.00000",
+ "d": 0,
+ "diff": [1, 2, 1, 1, 0, "50.00000", 0, 0, 0, 0, 0, 0, 0],
+ "f": 3,
+ "h": 17,
+ "m": 3,
+ "n": 20,
+ "p": 0,
+ "s": 1,
+ },
+ "pull": 1,
+ },
+ "head_totals_c": "60.00000",
+ },
+ }
+ )
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_comment.py b/apps/worker/services/notification/notifiers/tests/unit/test_comment.py
new file mode 100644
index 0000000000..0e09252092
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_comment.py
@@ -0,0 +1,5088 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+from typing import List
+from unittest.mock import PropertyMock
+
+import pytest
+from shared.plan.constants import DEFAULT_FREE_PLAN, PlanName
+from shared.reports.readonly import ReadOnlyReport
+from shared.reports.resources import Report, ReportFile
+from shared.reports.types import Change, LineSession, ReportLine, ReportTotals
+from shared.torngit.exceptions import (
+ TorngitClientError,
+ TorngitClientGeneralError,
+ TorngitObjectNotFoundError,
+ TorngitServerUnreachableError,
+)
+from shared.utils.sessions import Session
+from shared.validation.types import CoverageCommentRequiredChanges
+
+from database.models.core import Commit, GithubAppInstallation, Pull, Repository
+from database.tests.factories import RepositoryFactory
+from database.tests.factories.core import CommitFactory, OwnerFactory, PullFactory
+from services.comparison import ComparisonContext, ComparisonProxy
+from services.comparison.types import Comparison, FullCommit, ReportUploadedCount
+from services.decoration import Decoration
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.comment import CommentNotifier
+from services.notification.notifiers.mixins.message.helpers import (
+ diff_to_string,
+ format_number_to_str,
+ sort_by_importance,
+)
+from services.notification.notifiers.mixins.message.sections import (
+ AnnouncementSectionWriter,
+ ComponentsSectionWriter,
+ FileSectionWriter,
+ HeaderSectionWriter,
+ MessagesToUserSectionWriter,
+ NewFilesSectionWriter,
+ NewFooterSectionWriter,
+ _get_tree_cell,
+)
+from services.notification.notifiers.tests.conftest import generate_sample_comparison
+from services.repository import EnrichedPull
+from services.yaml.reader import get_components_from_yaml
+from tests.helpers import mock_all_plans_and_tiers
+
+
+@pytest.fixture
+def is_not_first_pull(mocker):
+ mocker.patch(
+ "database.models.core.Pull.is_first_coverage_pull",
+ return_value=False,
+ new_callable=PropertyMock,
+ )
+
+
+@pytest.fixture
+def sample_comparison_bunch_empty_flags(request, dbsession, mocker):
+ """
+ This is what this fixture has regarding to flags
+ - first is on both reports, both with 100% coverage (the common already existing result)
+ - second is declared on both reports, but is only on the head report, with 50% coverage
+ - third is declared on both reports, but only has coverage information on the base report,
+ with 50% coverage
+ - fourth is declared on both reports, and has no coverage information anywhere
+ - fifthis only declared at the head report, but has no coverage information there
+ - sixth is only declared at the head report, and has information: 100% coverage there
+ - seventh is only declared at the base report, but has no coverage information there
+ - eighth is only declared at the base report, and has 100% coverage there
+ """
+ head_report = Report()
+ head_report.add_session(Session(flags=["first"]))
+ head_report.add_session(Session(flags=["second"]))
+ head_report.add_session(Session(flags=["third"]))
+ head_report.add_session(Session(flags=["fourth"]))
+ head_report.add_session(Session(flags=["fifth"]))
+ head_report.add_session(Session(flags=["sixth"]))
+ base_report = Report()
+ base_report.add_session(Session(flags=["first"]))
+ base_report.add_session(Session(flags=["second"]))
+ base_report.add_session(Session(flags=["third"]))
+ base_report.add_session(Session(flags=["fourth"]))
+ base_report.add_session(Session(flags=["seventh"]))
+ base_report.add_session(Session(flags=["eighth"]))
+ # assert False
+ file_1 = ReportFile("space.py")
+ file_1.append(
+ 1,
+ ReportLine.create(
+ coverage=1, sessions=[LineSession(0, 1), LineSession(1, "1/2")]
+ ),
+ )
+ file_1.append(
+ 40,
+ ReportLine.create(coverage=1, sessions=[LineSession(5, 1), LineSession(1, 1)]),
+ )
+ head_report.append(file_1)
+ file_2 = ReportFile("jupiter.py")
+ file_2.append(
+ 1,
+ ReportLine.create(
+ coverage=1, sessions=[LineSession(0, 1), LineSession(2, "1/2")]
+ ),
+ )
+ file_2.append(
+ 40,
+ ReportLine.create(coverage=1, sessions=[LineSession(5, 1), LineSession(2, 1)]),
+ )
+ base_report.append(file_2)
+ mocker.patch(
+ "shared.bots.github_apps.get_github_integration_token",
+ return_value="github-integration-token",
+ )
+ return generate_sample_comparison(
+ request.node.name,
+ dbsession,
+ ReadOnlyReport.create_from_report(base_report),
+ ReadOnlyReport.create_from_report(head_report),
+ )
+
+
+@pytest.fixture
+def mock_repo_provider(mock_repo_provider):
+ compare_result = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["46", "12", "47", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include`` to your ``.coveragerc``",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["150", "5", "158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. Please contact email our Support at [support@codecov.io](mailto:support@codecov.io)",
+ "-",
+ "+We are happy to help if you have any questions. Please contact email our Support at `support@codecov.io `_.",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ },
+ "commits": [
+ {
+ "commitid": "{comparison.project_coverage_base.commit[:7]}44fdd29fcc506317cc3ddeae1a723dd08",
+ "message": "Update README.rst",
+ "timestamp": "2018-07-09T23:51:16Z",
+ "author": {
+ "id": 8398772,
+ "username": "jerrode",
+ "name": "Jerrod",
+ "email": "jerrod@fundersclub.com",
+ },
+ },
+ {
+ "commitid": "6ae5f1795a441884ed2847bb31154814ac01ef38",
+ "message": "Update README.rst",
+ "timestamp": "2018-04-26T08:35:58Z",
+ "author": {
+ "id": 11602092,
+ "username": "TomPed",
+ "name": "Thomas Pedbereznak",
+ "email": "tom@tomped.com",
+ },
+ },
+ ],
+ }
+
+ branch_result = {"name": "test", "sha": "aaaaaaa"}
+
+ mock_repo_provider.get_compare.return_value = compare_result
+ mock_repo_provider.post_comment.return_value = {}
+ mock_repo_provider.edit_comment.return_value = {}
+ mock_repo_provider.delete_comment.return_value = {}
+ mock_repo_provider.get_branch.side_effect = TorngitClientGeneralError(
+ 404, None, "Branch not found"
+ )
+ return mock_repo_provider
+
+
+class TestCommentNotifierHelpers(object):
+ @pytest.mark.django_db
+ def test_sort_by_importance(self):
+ modified_change = Change(
+ path="modified.py",
+ in_diff=True,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-2,
+ misses=1,
+ partials=0,
+ coverage=-23.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ )
+ renamed_with_changes_change = Change(
+ path="renamed_with_changes.py",
+ in_diff=True,
+ old_path="old_renamed_with_changes.py",
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-1,
+ misses=1,
+ partials=0,
+ coverage=-20.0,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ )
+ unrelated_change = Change(
+ path="unrelated.py",
+ in_diff=False,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ )
+ added_change = Change(
+ path="added.py", new=True, in_diff=None, old_path=None, totals=None
+ )
+ deleted_change = Change(path="deleted.py", deleted=True)
+ changes = [
+ modified_change,
+ renamed_with_changes_change,
+ unrelated_change,
+ added_change,
+ deleted_change,
+ ]
+ res = sort_by_importance(changes)
+ expected_result = [
+ unrelated_change,
+ modified_change,
+ renamed_with_changes_change,
+ deleted_change,
+ added_change,
+ ]
+ assert expected_result == res
+
+ @pytest.mark.django_db
+ def test_format_number_to_str(self):
+ assert "<0.1" == format_number_to_str(
+ {"coverage": {"precision": 1}}, Decimal("0.001")
+ )
+ assert "10.0" == format_number_to_str(
+ {"coverage": {"precision": 1}}, Decimal("10.001")
+ )
+ assert "10.1" == format_number_to_str(
+ {"coverage": {"precision": 1, "round": "up"}}, Decimal("10.001")
+ )
+
+ @pytest.mark.django_db
+ def test_diff_to_string_case_1(self):
+ case_1 = (
+ "master",
+ ReportTotals(10),
+ "stable",
+ ReportTotals(11),
+ [
+ "@@ Coverage Diff @@",
+ "## master stable +/- ##",
+ "====================================",
+ "====================================",
+ " Files 10 11 +1 ",
+ "",
+ ],
+ )
+ case = case_1
+ base_title, base_totals, head_title, head_totals, expected_result = case
+ diff = diff_to_string({}, base_title, base_totals, head_title, head_totals)
+ assert diff == expected_result
+
+ @pytest.mark.django_db
+ def test_diff_to_string_case_2(self):
+ case_2 = (
+ "master",
+ ReportTotals(files=10, coverage="12.0", complexity="10.0"),
+ "stable",
+ ReportTotals(files=10, coverage="15.0", complexity="9.0"),
+ [
+ "@@ Coverage Diff @@",
+ "## master stable +/- ##",
+ "============================================",
+ "+ Coverage 12.00% 15.00% +3.00% ",
+ "- Complexity 10.00% 9.00% -1.00% ",
+ "============================================",
+ " Files 10 10 ",
+ "",
+ ],
+ )
+ case = case_2
+ base_title, base_totals, head_title, head_totals, expected_result = case
+ diff = diff_to_string({}, base_title, base_totals, head_title, head_totals)
+ assert diff == expected_result
+
+ @pytest.mark.django_db
+ def test_diff_to_string_case_3(self):
+ case_3 = (
+ "master",
+ ReportTotals(files=100),
+ "#1",
+ ReportTotals(files=200, lines=2, hits=6, misses=7, partials=8, branches=3),
+ [
+ "@@ Coverage Diff @@",
+ "## master #1 +/- ##",
+ "=====================================",
+ "=====================================",
+ " Files 100 200 +100 ",
+ " Lines 0 2 +2 ",
+ " Branches 0 3 +3 ",
+ "=====================================",
+ "+ Hits 0 6 +6 ",
+ "- Misses 0 7 +7 ",
+ "- Partials 0 8 +8 ",
+ ],
+ )
+ case = case_3
+ base_title, base_totals, head_title, head_totals, expected_result = case
+ diff = diff_to_string({}, base_title, base_totals, head_title, head_totals)
+ assert diff == expected_result
+
+ @pytest.mark.django_db
+ def test_diff_to_string_case_4(self):
+ case_4 = (
+ "master",
+ ReportTotals(files=10, coverage="12.0", complexity=10),
+ "stable",
+ ReportTotals(files=10, coverage="15.0", complexity=9),
+ [
+ "@@ Coverage Diff @@",
+ "## master stable +/- ##",
+ "============================================",
+ "+ Coverage 12.00% 15.00% +3.00% ",
+ "+ Complexity 10 9 -1 ",
+ "============================================",
+ " Files 10 10 ",
+ "",
+ ],
+ )
+ case = case_4
+ base_title, base_totals, head_title, head_totals, expected_result = case
+ diff = diff_to_string({}, base_title, base_totals, head_title, head_totals)
+ assert diff == expected_result
+
+ @pytest.mark.django_db
+ def test_diff_to_string_case_different_types(self):
+ case_1 = (
+ "master",
+ ReportTotals(10, coverage="54.43"),
+ "stable",
+ ReportTotals(11, coverage=0),
+ [
+ "@@ Coverage Diff @@",
+ "## master stable +/- ##",
+ "===========================================",
+ "- Coverage 54.43% 0 -54.43% ",
+ "===========================================",
+ " Files 10 11 +1 ",
+ "",
+ ],
+ )
+ case = case_1
+ base_title, base_totals, head_title, head_totals, expected_result = case
+ diff = diff_to_string({}, base_title, base_totals, head_title, head_totals)
+ assert diff == expected_result
+
+
+@pytest.mark.usefixtures("is_not_first_pull")
+class TestCommentNotifier(object):
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ mock_all_plans_and_tiers()
+
+ @pytest.mark.django_db
+ def test_is_enabled_settings_individual_settings_false(self, dbsession):
+ repository = RepositoryFactory.create()
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=repository,
+ title="some_title",
+ notifier_yaml_settings=False,
+ notifier_site_settings=None,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ @pytest.mark.django_db
+ def test_is_enabled_settings_individual_settings_none(self, dbsession):
+ repository = RepositoryFactory.create()
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=repository,
+ title="some_title",
+ notifier_yaml_settings=None,
+ notifier_site_settings=None,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ @pytest.mark.django_db
+ def test_is_enabled_settings_individual_settings_true(self, dbsession):
+ repository = RepositoryFactory.create()
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=repository,
+ title="some_title",
+ notifier_yaml_settings=True,
+ notifier_site_settings=None,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ @pytest.mark.django_db
+ def test_is_enabled_settings_individual_settings_dict(self, dbsession):
+ repository = RepositoryFactory.create()
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=repository,
+ title="some_title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=None,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.is_enabled()
+
+ @pytest.mark.django_db
+ def test_create_message_files_section(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ mocker,
+ ):
+ comparison = sample_comparison
+ mocker.patch.object(comparison, "get_behind_by", return_value=0)
+ mocker.patch.object(
+ comparison,
+ "get_diff",
+ return_value={
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["105", "8", "105", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["1046", "12", "1047", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc``",
+ "+We highly suggest adding ``source`` to your ``.coveragerc`",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["10150", "5", "10158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. ",
+ "-",
+ "+We are happy to help if you have any questions. .",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ },
+ "file_2.py": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["10", "8", "10", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["50", "12", "51", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include`` to your ``.coveragerc``",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ },
+ }
+ },
+ )
+ pull_dict = {"base": {"branch": "master"}}
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "files"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ pull = comparison.pull
+ repository = sample_comparison.head.commit.repository
+ expected_result = [
+ f"## [Codecov](https://app.codecov.io/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](https://app.codecov.io/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](https://app.codecov.io/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](https://app.codecov.io/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](https://app.codecov.io/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <ø> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ f"| [file\\_2.py](https://app.codecov.io/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_2.py#diff-ZmlsZV8yLnB5) | `50.00% <ø> (ø)` | `0.00 <0.00> (ø)` | |",
+ "",
+ ]
+ res = notifier.create_message(comparison, pull_dict, {"layout": "files"})
+ for expected, res in zip(expected_result, res):
+ assert expected == res
+
+ @pytest.mark.django_db
+ def test_create_message_with_github_app_comment(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ mocker,
+ ):
+ comparison = sample_comparison
+ comparison.context = ComparisonContext(gh_is_using_codecov_commenter=True)
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "files",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ res = notifier.build_message(comparison)
+ assert (
+ res[0]
+ == ":warning: Please install the  to ensure uploads and comments are reliably processed by Codecov."
+ )
+
+ @pytest.mark.django_db
+ def test_build_message(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{sample_comparison.project_coverage_base.commit.commitid[:7]}...{sample_comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_flags_empty_coverage(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_bunch_empty_flags,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_bunch_empty_flags
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "flags"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 100.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [eighth](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | |",
+ f"| [fifth](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `∅ <ø> (?)` | |",
+ f"| [first](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <ø> (ø)` | |",
+ f"| [fourth](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `∅ <ø> (∅)` | |",
+ f"| [second](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `50.00% <ø> (∅)` | |",
+ f"| [seventh](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | |",
+ f"| [sixth](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <ø> (?)` | |",
+ f"| [third](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `∅ <ø> (∅)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_more_sections(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ pull = comparison.pull
+ all_sections = [
+ "reach, diff, flags, files, footer",
+ "changes",
+ "file",
+ "header",
+ "suggestions",
+ "sunburst",
+ "uncovered",
+ "random_section",
+ ]
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": ",".join(all_sections)},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{sample_comparison.project_coverage_base.commit.commitid[:7]}...{sample_comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_upgrade_message(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mocker.patch("services.license.is_enterprise", return_value=False)
+ comparison = sample_comparison
+ pull = comparison.enriched_pull.database_pull
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.upgrade,
+ )
+ result = notifier.build_message(comparison)
+ provider_pull = comparison.enriched_pull.provider_pull
+ expected_result = [
+ f"The author of this PR, {provider_pull['author']['username']}, is not an activated member of this organization on Codecov.",
+ f"Please [activate this user on Codecov](test.example.br/members/gh/{pull.repository.owner.username}) to display this PR comment.",
+ "Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.",
+ "Please don't hesitate to email us at support@codecov.io with any questions.",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_limited_upload_message(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ comparison = sample_comparison
+ pull = comparison.enriched_pull.database_pull
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.upload_limit,
+ )
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/plan/gh/{pull.repository.owner.username}) upload limit reached :warning:",
+ f"This org is currently on the free Basic Plan; which includes 250 free private repo uploads each rolling month.\
+ This limit has been reached and additional reports cannot be generated. For unlimited uploads,\
+ upgrade to our [pro plan](test.example.br/plan/gh/{pull.repository.owner.username}).",
+ "",
+ "**Do you have questions or need help?** Connect with our sales team today at ` sales@codecov.io `",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_passing_empty_upload(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ comparison = sample_comparison
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.passing_empty_upload,
+ )
+ result = notifier.build_message(comparison)
+ expected_result = [
+ "## Codecov Report",
+ ":heavy_check_mark: **No coverage data to report**, because files changed do not require tests or are set to [ignore](https://docs.codecov.com/docs/ignoring-paths#:~:text=You%20can%20use%20the%20top,will%20be%20skipped%20during%20processing.) ",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_failing_empty_upload(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ comparison = sample_comparison
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.failing_empty_upload,
+ )
+ result = notifier.build_message(comparison)
+ expected_result = [
+ "## Codecov Report",
+ "This is an empty upload",
+ "Files changed in this PR are testable or aren't ignored by Codecov, please run your tests and upload coverage. If you wish to ignore these files, please visit our [ignoring paths docs](https://docs.codecov.com/docs/ignoring-paths).",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_processing_upload(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"] = {
+ "codecov_url": "test.example.br",
+ "codecov_dashboard_url": "test.example.br",
+ }
+ comparison = sample_comparison
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.processing_upload,
+ )
+ result = notifier.build_message(comparison)
+ expected_result = [
+ "We're currently processing your upload. This comment will be updated when the results are available.",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_upgrade_message_enterprise(
+ self,
+ request,
+ dbsession,
+ mocker,
+ mock_configuration,
+ with_sql_functions,
+ sample_comparison,
+ ):
+ mocker.patch("services.license.is_enterprise", return_value=True)
+
+ encrypted_license = "wxWEJyYgIcFpi6nBSyKQZQeaQ9Eqpo3SXyUomAqQOzOFjdYB3A8fFM1rm+kOt2ehy9w95AzrQqrqfxi9HJIb2zLOMOB9tSy52OykVCzFtKPBNsXU/y5pQKOfV7iI3w9CHFh3tDwSwgjg8UsMXwQPOhrpvl2GdHpwEhFdaM2O3vY7iElFgZfk5D9E7qEnp+WysQwHKxDeKLI7jWCnBCBJLDjBJRSz0H7AfU55RQDqtTrnR+rsLDHOzJ80/VxwVYhb"
+ mock_configuration.params["setup"]["enterprise_license"] = encrypted_license
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = (
+ "https://codecov.mysite.com"
+ )
+
+ comparison = sample_comparison
+ pull = comparison.enriched_pull.database_pull
+ repository = sample_comparison.head.commit.repository
+ notifier = CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.upgrade,
+ )
+ result = notifier.build_message(comparison)
+ provider_pull = comparison.enriched_pull.provider_pull
+ expected_result = [
+ f"The author of this PR, {provider_pull['author']['username']}, is not activated in your Codecov Self-Hosted installation.",
+ f"Please [activate this user](https://codecov.mysite.com/account/gh/{pull.author.username}) to display this PR comment.",
+ "Coverage data is still being uploaded to Codecov Self-Hosted for the purposes of overall coverage calculations.",
+ "Please contact your Codecov On-Premises installation administrator with any questions.",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_hide_complexity(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={"codecov": {"ui": {"hide_complexity": True}}},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{sample_comparison.project_coverage_base.commit.commitid[:7]}...{sample_comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_base_report(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_without_base_report,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`master@{comparison.project_coverage_base.commit.commitid[:7]}`). [Learn more](https://docs.codecov.io/docs/error-reference#section-missing-base-commit) about missing BASE report.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=========================================",
+ " Coverage ? 60.00% ",
+ " Complexity ? 10 ",
+ "=========================================",
+ " Files ? 2 ",
+ " Lines ? 10 ",
+ " Branches ? 1 ",
+ "=========================================",
+ " Hits ? 6 ",
+ " Misses ? 3 ",
+ " Partials ? 1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (ø)` | `10.00 <0.00> (?)` | |",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_base_commit(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_without_base_with_pull,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_with_pull
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ "> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`master@cdf9aa4`). [Learn more](https://docs.codecov.io/docs/error-reference#section-missing-base-commit) about missing BASE report.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=========================================",
+ " Coverage ? 60.00% ",
+ " Complexity ? 10 ",
+ "=========================================",
+ " Files ? 2 ",
+ " Lines ? 10 ",
+ " Branches ? 1 ",
+ "=========================================",
+ " Hits ? 6 ",
+ " Misses ? 3 ",
+ " Partials ? 1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (ø)` | `10.00 <0.00> (?)` | |",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[cdf9aa4...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_change(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_no_change,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_no_change
+ pull = comparison.pull
+
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=========================================",
+ " Coverage 60.00% 60.00% ",
+ " Complexity 10 10 ",
+ "=========================================",
+ " Files 2 2 ",
+ " Lines 10 10 ",
+ " Branches 1 1 ",
+ "=========================================",
+ " Hits 6 6 ",
+ " Misses 3 3 ",
+ " Partials 1 1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (ø)` | `0.00 <0.00> (ø)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (ø)` | `10.00 <0.00> (ø)` | |",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{sample_comparison_no_change.project_coverage_base.commit.commitid[:7]}...{sample_comparison_no_change.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_negative_change(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_negative_change,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_negative_change
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 50.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "- Coverage 60.00% 50.00% -10.00% ",
+ "- Complexity 10 11 +1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 10 6 -4 ",
+ " Branches 1 0 -1 ",
+ "=============================================",
+ "- Hits 6 3 -3 ",
+ " Misses 3 3 ",
+ "+ Partials 1 0 -1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <ø> (?)` | `0.00 <ø> (?)` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `50.00% <ø> (-12.50%)` | `11.00 <0.00> (+1.00)` | :arrow_down: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_negative_change_tricky_rounding(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_negative_change,
+ ):
+ # This example was taken from a real PR in which we had issues with rounding
+ # That's why the numbers will be.... dramatic
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_negative_change
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "diff"},
+ notifier_site_settings=True,
+ current_yaml={"coverage": {"precision": 2, "round": "down"}},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ # Change the reports
+ new_base_report = Report()
+ new_base_file = ReportFile("file.py")
+ # Produce numbers that we know can be tricky for rounding
+ for i in range(1, 6760):
+ new_base_file.append(i, ReportLine.create(coverage=1))
+ for i in range(6760, 7631):
+ new_base_file.append(i, ReportLine.create(coverage=0))
+ new_base_report.append(new_base_file)
+ comparison.project_coverage_base.report = ReadOnlyReport.create_from_report(
+ new_base_report
+ )
+ new_head_report = Report()
+ new_head_file = ReportFile("file.py")
+ for i in range(1, 6758):
+ new_head_file.append(i, ReportLine.create(coverage=1))
+ for i in range(6758, 7632):
+ new_head_file.append(i, ReportLine.create(coverage=0))
+ new_head_report.append(new_head_file)
+ comparison.head.report = ReadOnlyReport.create_from_report(new_head_report)
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 88.54%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "==========================================",
+ "- Coverage 88.58% 88.54% -0.04% ",
+ "==========================================",
+ " Files 1 1 ",
+ " Lines 7630 7631 +1 ",
+ "==========================================",
+ "- Hits 6759 6757 -2 ",
+ "- Misses 871 874 +3 ",
+ "```",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_negative_change_tricky_rounding_newheader(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_negative_change,
+ ):
+ # This example was taken from a real PR in which we had issues with rounding
+ # That's why the numbers will be.... dramatic
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_negative_change
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "newheader"},
+ notifier_site_settings=True,
+ current_yaml={"coverage": {"precision": 2, "round": "down"}},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ # Change the reports
+ new_base_report = Report()
+ new_base_file = ReportFile("file.py")
+ # Produce numbers that we know can be tricky for rounding
+ for i in range(1, 6760):
+ new_base_file.append(i, ReportLine.create(coverage=1))
+ for i in range(6760, 7631):
+ new_base_file.append(i, ReportLine.create(coverage=0))
+ new_base_report.append(new_base_file)
+ comparison.project_coverage_base.report = ReadOnlyReport.create_from_report(
+ new_base_report
+ )
+ new_head_report = Report()
+ new_head_file = ReportFile("file.py")
+ for i in range(1, 6758):
+ new_head_file.append(i, ReportLine.create(coverage=1))
+ for i in range(6758, 7632):
+ new_head_file.append(i, ReportLine.create(coverage=0))
+ new_head_report.append(new_head_file)
+ comparison.head.report = ReadOnlyReport.create_from_report(new_head_report)
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 88.54%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_show_carriedforward_flags_no_cf_coverage(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "show_carryforward_flags": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{sample_comparison.project_coverage_base.commit.commitid[:7]}...{sample_comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_with_without_flags(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ # Without flags table
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_coverage_carriedforward
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, files, footer",
+ "show_carryforward_flags": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 65.38%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=======================================",
+ " Coverage 65.38% 65.38% ",
+ "=======================================",
+ " Files 15 15 ",
+ " Lines 208 208 ",
+ "=======================================",
+ " Hits 136 136 ",
+ " Misses 4 4 ",
+ " Partials 68 68 ",
+ "```",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ # With flags table
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_coverage_carriedforward
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "show_carryforward_flags": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 65.38%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=======================================",
+ " Coverage 65.38% 65.38% ",
+ "=======================================",
+ " Files 15 15 ",
+ " Lines 208 208 ",
+ "=======================================",
+ " Hits 136 136 ",
+ " Misses 4 4 ",
+ " Partials 68 68 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | | *Carryforward flag |",
+ "|---|---|---|---|",
+ f"| [enterprise](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | Carriedforward from [1234567](test.example.br/gh/{repository.slug}/commit/123456789sha) |",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | Carriedforward |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | |",
+ "",
+ "*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags) to find out more."
+ "",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_show_carriedforward_flags_has_cf_coverage(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_coverage_carriedforward
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "show_carryforward_flags": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 65.38%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=======================================",
+ " Coverage 65.38% 65.38% ",
+ "=======================================",
+ " Files 15 15 ",
+ " Lines 208 208 ",
+ "=======================================",
+ " Hits 136 136 ",
+ " Misses 4 4 ",
+ " Partials 68 68 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | | *Carryforward flag |",
+ "|---|---|---|---|",
+ f"| [enterprise](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | Carriedforward from [1234567](test.example.br/gh/{repository.slug}/commit/123456789sha) |",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | Carriedforward |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | | |",
+ "",
+ "*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags) to find out more."
+ "",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_hide_carriedforward_flags_has_cf_coverage(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_coverage_carriedforward
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "show_carryforward_flags": False,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 65.38%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=======================================",
+ " Coverage 65.38% 65.38% ",
+ "=======================================",
+ " Files 15 15 ",
+ " Lines 208 208 ",
+ "=======================================",
+ " Hits 136 136 ",
+ " Misses 4 4 ",
+ " Partials 68 68 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more."
+ "",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_default_layout(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_report_without_flags,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"codecov": {"ui": {"hide_complexity": True}}},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_spammy(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "spammy",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.edit_comment.called
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_build_message_no_flags(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_report_without_flags,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ pull = sample_comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ sample_comparison.head.report = ReadOnlyReport.create_from_report(
+ sample_report_without_flags
+ )
+ comparison = sample_comparison
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(sample_comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_new_no_permissions(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "new",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.delete_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "no_permissions"
+ assert result.data_sent == data
+ assert result.data_received is None
+ mock_repo_provider.delete_comment.assert_called_with(98, "12345")
+ assert not mock_repo_provider.post_comment.called
+ assert not mock_repo_provider.edit_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_new(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "new",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.delete_comment.return_value = True
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.edit_comment.called
+ mock_repo_provider.delete_comment.assert_called_with(98, "12345")
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_new_no_permissions_post(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "new",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": None, "pullid": 98}
+ mock_repo_provider.post_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ mock_repo_provider.edit_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "comment_posting_permissions"
+ assert result.data_sent == data
+ assert result.data_received is None
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.delete_comment.called
+ assert not mock_repo_provider.edit_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_new_deleted_comment(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "new",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.delete_comment.side_effect = TorngitObjectNotFoundError(
+ "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.edit_comment.called
+ mock_repo_provider.delete_comment.assert_called_with(98, "12345")
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_once_deleted_comment(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "once",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.side_effect = TorngitObjectNotFoundError(
+ "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted is False
+ assert result.notification_successful is None
+ assert result.explanation == "comment_deleted"
+ assert result.data_sent == data
+ assert result.data_received is None
+ assert not mock_repo_provider.post_comment.called
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_once_non_existing_comment(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "once",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": None, "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.side_effect = TorngitObjectNotFoundError(
+ "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.delete_comment.called
+ assert not mock_repo_provider.edit_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_once(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "once",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.return_value = {"id": "49"}
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": "49"}
+ assert not mock_repo_provider.post_comment.called
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_once_no_permissions(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "once",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "no_permissions"
+ assert result.data_sent == data
+ assert result.data_received is None
+ assert not mock_repo_provider.post_comment.called
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_default(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.return_value = {"id": "49"}
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": "49"}
+ assert not mock_repo_provider.post_comment.called
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_default_no_permissions_edit(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_default_no_permissions_twice(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ mock_repo_provider.edit_comment.side_effect = TorngitClientError(
+ "code", "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "comment_posting_permissions"
+ assert result.data_sent == data
+ assert result.data_received is None
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_send_actual_notification_default_comment_not_found(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ data = {"message": ["message"], "commentid": "12345", "pullid": 98}
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_repo_provider.edit_comment.side_effect = TorngitObjectNotFoundError(
+ "response", "message"
+ )
+ result = notifier.send_actual_notification(data)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == data
+ assert result.data_received == {"id": 9865}
+ mock_repo_provider.edit_comment.assert_called_with(98, "12345", "message")
+ mock_repo_provider.post_comment.assert_called_with(98, "message")
+ assert not mock_repo_provider.delete_comment.called
+
+ @pytest.mark.django_db
+ def test_notify_no_pull_request(self, dbsession, sample_comparison_without_pull):
+ notifier = CommentNotifier(
+ repository=sample_comparison_without_pull.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison_without_pull)
+ assert not result.notification_attempted
+ assert result.notification_successful == False
+ assert result.explanation == "no_pull_request"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_pull_head_doesnt_match(self, dbsession, sample_comparison):
+ sample_comparison.pull.head = "aaaaaaaaaa"
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is False
+ assert result.explanation == "pull_head_does_not_match"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_pull_request_not_in_provider(
+ self, dbsession, sample_comparison_database_pull_without_provider
+ ):
+ notifier = CommentNotifier(
+ repository=sample_comparison_database_pull_without_provider.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison_database_pull_without_provider)
+ assert not result.notification_attempted
+ assert result.notification_successful is False
+ assert result.explanation == "pull_request_not_in_provider"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_server_unreachable(self, mocker, dbsession, sample_comparison):
+ mocked_send_actual_notification = mocker.patch.object(
+ CommentNotifier,
+ "send_actual_notification",
+ side_effect=TorngitServerUnreachableError(),
+ )
+ mocked_build_message = mocker.patch.object(
+ CommentNotifier, "build_message", return_value=["title", "content"]
+ )
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "provider_issue"
+ assert result.data_sent == {
+ "commentid": None,
+ "message": ["title", "content"],
+ "pullid": sample_comparison.pull.pullid,
+ }
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_store_results(self, dbsession, sample_comparison):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent=None,
+ data_received={"id": 578263422},
+ )
+ notifier.store_results(sample_comparison, result)
+ assert sample_comparison.pull.commentid == 578263422
+ dbsession.flush()
+ assert sample_comparison.pull.commentid == 578263422
+ dbsession.refresh(sample_comparison.pull)
+ assert sample_comparison.pull.commentid == "578263422"
+
+ @pytest.mark.django_db
+ def test_store_results_deleted_comment(self, dbsession, sample_comparison):
+ sample_comparison.pull.commentid = 12
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent=None,
+ data_received={"deleted_comment": True},
+ )
+ notifier.store_results(sample_comparison, result)
+ assert sample_comparison.pull.commentid is None
+ dbsession.flush()
+ assert sample_comparison.pull.commentid is None
+ dbsession.refresh(sample_comparison.pull)
+ assert sample_comparison.pull.commentid is None
+
+ @pytest.mark.django_db
+ def test_store_results_no_succesfull_result(self, dbsession, sample_comparison):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation=None,
+ data_sent=None,
+ data_received={"id": "yadayada"},
+ )
+ notifier.store_results(sample_comparison, result)
+ assert sample_comparison.pull.commentid is None
+ dbsession.flush()
+ assert sample_comparison.pull.commentid is None
+ dbsession.refresh(sample_comparison.pull)
+ assert sample_comparison.pull.commentid is None
+
+ @pytest.mark.django_db
+ def test_notify_unable_to_fetch_info(self, dbsession, mocker, sample_comparison):
+ mocked_build_message = mocker.patch.object(
+ CommentNotifier,
+ "build_message",
+ side_effect=TorngitClientError("code", "response", "message"),
+ )
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is False
+ assert result.explanation == "unable_build_message"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_not_enough_builds(self, dbsession, sample_comparison):
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ "after_n_builds": 5,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is False
+ assert result.explanation == "not_enough_builds"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("pull_state", ["open", "closed"])
+ async def test_notify_with_enough_builds(
+ self, dbsession, sample_comparison, mocker, pull_state
+ ):
+ build_message_mocker = mocker.patch.object(
+ CommentNotifier,
+ "build_message",
+ return_value="message_test_notify_with_enough_builds",
+ )
+ send_comment_default_behavior_mocker = mocker.patch.object(
+ CommentNotifier,
+ "send_comment_default_behavior",
+ return_value=dict(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_received=None,
+ ),
+ )
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ "after_n_builds": 1,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mocker.MagicMock(),
+ )
+ sample_comparison.pull.state = pull_state
+ dbsession.flush()
+ dbsession.refresh(sample_comparison.pull)
+ result = notifier.notify(sample_comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == {
+ "commentid": None,
+ "message": "message_test_notify_with_enough_builds",
+ "pullid": sample_comparison.pull.pullid,
+ }
+ assert result.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_exact_same_report_diff_unrelated_report(
+ self, sample_comparison_no_change, mock_repo_provider
+ ):
+ compare_result = {
+ "diff": {
+ "files": {
+ "README.md": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["46", "12", "47", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " -",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ }
+ }
+ mock_repo_provider.get_compare.return_value = compare_result
+ notifier = CommentNotifier(
+ repository=sample_comparison_no_change.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ "after_n_builds": 1,
+ "require_changes": [CoverageCommentRequiredChanges.any_change.value],
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ res = notifier.notify(sample_comparison_no_change)
+ assert res.notification_attempted is False
+ assert res.notification_successful is False
+ assert res.explanation == "changes_required"
+ assert res.data_sent is None
+ assert res.data_received is None
+
+ @pytest.mark.django_db
+ def test_notify_exact_same_report_diff_unrelated_report_update_comment(
+ self, sample_comparison_no_change, mock_repo_provider
+ ):
+ compare_result = {
+ "diff": {
+ "files": {
+ "README.md": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["46", "12", "47", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " -",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ }
+ }
+ mock_repo_provider.get_compare.return_value = compare_result
+ sample_comparison_no_change.pull.commentid = 12
+ mock_repo_provider.edit_comment.return_value = {"id": 12}
+ notifier = CommentNotifier(
+ repository=sample_comparison_no_change.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "reach, diff, flags, files, footer",
+ "behavior": "default",
+ "after_n_builds": 1,
+ "require_changes": [CoverageCommentRequiredChanges.any_change.value],
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ res = notifier.notify(sample_comparison_no_change)
+ assert res.notification_attempted is True
+ assert res.notification_successful is True
+ mock_repo_provider.edit_comment.assert_called()
+
+ @pytest.mark.django_db
+ def test_message_hide_details_github(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach", "hide_comment_details": True},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ " ",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_message_announcements_only(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "announcements"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ ":mega: message",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ if exp == ":mega: message":
+ # We might not know the selected message if there are multiple active ones
+ assert res.startswith(":mega: ")
+ message = res[7:]
+ assert message in AnnouncementSectionWriter.current_active_messages
+ # We have to change the expected line to pass the global check
+ expected_result[4] = f":mega: {message}"
+ else:
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_message_hide_details_bitbucket(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "bitbucket"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach", "hide_comment_details": True},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{sample_comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ ]
+ li = 0
+ for exp, res in zip(expected_result, result):
+ li += 1
+ assert exp == res
+ assert result == expected_result
+
+
+class TestFileSectionWriter(object):
+ def test_filesection_no_extra_settings(self, sample_comparison, mocker):
+ section_writer = FileSectionWriter(
+ sample_comparison.head.commit.repository,
+ "layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={},
+ )
+ changes = [
+ Change(
+ path="file_2.py",
+ in_diff=True,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-2,
+ misses=1,
+ partials=0,
+ coverage=-23.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(
+ path="unrelated.py",
+ in_diff=False,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(path="added.py", new=True, in_diff=None, old_path=None, totals=None),
+ ]
+ lines = list(
+ section_writer.write_section(
+ sample_comparison,
+ {
+ "files": {
+ "file_1.go": {
+ "type": "added",
+ "totals": ReportTotals(
+ lines=3,
+ hits=2,
+ misses=1,
+ coverage=66.66,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ }
+ }
+ },
+ changes,
+ links={"pull": "pull.link"},
+ )
+ )
+ assert lines == [
+ "| [Files with missing lines](pull.link?dropdown=coverage&src=pr&el=tree) | Coverage Δ | |",
+ "|---|---|---|",
+ "| [file\\_1.go](pull.link?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.66%> (+12.50%)` | :arrow_up: |",
+ "",
+ "... and [3 files with indirect coverage changes](pull.link/indirect-changes?src=pr&el=tree-more)",
+ ]
+
+ @pytest.mark.parametrize(
+ "test_analytics_enabled,bundle_analysis_enabled",
+ [(False, False), (False, True), (True, False), (True, True)],
+ )
+ @pytest.mark.django_db
+ def test_build_cross_pollination_message(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ test_analytics_enabled,
+ bundle_analysis_enabled,
+ ):
+ mock_all_plans_and_tiers()
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.pull.is_first_coverage_pull = False
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = sample_comparison.head.commit.repository
+ if bundle_analysis_enabled:
+ repository.languages = ["javascript"]
+ if test_analytics_enabled:
+ repository.test_analytics_enabled = False
+ dbsession.flush()
+ result = notifier.build_message(comparison)
+
+ header = " :rocket: New features to boost your workflow:
"
+ ta_message = "- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems."
+ ba_message = "- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges."
+
+ end_of_message = []
+
+ if test_analytics_enabled or bundle_analysis_enabled:
+ end_of_message += [header, ""]
+ assert header in result
+
+ if test_analytics_enabled:
+ end_of_message.append(ta_message)
+ assert ta_message in result
+
+ if bundle_analysis_enabled:
+ end_of_message.append(ba_message)
+ assert ba_message in result
+
+ if len(end_of_message):
+ assert result[-1] == " "
+ else:
+ assert result[-1] == ""
+
+ def test_get_tree_cell(self):
+ typ = "added"
+ path = "path/to/test_file.go"
+ metrics = "| this is where the metrics go |"
+ line = _get_tree_cell(
+ typ=typ, path=path, metrics=metrics, compare="pull.link", is_critical=False
+ )
+ assert (
+ line
+ == f"| [path/to/test\\_file.go](pull.link?src=pr&el=tree&filepath=path%2Fto%2Ftest_file.go#diff-cGF0aC90by90ZXN0X2ZpbGUuZ28=) {metrics}"
+ )
+
+ def test_get_tree_cell_with_critical(self):
+ typ = "added"
+ path = "path/to/test_file.go"
+ metrics = "| this is where the metrics go |"
+ line = _get_tree_cell(
+ typ=typ, path=path, metrics=metrics, compare="pull.link", is_critical=True
+ )
+ assert (
+ line
+ == f"| [path/to/test\\_file.go](pull.link?src=pr&el=tree&filepath=path%2Fto%2Ftest_file.go#diff-cGF0aC90by90ZXN0X2ZpbGUuZ28=) **Critical** {metrics}"
+ )
+
+ def test_filesection_hide_project_cov(self, sample_comparison, mocker):
+ section_writer = NewFilesSectionWriter(
+ sample_comparison.head.commit.repository,
+ "layout",
+ show_complexity=False,
+ settings={"hide_project_coverage": True},
+ current_yaml={},
+ )
+ changes = [
+ Change(
+ path="unrelated.py",
+ in_diff=False,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(path="added.py", new=True, in_diff=None, old_path=None, totals=None),
+ ]
+ lines = list(
+ section_writer.write_section(
+ sample_comparison,
+ {
+ "files": {
+ "file_1.go": {
+ "type": "added",
+ "totals": ReportTotals(
+ lines=3,
+ hits=2,
+ misses=1,
+ coverage=66.66,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ },
+ "file_2.py": {
+ "type": "added",
+ "totals": ReportTotals(
+ lines=3,
+ hits=-2,
+ misses=2,
+ partials=3,
+ coverage=-23.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ },
+ }
+ },
+ changes,
+ links={"pull": "pull.link"},
+ )
+ )
+ assert lines == [
+ "| [Files with missing lines](pull.link?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ "| [file\\_2.py](pull.link?src=pr&el=tree&filepath=file_2.py#diff-ZmlsZV8yLnB5) | -23.33% | [2 Missing and 3 partials :warning: ](pull.link?src=pr&el=tree) |",
+ "| [file\\_1.go](pull.link?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.66% | [1 Missing :warning: ](pull.link?src=pr&el=tree) |",
+ ]
+
+ def test_filesection_hide_project_cov_with_changed_files_but_no_missing_lines(
+ self, sample_comparison, mocker
+ ):
+ section_writer = NewFilesSectionWriter(
+ sample_comparison.head.commit.repository,
+ "layout",
+ show_complexity=False,
+ settings={"hide_project_coverage": True},
+ current_yaml={},
+ )
+ changes = [
+ Change(
+ path="unrelated.py",
+ in_diff=False,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(path="added.py", new=True, in_diff=None, old_path=None, totals=None),
+ ]
+ lines = list(
+ section_writer.write_section(
+ sample_comparison,
+ {
+ "files": {
+ "file_1.go": {
+ "type": "added",
+ "totals": ReportTotals(
+ lines=3,
+ hits=3,
+ misses=0,
+ coverage=100.00,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ },
+ "file_2.py": {
+ "type": "added",
+ "totals": ReportTotals(
+ lines=3,
+ hits=3,
+ misses=0,
+ partials=0,
+ coverage=-100.00,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ },
+ }
+ },
+ changes,
+ links={"pull": "pull.link"},
+ )
+ )
+ assert lines == []
+
+ def test_filesection_hide_project_cov_no_files_changed(
+ self, sample_comparison, mocker
+ ):
+ section_writer = NewFilesSectionWriter(
+ sample_comparison.head.commit.repository,
+ "layout",
+ show_complexity=False,
+ settings={"hide_project_coverage": True},
+ current_yaml={},
+ )
+ changes = [
+ Change(
+ path="unrelated.py",
+ in_diff=False,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-43.333330000000004,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ Change(path="added.py", new=True, in_diff=None, old_path=None, totals=None),
+ ]
+ lines = list(
+ section_writer.write_section(
+ sample_comparison,
+ {"files": {}},
+ changes,
+ links={"pull": "pull.link"},
+ )
+ )
+ assert lines == []
+
+
+class TestNewHeaderSectionWriter(object):
+ def test_new_header_section_writer(self, mocker, sample_comparison):
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 0%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](urlurl?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](headurl?dropdown=coverage&el=desc).",
+ ]
+
+ def test_new_header_section_writer_with_behind_by(self, mocker, sample_comparison):
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ behind_by=3,
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 0%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](urlurl?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](headurl?dropdown=coverage&el=desc).",
+ "> Report is 3 commits behind head on master.",
+ ]
+
+ def test_new_header_section_writer_test_results_setup(
+ self, mocker, sample_comparison
+ ):
+ sample_comparison.context = ComparisonContext(all_tests_passed=True)
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 0%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](urlurl?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](headurl?dropdown=coverage&el=desc).",
+ "",
+ ":white_check_mark: All tests successful. No failed tests found.",
+ ]
+
+ def test_new_header_section_writer_test_results_error(
+ self, mocker, sample_comparison
+ ):
+ sample_comparison.context = ComparisonContext(
+ all_tests_passed=False,
+ test_results_error=":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ )
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 0%. Comparing base [(`{sample_comparison.project_coverage_base.commit.commitid[:7]}`)](urlurl?dropdown=coverage&el=desc) to head [(`{sample_comparison.head.commit.commitid[:7]}`)](headurl?dropdown=coverage&el=desc).",
+ "",
+ ":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ ]
+
+ def test_new_header_section_writer_no_project_coverage(
+ self, mocker, sample_comparison
+ ):
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={"hide_project_coverage": True},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ ]
+
+ def test_new_header_section_writer_no_project_coverage_test_results_setup(
+ self, mocker, sample_comparison
+ ):
+ sample_comparison.context = ComparisonContext(all_tests_passed=True)
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={"hide_project_coverage": True},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "",
+ ":white_check_mark: All tests successful. No failed tests found.",
+ ]
+
+ def test_new_header_section_writer_no_project_coverage_test_results_error(
+ self, mocker, sample_comparison
+ ):
+ sample_comparison.context = ComparisonContext(
+ all_tests_passed=False,
+ test_results_error=":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ )
+ writer = HeaderSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ show_complexity=mocker.MagicMock(),
+ settings={"hide_project_coverage": True},
+ current_yaml=mocker.MagicMock(),
+ )
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.round_number",
+ return_value=Decimal(0),
+ )
+ res = list(
+ writer.write_section(
+ sample_comparison,
+ None,
+ None,
+ links={"pull": "urlurl", "base": "urlurl", "head": "headurl"},
+ )
+ )
+ assert res == [
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "",
+ ":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ ]
+
+
+class TestAnnouncementsSectionWriter(object):
+ def test_announcement_section_writer(self, mocker):
+ writer = AnnouncementSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ )
+ res = list(writer.write_section(mocker.MagicMock()))
+ assert len(res) == 1
+ line = res[0]
+ assert line.startswith(":mega: ")
+ message = line[7:]
+ assert message in AnnouncementSectionWriter.current_active_messages
+
+
+class TestNewFooterSectionWriter(object):
+ def test_footer_section_writer_in_github(self, mocker):
+ writer = NewFooterSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mock_comparison = mocker.MagicMock()
+ mock_comparison.repository_service.service = "github"
+ res = list(
+ writer.write_section(mock_comparison, {}, [], links={"pull": "pull.link"})
+ )
+ assert res == [
+ "",
+ "[:umbrella: View full report in Codecov by Sentry](pull.link?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ ]
+
+ def test_footer_section_writer_in_gitlab(self, mocker):
+ writer = NewFooterSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mock_comparison = mocker.MagicMock()
+ mock_comparison.repository_service.service = "gitlab"
+ res = list(
+ writer.write_section(mock_comparison, {}, [], links={"pull": "pull.link"})
+ )
+ assert res == [
+ "",
+ "[:umbrella: View full report in Codecov by Sentry](pull.link?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://gitlab.com/codecov-open-source/codecov-user-feedback/-/issues/4).",
+ ]
+
+ def test_footer_section_writer_in_bitbucket(self, mocker):
+ writer = NewFooterSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ settings={},
+ current_yaml=mocker.MagicMock(),
+ )
+ mock_comparison = mocker.MagicMock()
+ mock_comparison.repository_service.service = "bitbucket"
+ res = list(
+ writer.write_section(mock_comparison, {}, [], links={"pull": "pull.link"})
+ )
+ assert res == [
+ "",
+ "[:umbrella: View full report in Codecov by Sentry](pull.link?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://gitlab.com/codecov-open-source/codecov-user-feedback/-/issues/4).",
+ ]
+
+ def test_footer_section_writer_with_project_cov_hidden(self, mocker):
+ writer = NewFooterSectionWriter(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ settings={
+ "layout": "newheader, files, newfooter",
+ "hide_project_coverage": True,
+ },
+ current_yaml={},
+ )
+ mock_comparison = mocker.MagicMock()
+ mock_comparison.repository_service.service = "bitbucket"
+ res = list(
+ writer.write_section(mock_comparison, {}, [], links={"pull": "pull.link"})
+ )
+ assert res == [
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/)",
+ ]
+
+
+@pytest.mark.usefixtures("is_not_first_pull")
+class TestCommentNotifierInNewLayout(object):
+ @pytest.fixture(autouse=True)
+ def mock_all_plans_and_tiers(self):
+ mock_all_plans_and_tiers()
+
+ @pytest.mark.django_db
+ def test_build_message_no_base_commit_new_layout(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_without_base_with_pull,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_with_pull
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, files, newfooter"
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ "> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`master@cdf9aa4`). [Learn more](https://docs.codecov.io/docs/error-reference#section-missing-base-commit) about missing BASE report.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=========================================",
+ " Coverage ? 60.00% ",
+ " Complexity ? 10 ",
+ "=========================================",
+ " Files ? 2 ",
+ " Lines ? 10 ",
+ " Branches ? 1 ",
+ "=========================================",
+ " Hits ? 6 ",
+ " Misses ? 3 ",
+ " Partials ? 1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (ø)` | `10.00 <0.00> (?)` | |",
+ "",
+ "",
+ f"[:umbrella: View full report in Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_base_report_new_layout(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_without_base_report,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, files, newfooter",
+ "hide_comment_details": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`master@{comparison.project_coverage_base.commit.commitid[:7]}`). [Learn more](https://docs.codecov.io/docs/error-reference#section-missing-base-commit) about missing BASE report.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=========================================",
+ " Coverage ? 60.00% ",
+ " Complexity ? 10 ",
+ "=========================================",
+ " Files ? 2 ",
+ " Lines ? 10 ",
+ " Branches ? 1 ",
+ "=========================================",
+ " Hits ? 6 ",
+ " Misses ? 3 ",
+ " Partials ? 1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (ø)` | `10.00 <0.00> (?)` | |",
+ "",
+ " ",
+ "",
+ f"[:umbrella: View full report in Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_project_coverage(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, newfiles, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ pull_url = f"test.example.br/gh/{repository.slug}/pull/{pull.pullid}"
+ expected_result = [
+ f"## [Codecov]({pull_url}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ "",
+ f"| [Files with missing lines]({pull_url}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go]({pull_url}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ]({pull_url}?src=pr&el=tree) |",
+ "",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/)",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_project_coverage_files(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, files, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ pull_url = f"test.example.br/gh/{repository.slug}/pull/{pull.pullid}"
+ expected_result = [
+ f"## [Codecov]({pull_url}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ "",
+ f"| [Files with missing lines]({pull_url}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go]({pull_url}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ]({pull_url}?src=pr&el=tree) |",
+ "",
+ f"| [Files with missing lines]({pull_url}?dropdown=coverage&src=pr&el=tree) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [file\\_1.go]({pull_url}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | `62.50% <66.67%> (+12.50%)` | `10.00 <0.00> (-1.00)` | :arrow_up: |",
+ "",
+ f"... and [1 file with indirect coverage changes]({pull_url}/indirect-changes?src=pr&el=tree-more)",
+ "",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/)",
+ "",
+ ]
+ assert result == expected_result
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+
+ @pytest.mark.django_db
+ def test_build_message_no_project_coverage_condensed_yaml_configs(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "condensed_header, condensed_files, condensed_footer",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ pull_url = f"test.example.br/gh/{repository.slug}/pull/{pull.pullid}"
+ expected_result = [
+ f"## [Codecov]({pull_url}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ "",
+ f"| [Files with missing lines]({pull_url}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go]({pull_url}?src=pr&el=tree&filepath=file_1.go#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ]({pull_url}?src=pr&el=tree) |",
+ "",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/)",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_head_and_pull_head_differ_new_layout(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_head_and_pull_head_differ,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_head_and_pull_head_differ
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, newfooter",
+ "hide_comment_details": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"> :exclamation: **Current head {comparison.head.commit.commitid[:7]} differs from pull request most recent head {comparison.enriched_pull.provider_pull['head']['commitid'][:7]}**",
+ "> ",
+ f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) reports for the commit {comparison.enriched_pull.provider_pull['head']['commitid'][:7]} to get more accurate results.",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ " ",
+ "",
+ f"[:umbrella: View full report in Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_head_and_pull_head_differ_with_components(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_head_and_pull_head_differ,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_head_and_pull_head_differ
+ comparison.repository_service.service = "github"
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, components, newfooter",
+ "hide_comment_details": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "unit_flags", "flag_regexes": [r"unit.*"]},
+ ]
+ }
+ },
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"> Project coverage is 60.00%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"> :exclamation: **Current head {comparison.head.commit.commitid[:7]} differs from pull request most recent head {comparison.enriched_pull.provider_pull['head']['commitid'][:7]}**",
+ "> ",
+ f"> Please [upload](https://docs.codecov.com/docs/codecov-uploader) reports for the commit {comparison.enriched_pull.provider_pull['head']['commitid'][:7]} to get more accurate results.",
+ "",
+ "Additional details and impacted files
\n",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=============================================",
+ "+ Coverage 50.00% 60.00% +10.00% ",
+ "+ Complexity 11 10 -1 ",
+ "=============================================",
+ " Files 2 2 ",
+ " Lines 6 10 +4 ",
+ " Branches 0 1 +1 ",
+ "=============================================",
+ "+ Hits 3 6 +3 ",
+ " Misses 3 3 ",
+ "- Partials 0 1 +1 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | Complexity Δ | |",
+ "|---|---|---|---|",
+ f"| [integration](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `?` | `?` | |",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `100.00% <100.00%> (?)` | `0.00 <0.00> (?)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.",
+ "",
+ f"| [Components](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/components?src=pr&el=components) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [go_files](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/components?src=pr&el=component) | `62.50% <66.67%> (+12.50%)` | :arrow_up: |",
+ f"| [unit_flags](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/components?src=pr&el=component) | `100.00% <100.00%> (∅)` | |",
+ "",
+ " ",
+ "",
+ f"[:umbrella: View full report in Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue). ",
+ ":loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/).",
+ "",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_team_plan_customer_missing_lines(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_head_and_pull_head_differ,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_head_and_pull_head_differ
+ comparison.repository_service.service = "github"
+ # relevant part of this test
+ comparison.head.commit.repository.owner.plan = "users-teamm"
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ # Irrelevant but they don't overwrite Owner's plan
+ "layout": "newheader, reach, diff, flags, components, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={
+ # Irrelevant but here to ensure they don't overwrite Owner's plan
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "unit_flags", "flag_regexes": [r"unit.*"]},
+ ]
+ }
+ },
+ repository_service=mock_repo_provider,
+ )
+
+ pull = comparison.pull
+ repository = sample_comparison_head_and_pull_head_differ.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "Attention: Patch coverage is `66.66667%` with `1 line` in your changes missing coverage. Please review.",
+ f"| [Files with missing lines](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=tree) | Patch % | Lines |",
+ "|---|---|---|",
+ f"| [file\\_1.go](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree#diff-ZmlsZV8xLmdv) | 66.67% | [1 Missing :warning: ](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree) |",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)",
+ ]
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_team_plan_customer_all_lines_covered(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ sample_comparison_coverage_carriedforward.context = ComparisonContext(
+ all_tests_passed=True
+ )
+ comparison = sample_comparison_coverage_carriedforward
+ comparison.repository_service.service = "github"
+ # relevant part of this test
+ comparison.head.commit.repository.owner.plan = "users-teamm"
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ # Irrelevant but they don't overwrite Owner's plan
+ "layout": "newheader, reach, diff, flags, components, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ pull = comparison.pull
+ repository = sample_comparison_coverage_carriedforward.head.commit.repository
+ result = notifier.build_message(comparison)
+
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "",
+ ":white_check_mark: All tests successful. No failed tests found.",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)",
+ ]
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_team_plan_customer_all_lines_covered_test_results_error(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ sample_comparison_coverage_carriedforward.context = ComparisonContext(
+ all_tests_passed=False,
+ test_results_error=":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ )
+ comparison = sample_comparison_coverage_carriedforward
+ comparison.repository_service.service = "github"
+ # relevant part of this test
+ comparison.head.commit.repository.owner.plan = "users-teamm"
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ # Irrelevant but they don't overwrite Owner's plan
+ "layout": "newheader, reach, diff, flags, components, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ pull = comparison.pull
+ repository = sample_comparison_coverage_carriedforward.head.commit.repository
+ result = notifier.build_message(comparison)
+
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "",
+ ":x: We are unable to process any of the uploaded JUnit XML files. Please ensure your files are in the right format.",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)",
+ ]
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_team_plan_customer_all_lines_covered_no_third_line(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ sample_comparison_coverage_carriedforward.context = ComparisonContext(
+ all_tests_passed=False,
+ test_results_error=None,
+ )
+ comparison = sample_comparison_coverage_carriedforward
+ comparison.repository_service.service = "github"
+ # relevant part of this test
+ comparison.head.commit.repository.owner.plan = "users-teamm"
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ # Irrelevant but they don't overwrite Owner's plan
+ "layout": "newheader, reach, diff, flags, components, newfooter",
+ "hide_project_coverage": True,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ pull = comparison.pull
+ repository = sample_comparison_coverage_carriedforward.head.commit.repository
+ result = notifier.build_message(comparison)
+
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ "",
+ ":loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)",
+ ]
+ assert result == expected_result
+
+ @pytest.mark.django_db
+ def test_build_message_no_patch_or_proj_change(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison_coverage_carriedforward,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_coverage_carriedforward
+ pull = comparison.pull
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "layout": "newheader, reach, diff, flags, files, footer",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ repository = comparison.head.commit.repository
+ result = notifier.build_message(comparison)
+ expected_result = [
+ f"## [Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=h1) Report",
+ "All modified and coverable lines are covered by tests :white_check_mark:",
+ f"> Project coverage is 65.38%. Comparing base [(`{comparison.project_coverage_base.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.project_coverage_base.commit.commitid}?dropdown=coverage&el=desc) to head [(`{comparison.head.commit.commitid[:7]}`)](test.example.br/gh/{repository.slug}/commit/{comparison.head.commit.commitid}?dropdown=coverage&el=desc).",
+ "",
+ f"[](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?src=pr&el=tree)",
+ "",
+ "```diff",
+ "@@ Coverage Diff @@",
+ f"## master #{pull.pullid} +/- ##",
+ "=======================================",
+ " Coverage 65.38% 65.38% ",
+ "=======================================",
+ " Files 15 15 ",
+ " Lines 208 208 ",
+ "=======================================",
+ " Hits 136 136 ",
+ " Misses 4 4 ",
+ " Partials 68 68 ",
+ "```",
+ "",
+ f"| [Flag](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flags) | Coverage Δ | |",
+ "|---|---|---|",
+ f"| [unit](test.example.br/gh/{repository.slug}/pull/{pull.pullid}/flags?src=pr&el=flag) | `25.00% <ø> (ø)` | |",
+ "",
+ "Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more."
+ "",
+ "",
+ "------",
+ "",
+ f"[Continue to review full report in "
+ f"Codecov by Sentry](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=continue).",
+ "> **Legend** - [Click here to learn "
+ "more](https://docs.codecov.io/docs/codecov-delta)",
+ "> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`",
+ f"> Powered by "
+ f"[Codecov](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=footer). "
+ f"Last update "
+ f"[{comparison.project_coverage_base.commit.commitid[:7]}...{comparison.head.commit.commitid[:7]}](test.example.br/gh/{repository.slug}/pull/{pull.pullid}?dropdown=coverage&src=pr&el=lastupdated). "
+ f"Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).",
+ "",
+ ]
+
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+
+class TestComponentWriterSection(object):
+ def test_write_message_component_section_empty(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ comparison = sample_comparison
+ section_writer = ComponentsSectionWriter(
+ repository=comparison.head.commit.repository,
+ layout="layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={},
+ )
+ message = section_writer.write_section(
+ comparison=comparison, diff=None, changes=None, links={"pull": "urlurl"}
+ )
+ expected = []
+ assert message == expected
+
+ def test_get_component_data_for_table(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ comparison = sample_comparison
+ section_writer = ComponentsSectionWriter(
+ repository=comparison.head.commit.repository,
+ layout="layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "py_files", "paths": [r".*\.py"]},
+ ]
+ }
+ },
+ )
+ all_components = get_components_from_yaml(section_writer.current_yaml)
+ comparison = sample_comparison
+ component_data = section_writer._get_table_data_for_components(
+ all_components, comparison
+ )
+ expected_result = [
+ {
+ "name": "go_files",
+ "before": ReportTotals(
+ files=1,
+ lines=4,
+ hits=2,
+ misses=2,
+ partials=0,
+ coverage="50.00000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=11,
+ complexity_total=20,
+ diff=0,
+ ),
+ "after": ReportTotals(
+ files=1,
+ lines=8,
+ hits=5,
+ misses=3,
+ partials=0,
+ coverage="62.50000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=10,
+ complexity_total=2,
+ diff=0,
+ ),
+ "diff": ReportTotals(
+ files=1,
+ lines=3,
+ hits=2,
+ misses=1,
+ partials=0,
+ coverage="66.66667",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ },
+ {
+ "name": "py_files",
+ "before": ReportTotals(
+ files=1,
+ lines=2,
+ hits=1,
+ misses=1,
+ partials=0,
+ coverage="50.00000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ "after": ReportTotals(
+ files=1,
+ lines=2,
+ hits=1,
+ misses=0,
+ partials=1,
+ coverage="50.00000",
+ branches=1,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ "diff": ReportTotals(
+ files=0,
+ lines=0,
+ hits=0,
+ misses=0,
+ partials=0,
+ coverage=None,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=None,
+ complexity_total=None,
+ diff=0,
+ ),
+ },
+ ]
+ assert component_data == expected_result
+
+ def test_get_component_data_for_table_no_base(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ comparison = sample_comparison
+ comparison.project_coverage_base.report = None
+ comparison.project_coverage_base.commit = None
+ section_writer = ComponentsSectionWriter(
+ repository=comparison.head.commit.repository,
+ layout="layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "py_files", "paths": [r".*\.py"]},
+ ]
+ }
+ },
+ )
+ all_components = get_components_from_yaml(section_writer.current_yaml)
+ comparison = sample_comparison
+ component_data = section_writer._get_table_data_for_components(
+ all_components, comparison
+ )
+ expected_result = [
+ {
+ "name": "go_files",
+ "before": None,
+ "after": ReportTotals(
+ files=1,
+ lines=8,
+ hits=5,
+ misses=3,
+ partials=0,
+ coverage="62.50000",
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=10,
+ complexity_total=2,
+ diff=0,
+ ),
+ "diff": None,
+ },
+ {
+ "name": "py_files",
+ "before": None,
+ "after": ReportTotals(
+ files=1,
+ lines=2,
+ hits=1,
+ misses=0,
+ partials=1,
+ coverage="50.00000",
+ branches=1,
+ methods=0,
+ messages=0,
+ sessions=1,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ "diff": None,
+ },
+ ]
+ assert component_data == expected_result
+
+ def test_write_message_component_section(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ comparison = sample_comparison
+ section_writer = ComponentsSectionWriter(
+ repository=comparison.head.commit.repository,
+ layout="layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "py_files", "paths": [r".*\.py"]},
+ ]
+ }
+ },
+ )
+ message = section_writer.write_section(
+ comparison=comparison, diff=None, changes=None, links={"pull": "urlurl"}
+ )
+ expected = [
+ "| [Components](urlurl/components?src=pr&el=components) | Coverage Δ | |",
+ "|---|---|---|",
+ "| [go_files](urlurl/components?src=pr&el=component) | `62.50% <66.67%> (+12.50%)` | :arrow_up: |",
+ "| [py_files](urlurl/components?src=pr&el=component) | `50.00% <ø> (ø)` | |",
+ ]
+ assert message == expected
+
+ def test_write_message_component_section_no_base(
+ self,
+ dbsession,
+ mock_configuration,
+ mock_repo_provider,
+ sample_comparison,
+ ):
+ comparison = sample_comparison
+ comparison.project_coverage_base.report = None
+ comparison.project_coverage_base.commit = None
+ section_writer = ComponentsSectionWriter(
+ repository=comparison.head.commit.repository,
+ layout="layout",
+ show_complexity=False,
+ settings={},
+ current_yaml={
+ "component_management": {
+ "individual_components": [
+ {"component_id": "go_files", "paths": [r".*\.go"]},
+ {"component_id": "py_files", "paths": [r".*\.py"]},
+ ]
+ }
+ },
+ )
+ message = section_writer.write_section(
+ comparison=comparison, diff=None, changes=None, links={"pull": "urlurl"}
+ )
+ expected = [
+ "| [Components](urlurl/components?src=pr&el=components) | Coverage Δ | |",
+ "|---|---|---|",
+ "| [go_files](urlurl/components?src=pr&el=component) | `62.50% <0.00%> (?)` | |",
+ "| [py_files](urlurl/components?src=pr&el=component) | `50.00% <0.00%> (?)` | |",
+ ]
+ assert message == expected
+
+
+PROJECT_COVERAGE_CTA = ":information_source: You can also turn on [project coverage checks](https://docs.codecov.com/docs/common-recipe-list#set-project-coverage-checks-on-a-pull-request) and [project coverage reporting on Pull Request comment](https://docs.codecov.com/docs/common-recipe-list#show-project-coverage-changes-on-the-pull-request-comment)"
+
+
+class TestCommentNotifierWelcome:
+ def test_build_message(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ expected_result = [
+ "## Welcome to [Codecov](https://codecov.io) :tada:",
+ "",
+ "Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.",
+ "",
+ "Thanks for integrating Codecov - We've got you covered :open_umbrella:",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ def test_build_message_with_preexisting_bundle_pulls(
+ self, dbsession, mock_configuration, mock_repo_provider
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ owner = OwnerFactory.create(
+ service="github",
+ # Setting the time to _before_ patch centric default YAMLs start date of 2024-04-30
+ createstamp=datetime(2023, 1, 1, tzinfo=timezone.utc),
+ )
+ repository = RepositoryFactory.create(owner=owner)
+ branch = "new_branch"
+ # artificially create multiple pull entries with BA comments only
+ ba_pull_one = PullFactory.create(
+ repository=repository,
+ base=CommitFactory.create(repository=repository).commitid,
+ head=CommitFactory.create(repository=repository, branch=branch).commitid,
+ commentid=None,
+ bundle_analysis_commentid="98123978",
+ )
+ ba_pull_two = PullFactory.create(
+ repository=repository,
+ base=CommitFactory.create(repository=repository).commitid,
+ head=CommitFactory.create(repository=repository, branch=branch).commitid,
+ commentid=None,
+ bundle_analysis_commentid="23982347",
+ )
+ # Add these entries first so they are created before the pull with commentid only
+ dbsession.add_all([ba_pull_one, ba_pull_two])
+ dbsession.flush()
+
+ # Create new coverage pull
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch=branch)
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ )
+
+ head_report = Report()
+ head_file = ReportFile("file_1.go")
+ head_file.append(
+ 1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ head_report.append(head_file)
+
+ base_report = Report()
+ base_file = ReportFile("file_1.go")
+ base_file.append(
+ 1, ReportLine.create(coverage=0, sessions=[[0, 1]], complexity=(10, 2))
+ )
+ base_report.append(base_file)
+
+ head_full_commit = FullCommit(
+ commit=head_commit, report=ReadOnlyReport.create_from_report(head_report)
+ )
+ base_full_commit = FullCommit(
+ commit=base_commit, report=ReadOnlyReport.create_from_report(base_report)
+ )
+ comparison = ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "author": {"id": "12345", "username": "codecov-test-user"},
+ "base": {"branch": "master", "commitid": base_commit.commitid},
+ "head": {
+ "branch": "reason/some-testing",
+ "commitid": head_commit.commitid,
+ },
+ "number": str(pull.pullid),
+ "id": str(pull.pullid),
+ "state": "open",
+ "title": "Creating new code for reasons no one knows",
+ },
+ ),
+ )
+ )
+ dbsession.add_all([repository, base_commit, head_commit, pull])
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(comparison)
+
+ expected_result = [
+ "## Welcome to [Codecov](https://codecov.io) :tada:",
+ "",
+ "Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.",
+ "",
+ "Thanks for integrating Codecov - We've got you covered :open_umbrella:",
+ ]
+ for exp, res in zip(expected_result, result):
+ assert exp == res
+ assert result == expected_result
+
+ pulls_in_db = dbsession.query(Pull).all()
+ assert len(pulls_in_db) == 3
+
+ def test_should_see_project_coverage_cta_public_repo(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ sample_comparison.head.commit.repository.private = False
+
+ before_introduction_date = datetime(2024, 4, 1, 0, 0, 0).replace(
+ tzinfo=timezone.utc
+ )
+ sample_comparison.head.commit.repository.owner.createstamp = (
+ before_introduction_date
+ )
+
+ dbsession.add_all(
+ [
+ sample_comparison.head.commit.repository,
+ sample_comparison.head.commit.repository.owner,
+ ]
+ )
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA not in result
+
+ after_introduction_date = datetime(2024, 6, 1, 0, 0, 0).replace(
+ tzinfo=timezone.utc
+ )
+ sample_comparison.head.commit.repository.owner.createstamp = (
+ after_introduction_date
+ )
+
+ dbsession.add(sample_comparison.head.commit.repository.owner)
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA in result
+
+ def test_should_see_project_coverage_cta_introduction_date(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ sample_comparison.head.commit.repository.private = True
+
+ before_introduction_date = datetime(2024, 4, 1, 0, 0, 0).replace(
+ tzinfo=timezone.utc
+ )
+ sample_comparison.head.commit.repository.owner.createstamp = (
+ before_introduction_date
+ )
+
+ dbsession.add_all(
+ [
+ sample_comparison.head.commit.repository,
+ sample_comparison.head.commit.repository.owner,
+ ]
+ )
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA not in result
+
+ after_introduction_date = datetime(2024, 6, 1, 0, 0, 0).replace(
+ tzinfo=timezone.utc
+ )
+ sample_comparison.head.commit.repository.owner.createstamp = (
+ after_introduction_date
+ )
+
+ dbsession.add(sample_comparison.head.commit.repository.owner)
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA in result
+
+ def test_should_see_project_coverage_cta_team_plan(
+ self, dbsession, mock_configuration, mock_repo_provider, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+
+ sample_comparison.head.commit.repository.private = True
+
+ after_introduction_date = datetime(2024, 6, 1, 0, 0, 0).replace(
+ tzinfo=timezone.utc
+ )
+ sample_comparison.head.commit.repository.owner.createstamp = (
+ after_introduction_date
+ )
+
+ sample_comparison.head.commit.repository.owner.plan = PlanName.TEAM_YEARLY.value
+
+ dbsession.add_all(
+ [
+ sample_comparison.head.commit.repository,
+ sample_comparison.head.commit.repository.owner,
+ ]
+ )
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA not in result
+
+ sample_comparison.head.commit.repository.owner.plan = DEFAULT_FREE_PLAN
+
+ dbsession.add(sample_comparison.head.commit.repository.owner)
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=mock_repo_provider,
+ )
+ result = notifier.build_message(sample_comparison)
+ assert PROJECT_COVERAGE_CTA in result
+
+
+class TestMessagesToUserSection(object):
+ @pytest.mark.parametrize(
+ "repo, is_enterprise, owner_has_apps, expected",
+ [
+ pytest.param(
+ RepositoryFactory(owner__service="github", owner__integration_id=None),
+ False,
+ False,
+ ":exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.",
+ id="owner_not_using_app_should_emit_warning",
+ ),
+ pytest.param(
+ RepositoryFactory(owner__service="github", owner__integration_id=None),
+ True,
+ False,
+ "",
+ id="is_enterprise_should_not_emit_warning",
+ ),
+ pytest.param(
+ RepositoryFactory(
+ owner__service="github", owner__integration_id="integration_id"
+ ),
+ False,
+ False,
+ "",
+ id="owner_using_app_legacy_should_not_emit_warning",
+ ),
+ pytest.param(
+ RepositoryFactory(owner__service="github", owner__integration_id=None),
+ False,
+ True,
+ "",
+ id="owner_using_app_should_not_emit_warning",
+ ),
+ ],
+ )
+ def test_install_github_app_warning(
+ self,
+ mocker,
+ repo: Repository,
+ is_enterprise: bool,
+ owner_has_apps: bool,
+ expected: str,
+ ):
+ if owner_has_apps:
+ repo.owner.github_app_installations = [
+ GithubAppInstallation(owner=repo.owner, app_id=10, installation_id=1000)
+ ]
+ commit = CommitFactory(repository=repo)
+ mock_head = mocker.MagicMock(commit=commit)
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.is_enterprise",
+ return_value=is_enterprise,
+ )
+ fake_comparison = mocker.MagicMock(head=mock_head)
+ assert (
+ MessagesToUserSectionWriter(
+ repo, mocker.MagicMock(), False, mocker.MagicMock(), {}
+ )._write_install_github_app_warning(fake_comparison)
+ == expected
+ )
+
+ @pytest.mark.parametrize(
+ "commit, upload_diff, has_project_status, is_coverage_drop_significant, expected",
+ [
+ pytest.param(
+ CommitFactory(state="pending"),
+ [ReportUploadedCount(flag="unit", base_count=4, head_count=3)],
+ True,
+ True,
+ "",
+ id="commit_not_ready_should_not_send_warning",
+ ),
+ pytest.param(
+ CommitFactory(state="complete"),
+ [ReportUploadedCount(flag="unit", base_count=4, head_count=3)],
+ False,
+ True,
+ "",
+ id="no_project_status_should_not_send_warning",
+ ),
+ pytest.param(
+ CommitFactory(state="complete"),
+ [ReportUploadedCount(flag="unit", base_count=4, head_count=3)],
+ True,
+ False,
+ "",
+ id="no_significant_drop_in_coverage_should_not_send_warning",
+ ),
+ pytest.param(
+ CommitFactory(state="complete"),
+ [],
+ True,
+ True,
+ "",
+ id="no_upload_diff_should_not_send_warning",
+ ),
+ pytest.param(
+ CommitFactory(
+ state="complete",
+ commitid="cf59ea49c149c8ef5d7303834362a4d27bbd4a28",
+ ),
+ [
+ ReportUploadedCount(flag="unit", base_count=4, head_count=3),
+ ReportUploadedCount(flag="local", base_count=2, head_count=1),
+ ],
+ True,
+ True,
+ (
+ "> :exclamation: There is a different number of reports uploaded between BASE (bbd4a28) and HEAD (cf59ea4). Click for more details."
+ + "\n> "
+ + "\n> HEAD has 2 uploads less than BASE
"
+ + "\n>"
+ + "\n>| Flag | BASE (bbd4a28) | HEAD (cf59ea4) |"
+ + "\n>|------|------|------|"
+ + "\n>|unit|4|3|"
+ + "\n>|local|2|1|"
+ + "\n> "
+ ),
+ id="should_send_warning_2_uploads_less",
+ ),
+ pytest.param(
+ CommitFactory(
+ state="complete",
+ commitid="cf59ea49c149c8ef5d7303834362a4d27bbd4a28",
+ ),
+ [
+ ReportUploadedCount(flag="unit", base_count=4, head_count=3),
+ ],
+ True,
+ True,
+ (
+ "> :exclamation: There is a different number of reports uploaded between BASE (bbd4a28) and HEAD (cf59ea4). Click for more details."
+ + "\n> "
+ + "\n> HEAD has 1 upload less than BASE
"
+ + "\n>"
+ + "\n>| Flag | BASE (bbd4a28) | HEAD (cf59ea4) |"
+ + "\n>|------|------|------|"
+ + "\n>|unit|4|3|"
+ + "\n> "
+ ),
+ id="should_send_warning_1_upload_less",
+ ),
+ ],
+ )
+ def test_different_upload_count_warning(
+ self,
+ mocker,
+ commit: Commit,
+ upload_diff: List[ReportUploadedCount],
+ has_project_status: bool,
+ is_coverage_drop_significant: bool,
+ expected: str,
+ ):
+ yaml = {"coverage": {"status": {"project": has_project_status}}}
+ mocker.patch(
+ "services.notification.notifiers.mixins.message.sections.is_coverage_drop_significant",
+ return_value=is_coverage_drop_significant,
+ )
+ mock_head = mocker.MagicMock(commit=commit)
+ mock_project_coverage_base = mocker.MagicMock(
+ commit=CommitFactory(
+ repository=commit.repository,
+ commitid="bbd4a28cf59ea49c149c8ef5d7303834362a4d27",
+ )
+ )
+ fake_comparison = mocker.MagicMock(
+ head=mock_head, project_coverage_base=mock_project_coverage_base
+ )
+ fake_comparison.get_reports_uploaded_count_per_flag_diff.return_value = (
+ upload_diff
+ )
+ assert (
+ MessagesToUserSectionWriter(
+ commit.repository, mocker.MagicMock(), False, mocker.MagicMock(), yaml
+ )._write_different_upload_count_warning(fake_comparison)
+ == expected
+ )
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_comment_conditions.py b/apps/worker/services/notification/notifiers/tests/unit/test_comment_conditions.py
new file mode 100644
index 0000000000..3f805746c2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_comment_conditions.py
@@ -0,0 +1,302 @@
+from typing import Dict, List
+
+import pytest
+from shared.validation.types import (
+ CoverageCommentRequiredChanges,
+ CoverageCommentRequiredChangesANDGroup,
+)
+from shared.yaml import UserYaml
+
+from database.enums import Decoration
+from database.models.core import Repository
+from services.comparison import ComparisonProxy
+from services.notification.notifiers.comment import CommentNotifier
+from services.notification.notifiers.comment.conditions import (
+ HasEnoughRequiredChanges,
+ NoAutoActivateMessageIfAutoActivateIsOff,
+)
+
+
+def _get_notifier(
+ repository: Repository,
+ required_changes: CoverageCommentRequiredChangesANDGroup,
+ repo_provider,
+):
+ return CommentNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"require_changes": required_changes},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=repo_provider,
+ )
+
+
+def _get_mock_compare_result(file_affected: str, lines_affected: List[str]) -> Dict:
+ return {
+ "diff": {
+ "files": {
+ file_affected: {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": lines_affected,
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ ],
+ "stats": {"added": 3, "removed": 3},
+ }
+ }
+ }
+ }
+
+
+@pytest.mark.parametrize(
+ "comparison_name, condition, expected",
+ [
+ pytest.param(
+ "sample_comparison",
+ [CoverageCommentRequiredChanges.any_change.value],
+ True,
+ id="any_change_comparison_with_changes",
+ ),
+ pytest.param(
+ "sample_comparison_without_base_report",
+ [CoverageCommentRequiredChanges.any_change.value],
+ False,
+ id="any_change_comparison_without_base",
+ ),
+ pytest.param(
+ "sample_comparison_no_change",
+ [CoverageCommentRequiredChanges.any_change.value],
+ False,
+ id="any_change_sample_comparison_no_change",
+ ),
+ pytest.param(
+ "sample_comparison",
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ False,
+ id="coverage_drop_comparison_with_positive_changes",
+ ),
+ pytest.param(
+ "sample_comparison_no_change",
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ False,
+ id="coverage_drop_sample_comparison_no_change",
+ ),
+ pytest.param(
+ "sample_comparison_without_base_report",
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ True,
+ id="coverage_drop_comparison_without_base",
+ ),
+ ],
+)
+def test_condition_different_comparisons_no_diff(
+ comparison_name, condition, expected, mock_repo_provider, request
+):
+ comparison = request.getfixturevalue(comparison_name)
+ # There's no diff between HEAD and BASE so we can't calculate unexpected coverage.
+ # Any change then needs to be a coverage change
+ mock_repo_provider.get_compare.return_value = {"diff": {"files": {}, "commits": []}}
+ notifier = _get_notifier(
+ comparison.head.commit.repository, condition, mock_repo_provider
+ )
+ assert HasEnoughRequiredChanges.check_condition(notifier, comparison) == expected
+
+
+@pytest.mark.parametrize(
+ "condition, expected",
+ [
+ pytest.param(
+ [CoverageCommentRequiredChanges.any_change.value], False, id="any_change"
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ False,
+ id="coverage_drop",
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.uncovered_patch.value],
+ False,
+ id="uncovered_patch",
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.no_requirements.value],
+ True,
+ id="no_requirements",
+ ),
+ ],
+)
+def test_condition_exact_same_report_coverage_not_affected_by_diff(
+ sample_comparison_no_change, mock_repo_provider, condition, expected
+):
+ mock_repo_provider.get_compare.return_value = _get_mock_compare_result(
+ "README.md", ["5", "8", "5", "9"]
+ )
+ notifier = _get_notifier(
+ sample_comparison_no_change.head.commit.repository,
+ condition,
+ mock_repo_provider,
+ )
+ assert (
+ HasEnoughRequiredChanges.check_condition(notifier, sample_comparison_no_change)
+ == expected
+ )
+
+
+@pytest.mark.parametrize(
+ "condition, expected",
+ [
+ pytest.param(
+ [CoverageCommentRequiredChanges.any_change.value], True, id="any_change"
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ False,
+ id="coverage_drop",
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.uncovered_patch.value],
+ False,
+ id="uncovered_patch",
+ ),
+ pytest.param(
+ [CoverageCommentRequiredChanges.no_requirements.value],
+ True,
+ id="no_requirements",
+ ),
+ ],
+)
+def test_condition_exact_same_report_coverage_affected_by_diff(
+ sample_comparison_no_change, mock_repo_provider, condition, expected
+):
+ mock_repo_provider.get_compare.return_value = _get_mock_compare_result(
+ "file_1.go", ["4", "8", "4", "8"]
+ )
+ notifier = _get_notifier(
+ sample_comparison_no_change.head.commit.repository,
+ condition,
+ mock_repo_provider,
+ )
+ assert (
+ HasEnoughRequiredChanges.check_condition(notifier, sample_comparison_no_change)
+ == expected
+ )
+
+
+@pytest.mark.parametrize(
+ "affected_lines, expected",
+ [
+ pytest.param(["4", "8", "4", "8"], False, id="patch_100%_covered"),
+ pytest.param(["1", "8", "1", "8"], True, id="patch_NOT_100%_covered"),
+ ],
+)
+def test_uncovered_patch(
+ sample_comparison_no_change, mock_repo_provider, affected_lines, expected
+):
+ mock_repo_provider.get_compare.return_value = _get_mock_compare_result(
+ "file_1.go", affected_lines
+ )
+ notifier = _get_notifier(
+ sample_comparison_no_change.head.commit.repository,
+ [CoverageCommentRequiredChanges.uncovered_patch.value],
+ mock_repo_provider,
+ )
+ assert (
+ HasEnoughRequiredChanges.check_condition(notifier, sample_comparison_no_change)
+ == expected
+ )
+
+
+@pytest.mark.parametrize(
+ "comparison_name, yaml, expected",
+ [
+ pytest.param("sample_comparison", {}, False, id="positive_change"),
+ pytest.param(
+ "sample_comparison_negative_change",
+ {},
+ True,
+ id="negative_change_no_extra_config",
+ ),
+ pytest.param(
+ "sample_comparison_negative_change",
+ {"coverage": {"status": {"project": True}}},
+ True,
+ id="negative_change_bool_config",
+ ),
+ pytest.param(
+ "sample_comparison_negative_change",
+ {"coverage": {"status": {"project": {"threshold": 10}}}},
+ False,
+ id="negative_change_high_threshold",
+ ),
+ ],
+)
+def test_coverage_drop_with_different_project_configs(
+ comparison_name, yaml, expected, request
+):
+ comparison: ComparisonProxy = request.getfixturevalue(comparison_name)
+ comparison.comparison.current_yaml = UserYaml(yaml)
+ notifier = _get_notifier(
+ comparison.head.commit.repository,
+ [CoverageCommentRequiredChanges.coverage_drop.value],
+ None,
+ )
+ assert HasEnoughRequiredChanges.check_condition(notifier, comparison) == expected
+
+
+@pytest.mark.parametrize(
+ "decoration_type, plan_auto_activate, expected",
+ [
+ pytest.param(
+ Decoration.upgrade, False, False, id="upgrade_no_auto_activate__dont_send"
+ ),
+ pytest.param(Decoration.upgrade, True, True, id="upgrade_auto_activate__send"),
+ pytest.param(
+ Decoration.upload_limit,
+ False,
+ True,
+ id="other_decoration_no_auto_activate__send",
+ ),
+ pytest.param(
+ Decoration.upload_limit,
+ True,
+ True,
+ id="other_decoration_auto_activate__send",
+ ),
+ ],
+)
+def test_no_auto_activate_message_if_auto_activate_is_off(
+ sample_comparison_no_change,
+ mock_repo_provider,
+ decoration_type,
+ plan_auto_activate,
+ expected,
+):
+ notifier = _get_notifier(
+ sample_comparison_no_change.head.commit.repository,
+ [CoverageCommentRequiredChanges.any_change.value],
+ mock_repo_provider,
+ )
+ notifier.decoration_type = decoration_type
+ notifier.repository.owner.plan_auto_activate = plan_auto_activate
+ assert (
+ NoAutoActivateMessageIfAutoActivateIsOff.check_condition(
+ notifier, sample_comparison_no_change
+ )
+ == expected
+ )
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_generics.py b/apps/worker/services/notification/notifiers/tests/unit/test_generics.py
new file mode 100644
index 0000000000..556a77cefc
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_generics.py
@@ -0,0 +1,270 @@
+import httpx
+
+from database.tests.factories import RepositoryFactory
+from services.notification.notifiers.generics import (
+ RequestsYamlBasedNotifier,
+ StandardNotifier,
+)
+
+
+class SampleNotifierForTest(StandardNotifier):
+ def build_payload(self, comparison):
+ return {"commitid": comparison.head.commit.commitid}
+
+ def send_actual_notification(self, data):
+ return {
+ "notification_attempted": True,
+ "notification_successful": True,
+ "explanation": None,
+ }
+
+
+class TestStandardkNotifier(object):
+ def test_is_enabled_without_site_settings(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="test_is_enabled_without_site_settings"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = StandardNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=False,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ def test_is_enabled_with_site_settings_no_special_config(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="test_is_enabled_without_site_settings"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = StandardNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"url": "https://example.com/myexample"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.is_enabled()
+
+ def test_is_enabled_with_site_settings_no_url(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="test_is_enabled_without_site_settings"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = StandardNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"field_1": "something"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ def test_is_enabled_with_site_settings_whitelisted_url(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="test_is_enabled_without_site_settings"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = StandardNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"url": "https://example.com/myexample"},
+ notifier_site_settings=["example.com"],
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.is_enabled()
+
+ def test_is_enabled_with_site_settings_not_whitelisted_url(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="test_is_enabled_without_site_settings"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ notifier = StandardNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={"url": "https://example.com/myexample"},
+ notifier_site_settings=["badexample.com"],
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.is_enabled()
+
+ def test_should_notify_comparison(self, sample_comparison):
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"url": "https://example.com/myexample"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.should_notify_comparison(sample_comparison)
+
+ def test_should_notify_comparison_bad_branch(self, sample_comparison):
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "branches": ["test-.*"],
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.should_notify_comparison(sample_comparison)
+
+ def test_should_notify_comparison_good_branch(self, sample_comparison):
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "branches": ["new_.*"],
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.should_notify_comparison(sample_comparison)
+
+ def test_should_notify_comparison_not_above_threshold(self, sample_comparison):
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 80.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.should_notify_comparison(sample_comparison)
+
+ def test_should_notify_comparison_no_base(
+ self, sample_comparison_without_base_report
+ ):
+ notifier = StandardNotifier(
+ repository=sample_comparison_without_base_report.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 80.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.should_notify_comparison(
+ sample_comparison_without_base_report
+ )
+
+ def test_should_notify_comparison_is_above_threshold(self, sample_comparison):
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 8.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert notifier.should_notify_comparison(sample_comparison)
+
+ def test_should_notify_comparison_is_above_threshold_no_coverage(
+ self, sample_comparison
+ ):
+ actual_comparison = sample_comparison.get_filtered_comparison(
+ path_patterns=[".*txt"], flags=None
+ )
+ notifier = StandardNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 8.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ assert not notifier.should_notify_comparison(actual_comparison)
+
+ def test_notify(self, sample_comparison):
+ notifier = SampleNotifierForTest(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 8.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ res = notifier.notify(sample_comparison)
+ assert res.notification_attempted
+ assert res.notification_successful
+ assert res.explanation is None
+ assert res.data_sent == {"commitid": sample_comparison.head.commit.commitid}
+ assert res.data_received is None
+
+ def test_notify_should_not_notify(self, sample_comparison, mocker):
+ notifier = SampleNotifierForTest(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 8.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ mocker.patch.object(
+ SampleNotifierForTest, "should_notify_comparison", return_value=False
+ )
+ res = notifier.notify(sample_comparison)
+ assert not res.notification_attempted
+ assert res.notification_successful is None
+ assert res.explanation == "Did not fit criteria"
+ assert res.data_sent is None
+ assert res.data_received is None
+
+
+class TestRequestsYamlBasedNotifier(object):
+ def test_send_notification_exception(self, mocker, sample_comparison):
+ mocked_post = mocker.patch.object(httpx.Client, "post")
+ mocked_post.side_effect = httpx.HTTPError("message")
+ notifier = RequestsYamlBasedNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "url": "https://example.com/myexample",
+ "threshold": 8.0,
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ data = {}
+ res = notifier.send_actual_notification(data)
+ assert res == {
+ "notification_attempted": True,
+ "notification_successful": False,
+ "explanation": "connection_issue",
+ }
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_gitter.py b/apps/worker/services/notification/notifiers/tests/unit/test_gitter.py
new file mode 100644
index 0000000000..e61446b2a7
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_gitter.py
@@ -0,0 +1,39 @@
+from decimal import Decimal
+
+from services.notification.notifiers.gitter import GitterNotifier
+from services.repository import get_repo_provider_service
+
+
+def test_build_payload_without_special_config(
+ dbsession, mock_configuration, sample_comparison
+):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = GitterNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ sample_comparison.head.commit.repository
+ ),
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f"Coverage *increased* +10.00% on `new_branch` is `60.00000%` via test.example.br/gh/{repository.slug}/commit/{commit.commitid}"
+ expected_result = {
+ "message": text,
+ "branch": "new_branch",
+ "pr": comparison.pull.pullid,
+ "commit": commit.commitid,
+ "commit_short": commit.commitid[:7],
+ "text": "increased",
+ "commit_url": f"https://github.com/{repository.slug}/commit/{commit.commitid}",
+ "codecov_url": f"test.example.br/gh/{repository.slug}/commit/{commit.commitid}",
+ "coverage": "60.00000",
+ "coverage_change": Decimal("10.00"),
+ }
+ assert result["message"] == expected_result["message"]
+ assert result == expected_result
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_hipchat.py b/apps/worker/services/notification/notifiers/tests/unit/test_hipchat.py
new file mode 100644
index 0000000000..169bf101f3
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_hipchat.py
@@ -0,0 +1,122 @@
+from services.notification.notifiers.hipchat import HipchatNotifier
+
+
+class TestHipchatkNotifier(object):
+ def test_build_payload_without_special_config(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ url = "test.example.br"
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = url
+ comparison = sample_comparison
+ notifier = HipchatNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f'Coverage for {repository.slug} increased +10.00% on new_branch is 60.00000% # via {commit.commitid[:7]}'
+ expected_result = {
+ "card": None,
+ "color": "green",
+ "from": "Codecov",
+ "message": text,
+ "message_format": "html",
+ "notify": False,
+ }
+ assert result["message"] == expected_result["message"]
+ assert result == expected_result
+
+ def test_build_payload_without_base_report(
+ self, sample_comparison, mock_configuration
+ ):
+ sample_comparison.project_coverage_base.report = None
+ url = "test.example.br"
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = url
+ comparison = sample_comparison
+ notifier = HipchatNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f'Coverage for {repository.slug} on new_branch is 60.00000% # via {commit.commitid[:7]}'
+ expected_result = {
+ "card": None,
+ "color": "gray",
+ "from": "Codecov",
+ "message": text,
+ "message_format": "html",
+ "notify": False,
+ }
+ assert result["message"] == expected_result["message"]
+ assert result == expected_result
+
+ def test_build_payload_with_card(self, sample_comparison, mock_configuration):
+ url = "test.example.br"
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = url
+ notifier = HipchatNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"card": True},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(sample_comparison)
+ commit = sample_comparison.head.commit
+ repository = commit.repository
+ text = f'Coverage for {repository.slug} increased +10.00% on new_branch is 60.00000% # via {commit.commitid[:7]}'
+ expected_result = {
+ "card": {
+ "attributes": [
+ {
+ "label": "Author",
+ "value": {
+ "label": commit.author.username,
+ "url": f"test.example.br/gh/{repository.slug}/commit/{commit.commitid}",
+ },
+ },
+ {
+ "label": "Commit",
+ "value": {
+ "label": commit.commitid[:7],
+ "url": f"test.example.br/gh/{repository.slug}/commit/{commit.commitid}",
+ },
+ },
+ {
+ "label": "Compare",
+ "value": {"label": "+10.00%", "style": "lozenge-success"},
+ },
+ ],
+ "description": {
+ "format": "html",
+ "value": f"Coverage for {repository.slug} on new_branch is now 60.00%",
+ },
+ "format": "compact",
+ "icon": {
+ "url": f"test.example.br/gh/{repository.slug}/commit/{commit.commitid}/graphs/sunburst.svg?size=100"
+ },
+ "id": commit.commitid,
+ "style": "application",
+ "title": f"Codecov ⋅ {repository.slug} on new_branch",
+ "url": f"test.example.br/gh/{repository.slug}/commit/{commit.commitid}",
+ },
+ "color": "green",
+ "from": "Codecov",
+ "message": text,
+ "message_format": "html",
+ "notify": False,
+ }
+ assert result["message"] == expected_result["message"]
+ assert result["card"] == expected_result["card"]
+ assert result == expected_result
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_irc.py b/apps/worker/services/notification/notifiers/tests/unit/test_irc.py
new file mode 100644
index 0000000000..9b11ef8b61
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_irc.py
@@ -0,0 +1,72 @@
+import socket
+
+from services.notification.notifiers.irc import IRCNotifier
+
+
+class TestIRCNotifier(object):
+ def test_build_payload(self, dbsession, mock_configuration, sample_comparison):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = IRCNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"server": ""},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f"Coverage for {repository.slug} *increased* `+10.00%` on `new_branch` is `60.00000%` via `{commit.commitid[:7]}`"
+ expected_result = {"message": text}
+ assert result == expected_result
+
+ def test_send_actual_notification(
+ self, dbsession, mock_configuration, sample_comparison, mocker
+ ):
+ mocked_connection = mocker.patch(
+ "services.notification.notifiers.irc.socket.socket"
+ ).return_value
+ mocked_connection.recv.side_effect = [
+ b":956adb1eca91.example.com NOTICE * :*** Looking up your hostname...\r\n",
+ b"PING :fhkFTdCsVx\r\n:956adb1eca91.example.com NOTICE codecov :*** I...\r\n",
+ b":956adb1eca91.example.com NOTICE codecov :*** Could not resolve you...\r\n",
+ socket.timeout,
+ b":956adb1eca91.example.com 451 codecov PRIVMSG :You have not registe...\r\n",
+ b":956adb1eca91.example.com 001 codecov :Welcome to the Omega IRC Net... USE",
+ b"RLEN=11 WHOX :are supported by this server\r\n:956adb1eca91.example...\\ |",
+ b" |_) | _| |_ | | \\ \\ | |____ | (_| |\r\n:956adb1eca91.example.co...6adb",
+ b"1eca91.example.com 372 codecov :- \\ \\ `. `-._ ... ",
+ b" \\\r\n:956adb1eca91.example.com 372 codecov :- | * IRC:...----",
+ b"-----------------------\r\n:956adb1eca91.example.com 372 codecov :-... war",
+ b"m welcome from the InspIRCd-Docker Team, too!\r\n:956adb1eca91.exam...\r\n",
+ b":NickServ!services@services.localhost.net NOTICE codecov :This nick...\r\n",
+ b"",
+ socket.timeout,
+ b":codecov!codecov@172.22.0.1 JOIN :#samplechannel\r\n:956adb1eca91.e...\r\n",
+ socket.timeout,
+ socket.timeout,
+ ]
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = IRCNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "server": "ircserver",
+ "channel": "#samplechannel",
+ "password": "s3cret",
+ "nickserv_password": "password",
+ },
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f"Coverage for {repository.slug} *increased* `+10.00%` on `new_branch` is `60.00000%` via `{commit.commitid[:7]}`"
+ data = {"message": text}
+ result = notifier.send_actual_notification(data)
+ expected_result = {"successful": True, "reason": None}
+ assert result == expected_result
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_slack.py b/apps/worker/services/notification/notifiers/tests/unit/test_slack.py
new file mode 100644
index 0000000000..68e1af1674
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_slack.py
@@ -0,0 +1,137 @@
+from services.notification.notifiers.slack import SlackNotifier
+
+
+class TestSlackNotifier(object):
+ def test_build_payload_without_attachments(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = SlackNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f"Coverage for *increased* `` on `new_branch` is `60.00000%` via ``"
+ expected_result = {
+ "attachments": [],
+ "author_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ "author_name": "Codecov",
+ "text": text,
+ }
+ assert result == expected_result
+
+ def test_build_payload_with_attachments(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = SlackNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"attachments": ["sunburst"]},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ repository = commit.repository
+ text = f"Coverage for *increased* `` on `new_branch` is `60.00000%` via ``"
+ expected_result = {
+ "attachments": [
+ {
+ "color": "good",
+ "fallback": "Commit sunburst attachment",
+ "image_url": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}/graphs/sunburst.svg?size=100",
+ "title": "Commit Sunburst",
+ "title_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ }
+ ],
+ "author_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ "author_name": "Codecov",
+ "text": text,
+ }
+ assert result["attachments"][0] == expected_result["attachments"][0]
+ assert result == expected_result
+
+ def test_build_payload_with_message(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison
+ notifier = SlackNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"message": "This is a sample"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ commit = comparison.head.commit
+ expected_result = {
+ "attachments": [],
+ "author_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ "author_name": "Codecov",
+ "text": "This is a sample",
+ }
+ assert result == expected_result
+
+ def test_build_payload_without_pull(
+ self, sample_comparison_without_pull, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_pull
+ commit = sample_comparison_without_pull.head.commit
+ repository = commit.repository
+ notifier = SlackNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ text = f"Coverage for *increased* `` on `new_branch` is `60.00000%` via ``"
+ expected_result = {
+ "attachments": [],
+ "author_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ "author_name": "Codecov",
+ "text": text,
+ }
+ assert result["text"] == expected_result["text"]
+ assert result == expected_result
+
+ def test_build_payload_without_base(
+ self, sample_comparison_without_base_report, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ commit = comparison.head.commit
+ repository = commit.repository
+ notifier = SlackNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ )
+ result = notifier.build_payload(comparison)
+ text = f"Coverage for on `new_branch` is `60.00000%` via ``"
+ expected_result = {
+ "attachments": [],
+ "author_link": f"test.example.br/gh/{commit.repository.slug}/commit/{commit.commitid}",
+ "author_name": "Codecov",
+ "text": text,
+ }
+ assert result["text"] == expected_result["text"]
+ assert result == expected_result
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_status.py b/apps/worker/services/notification/notifiers/tests/unit/test_status.py
new file mode 100644
index 0000000000..ab6b26e6f2
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_status.py
@@ -0,0 +1,2801 @@
+from unittest.mock import MagicMock
+
+import pytest
+from mock import AsyncMock
+from shared.reports.readonly import ReadOnlyReport
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import ReportLine, ReportTotals
+from shared.torngit.exceptions import (
+ TorngitClientError,
+ TorngitRepoNotFoundError,
+ TorngitServerUnreachableError,
+)
+from shared.torngit.status import Status
+from shared.typings.torngit import GithubInstallationInfo, TorngitInstanceData
+from shared.yaml.user_yaml import UserYaml
+
+from database.enums import Notification
+from database.tests.factories.core import CommitFactory
+from services.comparison import ComparisonProxy
+from services.comparison.types import FullCommit
+from services.decoration import Decoration
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.mixins.status import (
+ HELPER_TEXT_MAP,
+ HelperTextKey,
+ HelperTextTemplate,
+)
+from services.notification.notifiers.status import (
+ ChangesStatusNotifier,
+ PatchStatusNotifier,
+ ProjectStatusNotifier,
+)
+from services.notification.notifiers.status.base import StatusNotifier
+from services.urls import get_pull_url
+
+
+def test_notification_type(mocker):
+ assert (
+ ProjectStatusNotifier(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ ).notification_type
+ == Notification.status_project
+ )
+ assert (
+ ChangesStatusNotifier(
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ mocker.MagicMock(),
+ ).notification_type
+ == Notification.status_changes
+ )
+
+
+@pytest.fixture
+def multiple_diff_changes():
+ return {
+ "files": {
+ "modified.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["20", "8", "20", "8"],
+ "lines": [
+ " return k * k",
+ " ",
+ " ",
+ "-def k(l):",
+ "- return 2 * l",
+ "+def k(var):",
+ "+ return 2 * var",
+ " ",
+ " ",
+ " def sample_function():",
+ ],
+ }
+ ],
+ "stats": {"added": 2, "removed": 2},
+ "type": "modified",
+ },
+ "renamed.py": {
+ "before": "old_renamed.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "renamed_with_changes.py": {
+ "before": "old_renamed_with_changes.py",
+ "segments": [],
+ "stats": {"added": 0, "removed": 0},
+ "type": "modified",
+ },
+ "added.py": {
+ "before": None,
+ "segments": [
+ {
+ "header": ["0", "0", "1", ""],
+ "lines": ["+This is an explanation"],
+ }
+ ],
+ "stats": {"added": 1, "removed": 0},
+ "type": "new",
+ },
+ "deleted.py": {
+ "before": "tests/test_sample.py",
+ "stats": {"added": 0, "removed": 0},
+ "type": "deleted",
+ },
+ }
+ }
+
+
+@pytest.fixture
+def comparison_100_percent_patch(sample_comparison):
+ first_report = Report()
+ second_report = Report()
+ # MODIFIED FILE
+ first_modified_file = ReportFile("modified.py")
+ first_modified_file.append(17, ReportLine.create(coverage=0))
+ first_modified_file.append(18, ReportLine.create(coverage=0))
+ first_modified_file.append(19, ReportLine.create(coverage=1))
+ first_modified_file.append(20, ReportLine.create(coverage=0))
+ first_modified_file.append(21, ReportLine.create(coverage=1))
+ first_modified_file.append(22, ReportLine.create(coverage=1))
+ first_modified_file.append(23, ReportLine.create(coverage=0))
+ first_modified_file.append(24, ReportLine.create(coverage=0))
+ first_report.append(first_modified_file)
+ second_modified_file = ReportFile("modified.py")
+ second_modified_file.append(18, ReportLine.create(coverage=0))
+ second_modified_file.append(19, ReportLine.create(coverage=0))
+ second_modified_file.append(20, ReportLine.create(coverage=0))
+ second_modified_file.append(21, ReportLine.create(coverage=0))
+ second_modified_file.append(22, ReportLine.create(coverage=0))
+ second_modified_file.append(23, ReportLine.create(coverage=1))
+ second_modified_file.append(24, ReportLine.create(coverage=1))
+ second_report.append(second_modified_file)
+ sample_comparison.project_coverage_base.report = ReadOnlyReport.create_from_report(
+ first_report
+ )
+ sample_comparison.head.report = ReadOnlyReport.create_from_report(second_report)
+ return sample_comparison
+
+
+@pytest.fixture
+def comparison_with_multiple_changes(sample_comparison):
+ first_report = Report()
+ second_report = Report()
+ # DELETED FILE
+ first_deleted_file = ReportFile("deleted.py")
+ first_deleted_file.append(10, ReportLine.create(coverage=1))
+ first_deleted_file.append(12, ReportLine.create(coverage=0))
+ first_report.append(first_deleted_file)
+ # ADDED FILE
+ second_added_file = ReportFile("added.py")
+ second_added_file.append(99, ReportLine.create(coverage=1))
+ second_added_file.append(101, ReportLine.create(coverage=0))
+ second_report.append(second_added_file)
+ # MODIFIED FILE
+ first_modified_file = ReportFile("modified.py")
+ first_modified_file.append(17, ReportLine.create(coverage=1))
+ first_modified_file.append(18, ReportLine.create(coverage=1))
+ first_modified_file.append(19, ReportLine.create(coverage=1))
+ first_modified_file.append(20, ReportLine.create(coverage=0))
+ first_modified_file.append(21, ReportLine.create(coverage=1))
+ first_modified_file.append(22, ReportLine.create(coverage=1))
+ first_modified_file.append(23, ReportLine.create(coverage=1))
+ first_modified_file.append(24, ReportLine.create(coverage=1))
+ first_report.append(first_modified_file)
+ second_modified_file = ReportFile("modified.py")
+ second_modified_file.append(18, ReportLine.create(coverage=1))
+ second_modified_file.append(19, ReportLine.create(coverage=0))
+ second_modified_file.append(20, ReportLine.create(coverage=0))
+ second_modified_file.append(21, ReportLine.create(coverage=1))
+ second_modified_file.append(22, ReportLine.create(coverage=0))
+ second_modified_file.append(23, ReportLine.create(coverage=0))
+ second_modified_file.append(24, ReportLine.create(coverage=1))
+ second_report.append(second_modified_file)
+ # RENAMED WITHOUT CHANGES
+ first_renamed_without_changes_file = ReportFile("old_renamed.py")
+ first_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ first_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ first_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ first_report.append(first_renamed_without_changes_file)
+ second_renamed_without_changes_file = ReportFile("renamed.py")
+ second_renamed_without_changes_file.append(1, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(2, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(3, ReportLine.create(coverage=0))
+ second_renamed_without_changes_file.append(4, ReportLine.create(coverage=1))
+ second_renamed_without_changes_file.append(5, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_without_changes_file)
+ # RENAMED WITH COVERAGE CHANGES FILE
+ first_renamed_file = ReportFile("old_renamed_with_changes.py")
+ first_renamed_file.append(2, ReportLine.create(coverage=1))
+ first_renamed_file.append(3, ReportLine.create(coverage=1))
+ first_renamed_file.append(5, ReportLine.create(coverage=0))
+ first_renamed_file.append(8, ReportLine.create(coverage=1))
+ first_renamed_file.append(13, ReportLine.create(coverage=1))
+ first_report.append(first_renamed_file)
+ second_renamed_file = ReportFile("renamed_with_changes.py")
+ second_renamed_file.append(5, ReportLine.create(coverage=1))
+ second_renamed_file.append(8, ReportLine.create(coverage=0))
+ second_renamed_file.append(13, ReportLine.create(coverage=1))
+ second_renamed_file.append(21, ReportLine.create(coverage=1))
+ second_renamed_file.append(34, ReportLine.create(coverage=0))
+ second_report.append(second_renamed_file)
+ # UNRELATED FILE
+ first_unrelated_file = ReportFile("unrelated.py")
+ first_unrelated_file.append(1, ReportLine.create(coverage=1))
+ first_unrelated_file.append(2, ReportLine.create(coverage=1))
+ first_unrelated_file.append(4, ReportLine.create(coverage=1))
+ first_unrelated_file.append(16, ReportLine.create(coverage=0))
+ first_unrelated_file.append(256, ReportLine.create(coverage=1))
+ first_unrelated_file.append(65556, ReportLine.create(coverage=1))
+ first_report.append(first_unrelated_file)
+ second_unrelated_file = ReportFile("unrelated.py")
+ second_unrelated_file.append(2, ReportLine.create(coverage=1))
+ second_unrelated_file.append(4, ReportLine.create(coverage=0))
+ second_unrelated_file.append(8, ReportLine.create(coverage=0))
+ second_unrelated_file.append(16, ReportLine.create(coverage=1))
+ second_unrelated_file.append(32, ReportLine.create(coverage=0))
+ second_report.append(second_unrelated_file)
+ sample_comparison.project_coverage_base.report = ReadOnlyReport.create_from_report(
+ first_report
+ )
+ sample_comparison.head.report = ReadOnlyReport.create_from_report(second_report)
+ return sample_comparison
+
+
+@pytest.fixture
+def mock_repo_provider(mock_repo_provider):
+ compare_result = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["5", "8", "5", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["46", "12", "47", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include`` to your ``.coveragerc``",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["150", "5", "158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. Please contact email our Support at [support@codecov.io](mailto:support@codecov.io)",
+ "-",
+ "+We are happy to help if you have any questions. Please contact email our Support at `support@codecov.io `_.",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ },
+ "commits": [
+ {
+ "commitid": "b92edba44fdd29fcc506317cc3ddeae1a723dd08",
+ "message": "Update README.rst",
+ "timestamp": "2018-07-09T23:51:16Z",
+ "author": {
+ "id": 8398772,
+ "username": "jerrode",
+ "name": "Jerrod",
+ "email": "jerrod@fundersclub.com",
+ },
+ },
+ {
+ "commitid": "6ae5f1795a441884ed2847bb31154814ac01ef38",
+ "message": "Update README.rst",
+ "timestamp": "2018-04-26T08:35:58Z",
+ "author": {
+ "id": 11602092,
+ "username": "TomPed",
+ "name": "Thomas Pedbereznak",
+ "email": "tom@tomped.com",
+ },
+ },
+ ],
+ }
+ mock_repo_provider.get_compare.return_value = compare_result
+ return mock_repo_provider
+
+
+class TestBaseStatusNotifier(object):
+ def test_can_we_set_this_status_no_pull(self, sample_comparison_without_pull):
+ comparison = sample_comparison_without_pull
+ only_pulls_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"only_pulls": True},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ assert not only_pulls_notifier.can_we_set_this_status(comparison)
+ wrong_branch_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"only_pulls": False, "branches": ["old.*"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ assert not wrong_branch_notifier.can_we_set_this_status(comparison)
+ right_branch_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"only_pulls": False, "branches": ["new.*"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ assert right_branch_notifier.can_we_set_this_status(comparison)
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ assert no_settings_notifier.can_we_set_this_status(comparison)
+ exclude_branch_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"only_pulls": False, "branches": ["!new_branch"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ assert not exclude_branch_notifier.can_we_set_this_status(comparison)
+
+ def test_notify_after_n_builds_flags(self, sample_comparison, mocker):
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml(
+ {
+ "coverage": {
+ "status": {"project": True, "patch": True, "changes": True}
+ },
+ "flag_management": {
+ "default_rules": {"carryforward": False},
+ "individual_flags": [
+ {
+ "name": "unit",
+ "statuses": [{"type": "patch"}],
+ "after_n_builds": 3,
+ },
+ ],
+ },
+ }
+ ),
+ repository_service={},
+ )
+ mocker.patch.object(StatusNotifier, "can_we_set_this_status", return_value=True)
+ result = no_settings_notifier.notify(comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "need_more_builds"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ def test_notify_after_n_builds_flags2(self, sample_comparison, mocker):
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml(
+ {
+ "coverage": {
+ "status": {
+ "project": True,
+ "patch": {"default": False, "unit": {"flags": ["unit"]}},
+ "changes": True,
+ }
+ },
+ "flags": {
+ "unit": {
+ "after_n_builds": 3,
+ }
+ },
+ }
+ ),
+ repository_service={},
+ )
+ mocker.patch.object(StatusNotifier, "can_we_set_this_status", return_value=True)
+ result = no_settings_notifier.notify(comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "need_more_builds"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ def test_notify_cannot_set_status(self, sample_comparison, mocker):
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ mocker.patch.object(
+ StatusNotifier, "can_we_set_this_status", return_value=False
+ )
+ result = no_settings_notifier.notify(comparison)
+ assert not result.notification_attempted
+ assert result.notification_successful is None
+ assert result.explanation == "not_fit_criteria"
+ assert result.data_sent is None
+ assert result.data_received is None
+
+ def test_notify_no_base(
+ self, sample_comparison_without_base_with_pull, mocker, mock_repo_provider
+ ):
+ comparison = sample_comparison_without_base_with_pull
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ no_settings_notifier.context = "fake"
+ mocker.patch.object(StatusNotifier, "can_we_set_this_status", return_value=True)
+ mocked_build_payload = mocker.patch.object(
+ StatusNotifier,
+ "build_payload",
+ return_value={"state": "success", "message": "somemessage"},
+ )
+ mocked_send_notification = mocker.patch.object(
+ StatusNotifier, "send_notification"
+ )
+ mocked_send_notification.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": "somemessage",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = no_settings_notifier.notify(comparison)
+ assert result.notification_attempted
+ assert result.notification_successful
+ assert result.explanation is None
+ assert result.data_sent == {
+ "message": "somemessage",
+ "state": "success",
+ "title": "codecov/project/title",
+ }
+ assert result.data_received == {"id": "some_id"}
+
+ def test_notify_uncached(
+ self,
+ sample_comparison,
+ mocker,
+ ):
+ comparison = sample_comparison
+ payload = {
+ "message": "something to say",
+ "state": "success",
+ "url": get_pull_url(comparison.pull),
+ }
+
+ class TestNotifier(StatusNotifier):
+ def build_payload(self, comparison):
+ return payload
+
+ notifier = TestNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+
+ send_notification = mocker.patch.object(TestNotifier, "send_notification")
+ notifier.notify(comparison)
+ send_notification.assert_called_once
+
+ def test_notify_multiple_shas(
+ self,
+ sample_comparison,
+ mocker,
+ ):
+ comparison = sample_comparison
+ comparison.context.gitlab_extra_shas = set(["extra_sha"])
+ payload = {
+ "message": "something to say",
+ "state": "success",
+ "url": get_pull_url(comparison.pull),
+ }
+
+ def set_status_side_effect(commit, *args, **kwargs):
+ return {"id": f"{commit}-status-set"}
+
+ class TestNotifier(StatusNotifier):
+ def build_payload(self, comparison):
+ return payload
+
+ def get_github_app_used(self) -> None:
+ return None
+
+ def status_already_exists(
+ self, comparison: ComparisonProxy, title, state, description
+ ) -> bool:
+ return False
+
+ fake_repo_service = MagicMock(
+ name="fake_repo_provider",
+ set_commit_status=AsyncMock(side_effect=set_status_side_effect),
+ )
+ notifier = TestNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=fake_repo_service,
+ )
+ notifier.context = "fake"
+
+ result = notifier.notify(comparison)
+ assert result == NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": payload["message"],
+ "state": payload["state"],
+ "title": "codecov/fake/title",
+ },
+ data_received={"id": f"{comparison.head.commit.commitid}-status-set"},
+ github_app_used=None,
+ )
+ assert fake_repo_service.set_commit_status.call_count == 2
+
+ def test_notify_cached(
+ self,
+ sample_comparison,
+ mocker,
+ ):
+ comparison = sample_comparison
+
+ payload = {
+ "message": "something to say",
+ "state": "success",
+ "url": get_pull_url(comparison.pull),
+ }
+
+ class TestNotifier(StatusNotifier):
+ def build_payload(self, comparison):
+ return payload
+
+ notifier = TestNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+
+ mocker.patch(
+ "shared.helpers.cache.NullBackend.get",
+ return_value=payload,
+ )
+
+ send_notification = mocker.patch.object(TestNotifier, "send_notification")
+ result = notifier.notify(comparison)
+ assert result == NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="payload_unchanged",
+ data_sent=None,
+ )
+
+ # payload was cached - we do not send the notification
+ assert not send_notification.called
+
+ def test_send_notification(self, sample_comparison, mocker, mock_repo_provider):
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ no_settings_notifier.context = "fake"
+ mocked_status_already_exists = mocker.patch.object(
+ StatusNotifier, "status_already_exists"
+ )
+ mocked_status_already_exists.return_value = False
+ mock_repo_provider.set_commit_status.side_effect = TorngitClientError(
+ 403, "response", "message"
+ )
+ payload = {
+ "message": "something to say",
+ "state": "success",
+ "url": "url",
+ "included_helper_text": "yayaya",
+ }
+ result = no_settings_notifier.send_notification(comparison, payload)
+ assert result.notification_attempted
+ assert not result.notification_successful
+ assert result.explanation == "no_write_permission"
+ expected_data_sent = {
+ "message": "something to say",
+ "state": "success",
+ "title": "codecov/fake/title",
+ "included_helper_text": "yayaya",
+ }
+ assert result.data_sent == expected_data_sent
+ assert result.data_received is None
+
+ def test_notify_analytics(self, sample_comparison, mocker, mock_repo_provider):
+ mocker.patch("helpers.environment.is_enterprise", return_value=False)
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ no_settings_notifier.context = "fake"
+ mocked_status_already_exists = mocker.patch.object(
+ StatusNotifier, "status_already_exists"
+ )
+ mocked_status_already_exists.return_value = False
+ mock_repo_provider.set_commit_status.side_effect = TorngitClientError(
+ 403, "response", "message"
+ )
+ payload = {"message": "something to say", "state": "success", "url": "url"}
+ no_settings_notifier.send_notification(comparison, payload)
+
+ def test_notify_analytics_enterprise(
+ self, sample_comparison, mocker, mock_repo_provider
+ ):
+ mocker.patch("helpers.environment.is_enterprise", return_value=True)
+ comparison = sample_comparison
+ no_settings_notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ no_settings_notifier.context = "fake"
+ mocked_status_already_exists = mocker.patch.object(
+ StatusNotifier, "status_already_exists"
+ )
+ mocked_status_already_exists.return_value = False
+ mock_repo_provider.set_commit_status.side_effect = TorngitClientError(
+ 403, "response", "message"
+ )
+ payload = {"message": "something to say", "state": "success", "url": "url"}
+ no_settings_notifier.send_notification(comparison, payload)
+
+ def test_determine_status_check_behavior_to_apply(self, sample_comparison):
+ # uses component level setting if provided
+ comparison = sample_comparison
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flag_coverage_not_uploaded_behavior": "exclude"},
+ notifier_site_settings=True,
+ current_yaml={
+ "coverage": {
+ "status": {
+ "default_rules": {
+ "flag_coverage_not_uploaded_behavior": "pass"
+ },
+ "project": {
+ "component_check": {
+ "flag_coverage_not_uploaded_behavior": "exclude"
+ }
+ },
+ }
+ }
+ },
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert (
+ notifier.determine_status_check_behavior_to_apply(
+ comparison, "flag_coverage_not_uploaded_behavior"
+ )
+ == "exclude"
+ )
+
+ # uses global setting if no component setting provided
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={
+ "coverage": {
+ "status": {
+ "default_rules": {
+ "flag_coverage_not_uploaded_behavior": "pass"
+ },
+ "project": {"component_check": {}},
+ }
+ }
+ },
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert (
+ notifier.determine_status_check_behavior_to_apply(
+ comparison, "flag_coverage_not_uploaded_behavior"
+ )
+ == "pass"
+ )
+
+ # returns None if nothing set for flag_coverage_not_uploaded_behavior behavior field
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"coverage": {"status": {"default_rules": {}, "project": {}}}},
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert (
+ notifier.determine_status_check_behavior_to_apply(
+ comparison, "flag_coverage_not_uploaded_behavior"
+ )
+ is None
+ )
+
+ def test_flag_coverage_was_uploaded_when_none_uploaded(
+ self, sample_comparison_coverage_carriedforward
+ ):
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": ["missing"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert notifier.flag_coverage_was_uploaded(comparison) is False
+
+ def test_flag_coverage_was_uploaded_when_all_uploaded(
+ self, sample_comparison_coverage_carriedforward
+ ):
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert notifier.flag_coverage_was_uploaded(comparison) is True
+
+ def test_flag_coverage_was_uploaded_when_some_uploaded(
+ self, sample_comparison_coverage_carriedforward
+ ):
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": ["unit", "enterprise", "missing"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert notifier.flag_coverage_was_uploaded(comparison) is True
+
+ def test_flag_coverage_was_uploaded_when_no_status_flags(
+ self, sample_comparison_coverage_carriedforward
+ ):
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": None},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ notifier.context = "fake"
+ assert notifier.flag_coverage_was_uploaded(comparison) is True
+
+ @pytest.mark.parametrize(
+ "fake_torngit_data, expected",
+ [
+ (TorngitInstanceData(), None),
+ (TorngitInstanceData(installation=None), None),
+ (
+ TorngitInstanceData(
+ installation=GithubInstallationInfo(
+ installation_id="owner.integration_id"
+ )
+ ),
+ None,
+ ),
+ (TorngitInstanceData(installation=GithubInstallationInfo(id=12)), 12),
+ ],
+ )
+ def test_get_github_app_used(
+ self, fake_torngit_data, expected, sample_comparison_coverage_carriedforward
+ ):
+ fake_torngit = MagicMock(data=fake_torngit_data, name="fake_torngit")
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": None},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=fake_torngit,
+ )
+ notifier.context = "fake"
+ assert notifier.get_github_app_used() == expected
+
+ def test_get_github_app_used_no_repository_service(
+ self, sample_comparison_coverage_carriedforward
+ ):
+ comparison = sample_comparison_coverage_carriedforward
+ notifier = StatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="component_check",
+ notifier_yaml_settings={"flags": None},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ notifier.context = "fake"
+ assert notifier.get_github_app_used() is None
+
+
+class TestProjectStatusNotifier(object):
+ def test_build_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = {
+ "message": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_passing_empty_upload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.passing_empty_upload,
+ )
+ expected_result = {
+ "state": "success",
+ "message": "Non-testable files changed.",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_failing_empty_upload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.failing_empty_upload,
+ )
+ expected_result = {
+ "state": "failure",
+ "message": "Testable files changed",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = {
+ "message": "Please activate this user to display a detailed status check",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_not_auto(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "57%",
+ "removed_code_behavior": "removals_only",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "60.00% (target 57.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_not_auto_not_string(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"target": 57.0},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "60.00% (target 57.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_no_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = ProjectStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "No report found to compare against",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_notify_status_doesnt_exist(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison)
+ assert expected_result == result
+
+ def test_notify_client_side_exception(
+ self, sample_comparison, mocker, mock_configuration
+ ):
+ mocker.patch.object(
+ ProjectStatusNotifier,
+ "send_notification",
+ side_effect=TorngitRepoNotFoundError("response", "message"),
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ repo = sample_comparison.head.commit.repository
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="client_side_error_provider",
+ data_sent={
+ "message": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "url": f"test.example.br/gh/{repo.slug}/pull/{sample_comparison.pull.pullid}",
+ "included_helper_text": {},
+ },
+ data_received=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert expected_result == result
+
+ def test_notify_server_side_exception(
+ self, sample_comparison, mocker, mock_configuration
+ ):
+ mocker.patch.object(
+ ProjectStatusNotifier,
+ "send_notification",
+ side_effect=TorngitServerUnreachableError(),
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ repo = sample_comparison.head.commit.repository
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation="server_side_error_provider",
+ data_sent={
+ "message": f"60.00% (+10.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "url": f"test.example.br/gh/{repo.slug}/pull/{sample_comparison.pull.pullid}",
+ "included_helper_text": {},
+ },
+ data_received=None,
+ )
+ result = notifier.notify(sample_comparison)
+ assert expected_result.data_sent == result.data_sent
+ assert expected_result == result
+
+ def test_notify_pass_behavior_when_coverage_not_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "pass",
+ "flags": ["integration", "missing"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]} [Auto passed due to carriedforward or missing coverage]",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_pass_behavior_when_coverage_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "pass",
+ "flags": ["unit"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]}", # no message indicating auto-pass
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_include_behavior_when_coverage_not_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "include",
+ "flags": ["integration", "enterprise"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_coverage_not_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": ["missing"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="exclude_flag_coverage_not_uploaded_checks",
+ data_sent=None,
+ data_received=None,
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_coverage_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": ["unit"],
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"25.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_when_some_coverage_uploaded(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": [
+ "unit",
+ "missing",
+ "integration",
+ ], # only "unit" was uploaded, but this should still notify
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"36.17% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_exclude_behavior_no_flags(
+ self, sample_comparison_coverage_carriedforward, mock_repo_provider
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_repo_provider.set_commit_status.return_value = {"id": "some_id"}
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_coverage_carriedforward.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "flag_coverage_not_uploaded_behavior": "exclude",
+ "flags": None,
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = (
+ sample_comparison_coverage_carriedforward.project_coverage_base.commit
+ )
+ # should send the check as normal if there are no flags
+ expected_result = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "message": f"65.38% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "title": "codecov/project/title",
+ },
+ data_received={"id": "some_id"},
+ )
+ result = notifier.notify(sample_comparison_coverage_carriedforward)
+ assert expected_result == result
+
+ def test_notify_path_filter(
+ self, sample_comparison, mock_repo_provider, mock_configuration, mocker
+ ):
+ mocked_send_notification = mocker.patch.object(
+ ProjectStatusNotifier, "send_notification"
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ expected_result = {
+ "message": f"62.50% (+12.50%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "url": f"test.example.br/gh/{sample_comparison.head.commit.repository.slug}/pull/{sample_comparison.pull.pullid}",
+ "included_helper_text": {},
+ }
+ result = notifier.notify(sample_comparison)
+ assert result == mocked_send_notification.return_value
+ mocked_send_notification.assert_called_with(sample_comparison, expected_result)
+
+ def test_notify_path_and_flags_filter_nothing_on_base(
+ self, sample_comparison, mock_repo_provider, mock_configuration, mocker
+ ):
+ mocked_send_notification = mocker.patch.object(
+ ProjectStatusNotifier, "send_notification"
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"], "flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ # base report does not have unit flag, so there is no coverage there
+ "message": "No coverage information found on base report",
+ "state": "success",
+ "url": f"test.example.br/gh/{sample_comparison.head.commit.repository.slug}/pull/{sample_comparison.pull.pullid}",
+ "included_helper_text": {},
+ }
+ result = notifier.notify(sample_comparison)
+ assert result == mocked_send_notification.return_value
+ mocked_send_notification.assert_called_with(sample_comparison, expected_result)
+
+ def test_notify_path_and_flags_filter_something_on_base(
+ self,
+ sample_comparison_matching_flags,
+ mock_repo_provider,
+ mock_configuration,
+ mocker,
+ ):
+ mocked_send_notification = mocker.patch.object(
+ ProjectStatusNotifier, "send_notification"
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison_matching_flags.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"], "flags": ["unit"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison_matching_flags.project_coverage_base.commit
+ expected_result = {
+ # base report does not have unit flag, so there is no coverage there
+ "message": f"100.00% (+0.00%) compared to {base_commit.commitid[:7]}",
+ "state": "success",
+ "url": f"test.example.br/gh/{sample_comparison_matching_flags.head.commit.repository.slug}/pull/{sample_comparison_matching_flags.pull.pullid}",
+ "included_helper_text": {},
+ }
+ result = notifier.notify(sample_comparison_matching_flags)
+ assert result == mocked_send_notification.return_value
+ mocked_send_notification.assert_called_with(
+ sample_comparison_matching_flags, expected_result
+ )
+
+ def test_notify_pass_via_removals_only_behavior(
+ self, mock_configuration, sample_comparison, mocker
+ ):
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "80%",
+ "removed_code_behavior": "removals_only",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": "60.00% (target 80.00%), passed because this change only removed code",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ @pytest.mark.parametrize(
+ "base_totals, head_totals, impacted_files, expected",
+ [
+ pytest.param(
+ ReportTotals(hits=1980, misses=120, partials=0),
+ ReportTotals(
+ hits=1974,
+ misses=120,
+ partials=0,
+ coverage=round((1974 / (1974 + 120)) * 100, 5),
+ ),
+ [
+ {
+ "removed_diff_coverage": [
+ (1, "h"),
+ (2, "h"),
+ (3, "h"),
+ (4, "h"),
+ (5, "h"),
+ (6, "h"),
+ (7, "h"),
+ (8, "h"),
+ (9, "h"),
+ (10, "h"),
+ (11, "h"),
+ (12, "h"),
+ (13, "h"),
+ (14, "h"),
+ (15, "h"),
+ ]
+ }
+ ],
+ (
+ (
+ "success",
+ ", passed because coverage increased by 0.02% when compared to adjusted base (94.24%)",
+ ),
+ {},
+ ),
+ id="many_removed_hits_makes_head_more_covered_than_base",
+ ),
+ pytest.param(
+ ReportTotals(hits=1980, misses=120, partials=0),
+ ReportTotals(
+ hits=1974,
+ misses=120,
+ partials=0,
+ coverage=round((1974 / (1974 + 120)) * 100, 5),
+ ),
+ [
+ {
+ "removed_diff_coverage": [
+ (1, "h"),
+ (2, "h"),
+ (3, "h"),
+ (4, "h"),
+ (5, "h"),
+ (6, "h"),
+ ]
+ }
+ ],
+ (
+ (
+ "success",
+ ", passed because coverage increased by 0% when compared to adjusted base (94.27%)",
+ ),
+ {},
+ ),
+ id="many_removed_hits_makes_head_same_as_base",
+ ),
+ pytest.param(
+ ReportTotals(hits=1980, misses=120, partials=0),
+ ReportTotals(
+ hits=1974,
+ misses=120,
+ partials=0,
+ coverage=round((1974 / (1974 + 120)) * 100, 5),
+ ),
+ [
+ {
+ "removed_diff_coverage": [
+ (1, "h"),
+ (2, "h"),
+ (3, "h"),
+ ]
+ }
+ ],
+ (
+ None,
+ {
+ HelperTextKey.RCB_ADJUST_BASE.value: HELPER_TEXT_MAP[
+ HelperTextKey.RCB_ADJUST_BASE
+ ].value.format(
+ notification_type="status",
+ coverage=94.27,
+ adjusted_base_cov=94.28,
+ ),
+ },
+ ),
+ id="not_enough_hits_removed_for_status_to_pass",
+ ),
+ pytest.param(
+ ReportTotals(hits=0, misses=0, partials=0),
+ ReportTotals(hits=0, misses=0, partials=0, coverage="100"),
+ [],
+ (None, {}),
+ id="zero_coverage",
+ ),
+ ],
+ )
+ def test_adjust_base_behavior(
+ self, mocker, base_totals, head_totals, impacted_files, expected
+ ):
+ comparison = mocker.MagicMock(
+ name="fake-comparison",
+ get_impacted_files=MagicMock(return_value={"files": impacted_files}),
+ project_coverage_base=FullCommit(
+ commit=None, report=Report(totals=base_totals)
+ ),
+ head=FullCommit(commit=CommitFactory(), report=Report(totals=head_totals)),
+ )
+ settings = {"target": "auto", "threshold": "0"}
+ status_mixin = ProjectStatusNotifier(
+ repository="repo",
+ title="fake-notifier",
+ notifier_yaml_settings=settings,
+ notifier_site_settings={},
+ current_yaml=settings,
+ repository_service={},
+ )
+ result = status_mixin._apply_adjust_base_behavior(
+ comparison, notification_type="status"
+ )
+ assert result == expected
+
+ def test_notify_pass_adjust_base_behavior(
+ self, mock_configuration, sample_comparison_negative_change, mocker
+ ):
+ sample_comparison = sample_comparison_negative_change
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"], [3, "h"], [4, "m"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"removed_code_behavior": "adjust_base"},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": f"50.00% (-10.00%) compared to {sample_comparison.project_coverage_base.commit.commitid[:7]}, passed because coverage increased by 0% when compared to adjusted base (50.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_pass_adjust_base_behavior_with_threshold(
+ self, mock_configuration, sample_comparison_negative_change, mocker
+ ):
+ sample_comparison = sample_comparison_negative_change
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"], [3, "h"], [4, "m"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "removed_code_behavior": "adjust_base",
+ "threshold": "5",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": f"50.00% (-10.00%) compared to {sample_comparison.project_coverage_base.commit.commitid[:7]}, passed because coverage increased by 5.00% when compared to adjusted base (45.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_removed_code_behavior_fail(
+ self, mock_configuration, sample_comparison, mocker
+ ):
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [[2, "h"], [3, "h"]],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ repository_service=None,
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "80%",
+ "removed_code_behavior": "removals_only",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=None,
+ )
+ expected_result = {
+ "message": "60.00% (target 80.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="project",
+ notification_type="status",
+ point_of_comparison="head",
+ coverage="60.00",
+ target="80.00",
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_adjust_base_behavior_fail(
+ self, mock_configuration, sample_comparison_negative_change, mocker
+ ):
+ sample_comparison = sample_comparison_negative_change
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"], [3, "m"], [4, "m"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"removed_code_behavior": "adjust_base"},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ # included helper text for this user because they have adjust_base in their yaml
+ expected_result = {
+ "message": f"50.00% (-10.00%) compared to {sample_comparison.project_coverage_base.commit.commitid[:7]}",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.RCB_ADJUST_BASE.value: HELPER_TEXT_MAP[
+ HelperTextKey.RCB_ADJUST_BASE
+ ].value.format(
+ notification_type="status",
+ coverage="50.00",
+ adjusted_base_cov=71.43,
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_rcb_default(
+ self, mock_configuration, sample_comparison_negative_change, mocker
+ ):
+ sample_comparison = sample_comparison_negative_change
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"], [3, "m"], [4, "m"]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ # NO helper text for this user because they have NOT specified adjust_base in their yaml
+ expected_result = {
+ "message": f"50.00% (-10.00%) compared to {sample_comparison.project_coverage_base.commit.commitid[:7]}",
+ "state": "failure",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_adjust_base_behavior_skips_if_target_coverage_defined(
+ self, mock_configuration, sample_comparison_negative_change, mocker
+ ):
+ sample_comparison = sample_comparison_negative_change
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy, "get_impacted_files"
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "removed_code_behavior": "adjust_base",
+ "target": "80%",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": "50.00% (target 80.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="project",
+ notification_type="status",
+ point_of_comparison="head",
+ coverage="50.00",
+ target="80.00",
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_not_called()
+
+ def test_notify_removed_code_behavior_unknown(
+ self, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "80%",
+ "removed_code_behavior": "not_valid",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": "60.00% (target 80.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="project",
+ notification_type="status",
+ point_of_comparison="head",
+ coverage="60.00",
+ target="80.00",
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+
+ def test_notify_fully_covered_patch_behavior_fail(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ mocker,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [[2, "h"], [3, "h"]],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ notifier = ProjectStatusNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "removed_code_behavior": "fully_covered_patch",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "50.00% (target 70.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="project",
+ notification_type="status",
+ point_of_comparison="head",
+ coverage="50.00",
+ target="70.00",
+ )
+ },
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_fully_covered_patch_behavior_fail_indirect_changes(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ mocker,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [[2, "h"], [3, "h"]],
+ "unexpected_line_changes": "any value in this field",
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ notifier = ProjectStatusNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "removed_code_behavior": "fully_covered_patch",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "50.00% (target 70.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT.value: HelperTextTemplate.CUSTOM_TARGET.value.format(
+ context="project",
+ notification_type="status",
+ point_of_comparison="head",
+ coverage="50.00",
+ target="70.00",
+ ),
+ HelperTextKey.RCB_INDIRECT_CHANGES.value: HELPER_TEXT_MAP[
+ HelperTextKey.RCB_INDIRECT_CHANGES
+ ].value.format(
+ context="project",
+ notification_type="status",
+ ),
+ },
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_fully_covered_patch_behavior_success(
+ self,
+ comparison_100_percent_patch,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ mocker,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [[2, "h"], [3, "h"]],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ notifier = ProjectStatusNotifier(
+ repository=comparison_100_percent_patch.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "removed_code_behavior": "fully_covered_patch",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "28.57% (target 70.00%), passed because patch was fully covered by tests, and no indirect coverage changes",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison_100_percent_patch)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+ def test_notify_fully_covered_patch_behavior_no_coverage_change(
+ self, mock_configuration, sample_comparison, mocker
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ mock_get_impacted_files = mocker.patch.object(
+ ComparisonProxy,
+ "get_impacted_files",
+ return_value={
+ "files": [
+ {
+ "base_name": "tests/file1.py",
+ "head_name": "tests/file1.py",
+ # Not complete, but we only care about these fields
+ "removed_diff_coverage": [[1, "h"]],
+ "added_diff_coverage": [[2, "h"], [3, "h"]],
+ "unexpected_line_changes": [],
+ },
+ {
+ "base_name": "tests/file2.go",
+ "head_name": "tests/file2.go",
+ "removed_diff_coverage": [[1, "h"], [3, None]],
+ "added_diff_coverage": [],
+ "unexpected_line_changes": [],
+ },
+ ],
+ },
+ )
+ mocker.patch.object(
+ sample_comparison,
+ "get_diff",
+ return_value={
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["105", "8", "105", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["1046", "12", "1047", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc``",
+ "+We highly suggest adding ``source`` to your ``.coveragerc`",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ {
+ "header": ["10150", "5", "10158", "4"],
+ "lines": [
+ " * Twitter: `@codecov `_.",
+ " * Email: `hello@codecov.io `_.",
+ " ",
+ "-We are happy to help if you have any questions. ",
+ "-",
+ "+We are happy to help if you have any questions. .",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ },
+ "file_2.py": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["10", "8", "10", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ },
+ {
+ "header": ["50", "12", "51", "19"],
+ "lines": [
+ " ",
+ " You may need to configure a ``.coveragerc`` file. Learn more `here `_. Start with this `generic .coveragerc `_ for example.",
+ " ",
+ "-We highly suggest adding `source` to your ``.coveragerc`` which solves a number of issues collecting coverage.",
+ "+We highly suggest adding ``source`` to your ``.coveragerc``, which solves a number of issues collecting coverage.",
+ " ",
+ " .. code-block:: ini",
+ " ",
+ " [run]",
+ " source=your_package_name",
+ "+ ",
+ "+If there are multiple sources, you instead should add ``include`` to your ``.coveragerc``",
+ "+",
+ "+.. code-block:: ini",
+ "+",
+ "+ [run]",
+ "+ include=your_package_name/*",
+ " ",
+ " unittests",
+ " ---------",
+ ],
+ },
+ ],
+ "stats": {"added": 11, "removed": 4},
+ },
+ }
+ },
+ )
+ notifier = ProjectStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "removed_code_behavior": "fully_covered_patch",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service={},
+ )
+ expected_result = {
+ "message": "60.00% (target 70.00%), passed because coverage was not affected by patch",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert result == expected_result
+ mock_get_impacted_files.assert_called()
+
+
+class TestPatchStatusNotifier(object):
+ def test_build_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (target 50.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ expected_result = {
+ "message": "Please activate this user to display a detailed status check",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_target_coverage_failure(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"target": "70%"},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (target 70.00%)",
+ "state": "failure",
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PATCH: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="patch",
+ notification_type="status",
+ point_of_comparison="patch",
+ coverage=66.67,
+ target="70.00",
+ )
+ },
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_not_auto_not_string(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"target": 57.0},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (target 57.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_target_coverage_failure_within_threshold(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ third_file = ReportFile("file_3.c")
+ third_file.append(100, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(101, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(102, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(103, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report = sample_comparison.project_coverage_base.report.inner_report
+ report.append(third_file)
+ sample_comparison.project_coverage_base.report = (
+ ReadOnlyReport.create_from_report(report)
+ )
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"threshold": "5"},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (within 5.00% threshold of 70.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_get_patch_status_bad_threshold(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ third_file = ReportFile("file_3.c")
+ third_file.append(100, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(101, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(102, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(103, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report = sample_comparison.project_coverage_base.report.inner_report
+ report.append(third_file)
+ sample_comparison.project_coverage_base.report = (
+ ReadOnlyReport.create_from_report(report)
+ )
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"threshold": None}, # invalid value for threshold
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (target 70.00%)",
+ "state": "failure",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_get_patch_status_bad_threshold_fixed(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ third_file = ReportFile("file_3.c")
+ third_file.append(100, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(101, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(102, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ third_file.append(103, ReportLine.create(coverage=1, sessions=[[0, 1]]))
+ report = sample_comparison.project_coverage_base.report.inner_report
+ report.append(third_file)
+ sample_comparison.project_coverage_base.report = (
+ ReadOnlyReport.create_from_report(report)
+ )
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "threshold": "5%"
+ }, # invalid value for threshold, caught and fixed
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "66.67% of diff hit (within 5.00% threshold of 70.00%)",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_no_diff(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_compare.return_value = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["15", "8", "15", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ }
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ }
+ }
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ expected_result = {
+ "message": f"Coverage not affected when comparing {base_commit.commitid[:7]}...{head_commit.commitid[:7]}",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_no_diff_no_base_report(
+ self,
+ sample_comparison_without_base_with_pull,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_repo_provider.get_compare.return_value = {
+ "diff": {
+ "files": {
+ "file_1.go": {
+ "type": "modified",
+ "before": None,
+ "segments": [
+ {
+ "header": ["15", "8", "15", "9"],
+ "lines": [
+ " Overview",
+ " --------",
+ " ",
+ "-Main website: `Codecov `_.",
+ "-Main website: `Codecov `_.",
+ "+",
+ "+website: `Codecov `_.",
+ "+website: `Codecov `_.",
+ " ",
+ " .. code-block:: shell-session",
+ " ",
+ ],
+ }
+ ],
+ "stats": {"added": 11, "removed": 4},
+ }
+ }
+ }
+ }
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_with_pull
+ notifier = PatchStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "Coverage not affected",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_build_payload_without_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = PatchStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "No report found to compare against",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_build_payload_with_multiple_changes(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = PatchStatusNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "50.00% of diff hit (target 76.92%)",
+ "state": "failure",
+ "included_helper_text": {}, # not a custom target, no helper text
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert expected_result == result
+
+
+class TestChangesStatusNotifier(object):
+ def test_build_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "No indirect coverage changes found",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_upgrade_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.upgrade,
+ )
+ expected_result = {
+ "message": "Please activate this user to display a detailed status check",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
+
+ def test_build_payload_with_multiple_changes(
+ self,
+ comparison_with_multiple_changes,
+ mock_repo_provider,
+ mock_configuration,
+ multiple_diff_changes,
+ ):
+ json_diff = multiple_diff_changes
+ mock_repo_provider.get_compare.return_value = {"diff": json_diff}
+
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesStatusNotifier(
+ repository=comparison_with_multiple_changes.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "3 files have indirect coverage changes not visible in diff",
+ "state": "failure",
+ "included_helper_text": {
+ "indirect_changes_helper_text": (
+ "Your changes status has failed because you have indirect coverage changes. "
+ "Learn more about [Unexpected Coverage Changes](https://docs.codecov.com/docs/unexpected-coverage-changes) "
+ "and [reasons for indirect coverage changes](https://docs.codecov.com/docs/unexpected-coverage-changes#reasons-for-indirect-changes)."
+ )
+ },
+ }
+ result = notifier.build_payload(comparison_with_multiple_changes)
+ assert expected_result == result
+
+ def test_build_payload_without_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_repo_provider,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ notifier = ChangesStatusNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "Unable to determine changes, no report found at pull request base",
+ "state": "success",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(comparison)
+ assert expected_result == result
+
+ def test_notify_path_filter(
+ self, sample_comparison, mock_repo_provider, mock_configuration, mocker
+ ):
+ mocked_send_notification = mocker.patch.object(
+ ChangesStatusNotifier, "send_notification"
+ )
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ notifier = ChangesStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"paths": ["file_1.go"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ expected_result = {
+ "message": "No indirect coverage changes found",
+ "state": "success",
+ "url": f"test.example.br/gh/{sample_comparison.head.commit.repository.slug}/pull/{sample_comparison.pull.pullid}",
+ "included_helper_text": {},
+ }
+ result = notifier.notify(sample_comparison)
+ assert result == mocked_send_notification.return_value
+ mocked_send_notification.assert_called_with(sample_comparison, expected_result)
+
+ def test_build_passing_empty_upload_payload(
+ self, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_url"] = "test.example.br"
+ notifier = ChangesStatusNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ decoration_type=Decoration.passing_empty_upload,
+ )
+ expected_result = {
+ "state": "success",
+ "message": "Non-testable files changed.",
+ "included_helper_text": {},
+ }
+ result = notifier.build_payload(sample_comparison)
+ assert expected_result == result
diff --git a/apps/worker/services/notification/notifiers/tests/unit/test_webhook.py b/apps/worker/services/notification/notifiers/tests/unit/test_webhook.py
new file mode 100644
index 0000000000..2dca5d7f9d
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/tests/unit/test_webhook.py
@@ -0,0 +1,738 @@
+from decimal import Decimal
+
+from database.tests.factories import CommitFactory, RepositoryFactory
+from services.comparison.types import FullCommit
+from services.notification.notifiers.webhook import WebhookNotifier
+from services.repository import get_repo_provider_service
+
+
+class TestWebhookNotifier(object):
+ def test_build_commit_payload(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ repository = RepositoryFactory.create(
+ owner__username="TestWebhookNotifier", name="test_build_payload"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ pull = sample_comparison.pull
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ notifier = WebhookNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ sample_comparison.head.commit.repository
+ ),
+ )
+ repository = base_commit.repository
+ comparison = sample_comparison
+ result = notifier.build_commit_payload(comparison.head)
+ expected_result = {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ }
+ assert result["totals"] == expected_result["totals"]
+ assert result == expected_result
+
+ def test_build_commit_payload_gitlab(
+ self, dbsession, mock_configuration, create_sample_comparison
+ ):
+ subgroup_namespace_path = "group/subgroup1/subsubgroup"
+ username_in_db = subgroup_namespace_path.replace("/", ":")
+ sample_comparison = create_sample_comparison(
+ username=username_in_db, service="gitlab"
+ )
+
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "codecov.io"
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ pull = sample_comparison.pull
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ notifier = WebhookNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ sample_comparison.head.commit.repository
+ ),
+ )
+ repository = base_commit.repository
+ comparison = sample_comparison
+ result = notifier.build_commit_payload(comparison.head)
+ expected_result = {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"codecov.io/gl/{username_in_db}/{repository.name}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": 0,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://gitlab.com/{subgroup_namespace_path}/{repository.name}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ }
+ assert result == expected_result
+
+ def test_build_commit_payload_no_author(
+ self, dbsession, mock_configuration, sample_report
+ ):
+ repository = RepositoryFactory.create(
+ owner__username="test_build_commit_payload_no_author",
+ owner__service="github",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ head_commit = CommitFactory.create(
+ repository=repository, branch="new_branch", author=None
+ )
+ head_full_commit = FullCommit(commit=head_commit, report=sample_report)
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ dbsession.add(head_commit)
+ dbsession.flush()
+ notifier = WebhookNotifier(
+ repository=repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(repository),
+ )
+ result = notifier.build_commit_payload(head_full_commit)
+ expected_result = {
+ "author": None,
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": 0,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ }
+ assert result == expected_result
+
+ def test_build_payload(self, dbsession, mock_configuration, sample_comparison):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ repository = RepositoryFactory.create(
+ owner__username="TestWebhookNotifier", name="test_build_payload"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ pull = sample_comparison.pull
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ notifier = WebhookNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ sample_comparison.head.commit.repository
+ ),
+ )
+ repository = base_commit.repository
+ comparison = sample_comparison
+ result = notifier.build_payload(comparison)
+ expected_result = {
+ "repo": {
+ "url": f"test.example.br/gh/{repository.slug}",
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": True,
+ },
+ "head": {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ },
+ "base": {
+ "author": {
+ "username": base_commit.author.username,
+ "service_id": base_commit.author.service_id,
+ "email": base_commit.author.email,
+ "service": base_commit.author.service,
+ "name": base_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{base_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 6,
+ "hits": 3,
+ "misses": 3,
+ "partials": 0,
+ "coverage": "50.00000",
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 11,
+ "complexity_total": 20,
+ "diff": None,
+ },
+ "commitid": base_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{base_commit.commitid}",
+ "branch": None,
+ "message": base_commit.message,
+ },
+ "compare": {
+ "url": f"test.example.br/gh/{repository.slug}/pull/{pull.pullid}",
+ "message": "increased",
+ "coverage": Decimal("10.00"),
+ "notation": "+",
+ },
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": "github",
+ },
+ "pull": {
+ "head": {"commit": head_commit.commitid, "branch": "master"},
+ "number": str(pull.pullid),
+ "base": {"commit": base_commit.commitid, "branch": "master"},
+ "open": True,
+ "id": pull.pullid,
+ "merged": False,
+ },
+ }
+ assert result["repo"] == expected_result["repo"]
+ assert result["head"]["totals"] == expected_result["head"]["totals"]
+ assert result["head"] == expected_result["head"]
+ assert result["base"]["totals"] == expected_result["base"]["totals"]
+ assert result["base"] == expected_result["base"]
+ assert result["compare"] == expected_result["compare"]
+ assert result["owner"] == expected_result["owner"]
+ assert result["pull"] == expected_result["pull"]
+ assert result == expected_result
+
+ def test_build_payload_higher_precision(
+ self, dbsession, mock_configuration, sample_comparison
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ repository = RepositoryFactory.create(
+ owner__username="TestWebhookNotifier", name="test_build_payload"
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = sample_comparison.project_coverage_base.commit
+ head_commit = sample_comparison.head.commit
+ pull = sample_comparison.pull
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ notifier = WebhookNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={"coverage": {"precision": 5, "round": "up"}},
+ repository_service=get_repo_provider_service(
+ sample_comparison.head.commit.repository
+ ),
+ )
+ repository = base_commit.repository
+ comparison = sample_comparison
+ result = notifier.build_payload(comparison)
+ expected_result = {
+ "repo": {
+ "url": f"test.example.br/gh/{repository.slug}",
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": True,
+ },
+ "head": {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ },
+ "base": {
+ "author": {
+ "username": base_commit.author.username,
+ "service_id": base_commit.author.service_id,
+ "email": base_commit.author.email,
+ "service": base_commit.author.service,
+ "name": base_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{base_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 6,
+ "hits": 3,
+ "misses": 3,
+ "partials": 0,
+ "coverage": "50.00000",
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 11,
+ "complexity_total": 20,
+ "diff": None,
+ },
+ "commitid": base_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{base_commit.commitid}",
+ "branch": None,
+ "message": base_commit.message,
+ },
+ "compare": {
+ "url": f"test.example.br/gh/{repository.slug}/pull/{pull.pullid}",
+ "message": "increased",
+ "coverage": Decimal("10.00"),
+ "notation": "+",
+ },
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": "github",
+ },
+ "pull": {
+ "head": {"commit": head_commit.commitid, "branch": "master"},
+ "number": str(pull.pullid),
+ "base": {"commit": base_commit.commitid, "branch": "master"},
+ "open": True,
+ "id": pull.pullid,
+ "merged": False,
+ },
+ }
+
+ assert result["repo"] == expected_result["repo"]
+ assert result["head"] == expected_result["head"]
+ assert result["base"]["totals"] == expected_result["base"]["totals"]
+ assert result["base"] == expected_result["base"]
+ assert result["compare"] == expected_result["compare"]
+ assert result["owner"] == expected_result["owner"]
+ assert result["pull"] == expected_result["pull"]
+ assert result == expected_result
+
+ def test_build_payload_without_pull(
+ self, sample_comparison_without_pull, mock_configuration
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_pull
+ commit = sample_comparison_without_pull.head.commit
+ base_commit = comparison.project_coverage_base.commit
+ head_commit = comparison.head.commit
+ repository = commit.repository
+ notifier = WebhookNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ comparison.head.commit.repository
+ ),
+ )
+ result = notifier.build_payload(comparison)
+ expected_result = {
+ "repo": {
+ "url": f"test.example.br/gh/{repository.slug}",
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": True,
+ },
+ "head": {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ },
+ "base": {
+ "author": {
+ "username": base_commit.author.username,
+ "service_id": base_commit.author.service_id,
+ "email": base_commit.author.email,
+ "service": base_commit.author.service,
+ "name": base_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{base_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 6,
+ "hits": 3,
+ "misses": 3,
+ "partials": 0,
+ "coverage": "50.00000",
+ "branches": 0,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 11,
+ "complexity_total": 20,
+ "diff": None,
+ },
+ "commitid": base_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{base_commit.commitid}",
+ "branch": None,
+ "message": base_commit.message,
+ },
+ "compare": {
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "message": "increased",
+ "coverage": Decimal("10.00"),
+ "notation": "+",
+ },
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": "github",
+ },
+ "pull": None,
+ }
+
+ assert result["repo"] == expected_result["repo"]
+ assert result["head"] == expected_result["head"]
+ assert result["base"]["totals"] == expected_result["base"]["totals"]
+ assert result["base"] == expected_result["base"]
+ assert result["compare"] == expected_result["compare"]
+ assert result["owner"] == expected_result["owner"]
+ assert result["pull"] == expected_result["pull"]
+ assert result == expected_result
+
+ def test_build_payload_without_base_report(
+ self,
+ sample_comparison_without_base_report,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_report
+ commit = comparison.head.commit
+ repository = commit.repository
+ notifier = WebhookNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ comparison.head.commit.repository
+ ),
+ )
+ result = notifier.build_payload(comparison)
+ head_commit = comparison.head.commit
+ base_commit = comparison.project_coverage_base.commit
+ pull = comparison.pull
+ expected_result = {
+ "repo": {
+ "url": f"test.example.br/gh/{repository.slug}",
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": True,
+ },
+ "head": {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ },
+ "base": {
+ "author": {
+ "username": base_commit.author.username,
+ "service_id": base_commit.author.service_id,
+ "email": base_commit.author.email,
+ "service": base_commit.author.service,
+ "name": base_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{base_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": None,
+ "commitid": base_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{base_commit.commitid}",
+ "branch": None,
+ "message": base_commit.message,
+ },
+ "compare": {
+ "url": None,
+ "message": "unknown",
+ "coverage": None,
+ "notation": "",
+ },
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": "github",
+ },
+ "pull": {
+ "head": {"commit": head_commit.commitid, "branch": "master"},
+ "number": str(pull.pullid),
+ "base": {"commit": base_commit.commitid, "branch": "master"},
+ "open": True,
+ "id": pull.pullid,
+ "merged": False,
+ },
+ }
+
+ assert result["repo"] == expected_result["repo"]
+ assert result["head"] == expected_result["head"]
+ assert result["base"]["totals"] == expected_result["base"]["totals"]
+ assert result["base"] == expected_result["base"]
+ assert result["compare"] == expected_result["compare"]
+ assert result["owner"] == expected_result["owner"]
+ assert result["pull"] == expected_result["pull"]
+ assert result == expected_result
+
+ def test_build_payload_without_base(
+ self,
+ sample_comparison_without_base_with_pull,
+ mock_configuration,
+ ):
+ mock_configuration.params["setup"]["codecov_dashboard_url"] = "test.example.br"
+ comparison = sample_comparison_without_base_with_pull
+ commit = comparison.head.commit
+ repository = commit.repository
+ notifier = WebhookNotifier(
+ repository=comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=get_repo_provider_service(
+ comparison.head.commit.repository
+ ),
+ )
+ result = notifier.build_payload(comparison)
+ head_commit = comparison.head.commit
+ pull = comparison.pull
+ expected_result = {
+ "repo": {
+ "url": f"test.example.br/gh/{repository.slug}",
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": True,
+ },
+ "head": {
+ "author": {
+ "username": head_commit.author.username,
+ "service_id": head_commit.author.service_id,
+ "email": head_commit.author.email,
+ "service": head_commit.author.service,
+ "name": head_commit.author.name,
+ },
+ "url": f"test.example.br/gh/{repository.slug}/commit/{head_commit.commitid}",
+ "timestamp": "2019-02-01T17:59:47+00:00",
+ "totals": {
+ "files": 2,
+ "lines": 10,
+ "hits": 6,
+ "misses": 3,
+ "partials": 1,
+ "coverage": "60.00000",
+ "branches": 1,
+ "methods": 0,
+ "messages": 0,
+ "sessions": 1,
+ "complexity": 10,
+ "complexity_total": 2,
+ "diff": None,
+ },
+ "commitid": head_commit.commitid,
+ "service_url": f"https://github.com/{repository.slug}/commit/{head_commit.commitid}",
+ "branch": "new_branch",
+ "message": head_commit.message,
+ },
+ "base": None,
+ "compare": {
+ "url": None,
+ "message": "unknown",
+ "coverage": None,
+ "notation": "",
+ },
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": "github",
+ },
+ "pull": {
+ "head": {"commit": head_commit.commitid, "branch": "master"},
+ "number": str(pull.pullid),
+ "base": {"commit": "base_commitid", "branch": "master"},
+ "open": True,
+ "id": pull.pullid,
+ "merged": False,
+ },
+ }
+
+ assert result["repo"] == expected_result["repo"]
+ assert result["head"] == expected_result["head"]
+ assert result["base"] == expected_result["base"]
+ assert result["compare"] == expected_result["compare"]
+ assert result["owner"] == expected_result["owner"]
+ assert result["pull"] == expected_result["pull"]
+ assert result == expected_result
diff --git a/apps/worker/services/notification/notifiers/webhook.py b/apps/worker/services/notification/notifiers/webhook.py
new file mode 100644
index 0000000000..ecf5645b61
--- /dev/null
+++ b/apps/worker/services/notification/notifiers/webhook.py
@@ -0,0 +1,78 @@
+import logging
+
+from shared.torngit.enums import Endpoints
+
+from database.enums import Notification
+from services.comparison.types import Comparison, FullCommit
+from services.notification.notifiers.generics import RequestsYamlBasedNotifier
+from services.urls import get_commit_url, get_repository_url
+
+log = logging.getLogger(__name__)
+
+
+class WebhookNotifier(RequestsYamlBasedNotifier):
+ @property
+ def notification_type(self) -> Notification:
+ return Notification.webhook
+
+ def build_commit_payload(self, full_commit: FullCommit):
+ if full_commit.commit is None:
+ return None
+ commit = full_commit.commit
+ author_dict = None
+ if commit.author is not None:
+ author_dict = {
+ "username": commit.author.username,
+ "service_id": commit.author.service_id,
+ "email": commit.author.email,
+ "service": commit.author.service,
+ "name": commit.author.name,
+ }
+ return {
+ "author": author_dict,
+ "url": get_commit_url(commit),
+ "timestamp": commit.timestamp.isoformat(),
+ "totals": full_commit.report.totals.asdict()
+ if full_commit.report is not None
+ else None,
+ "commitid": commit.commitid,
+ "service_url": self.repository_service.get_href(
+ Endpoints.commit_detail, commitid=commit.commitid
+ ),
+ "branch": commit.branch,
+ "message": commit.message,
+ }
+
+ def build_payload(self, comparison: Comparison) -> dict:
+ head_full_commit = comparison.head
+ base_full_commit = comparison.project_coverage_base
+ pull = comparison.pull
+ head_commit = head_full_commit.commit
+ repository = head_commit.repository
+ pull_dict = None
+ if pull:
+ pull_dict = {
+ "head": {"commit": pull.head, "branch": "master"},
+ "number": str(pull.pullid),
+ "base": {"commit": pull.base, "branch": "master"},
+ "open": pull.state == "open",
+ "id": pull.pullid,
+ "merged": pull.state == "merged",
+ }
+ return {
+ "repo": {
+ "url": get_repository_url(head_commit.repository),
+ "service_id": repository.service_id,
+ "name": repository.name,
+ "private": repository.private,
+ },
+ "head": self.build_commit_payload(head_full_commit),
+ "base": self.build_commit_payload(base_full_commit),
+ "compare": self.generate_compare_dict(comparison),
+ "owner": {
+ "username": repository.owner.username,
+ "service_id": repository.owner.service_id,
+ "service": repository.owner.service,
+ },
+ "pull": pull_dict,
+ }
diff --git a/apps/worker/services/notification/tests/unit/test_commit_notifications.py b/apps/worker/services/notification/tests/unit/test_commit_notifications.py
new file mode 100644
index 0000000000..9f728e4d7e
--- /dev/null
+++ b/apps/worker/services/notification/tests/unit/test_commit_notifications.py
@@ -0,0 +1,219 @@
+import pytest
+
+from database.enums import Decoration, Notification, NotificationState
+from database.models.core import GithubAppInstallation
+from database.tests.factories import (
+ CommitFactory,
+ CommitNotificationFactory,
+ PullFactory,
+ RepositoryFactory,
+)
+from services.comparison.types import Comparison, FullCommit
+from services.notification.commit_notifications import (
+ create_or_update_commit_notification_from_notification_result,
+)
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.comment import CommentNotifier
+from services.repository import EnrichedPull
+
+
+@pytest.fixture
+def comparison(dbsession):
+ repository = RepositoryFactory.create(
+ owner__username="codecov",
+ owner__unencrypted_oauth_token="testtlxuu2kfef3km1fbecdlmnb2nvpikvmoadi3",
+ owner__plan="users-pr-inappm",
+ name="example-python",
+ image_token="abcdefghij",
+ private=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository)
+ pull = PullFactory.create(
+ repository=repository,
+ base=base_commit.commitid,
+ head=head_commit.commitid,
+ state="merged",
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ return Comparison(
+ head=FullCommit(commit=head_commit, report=None),
+ project_coverage_base=FullCommit(commit=base_commit, report=None),
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(database_pull=pull, provider_pull=None),
+ )
+
+
+class TestCommitNotificationsServiceTestCase(object):
+ def test_create_or_update_commit_notification_not_yet_exists(
+ self, dbsession, comparison
+ ):
+ pull = comparison.pull
+ commit = pull.get_head_commit()
+ notifier = CommentNotifier(
+ repository=pull.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.standard,
+ )
+ notify_res = NotificationResult(
+ notification_attempted=True,
+ notification_successful=False,
+ explanation=None,
+ data_received=dict(id=123),
+ data_sent=dict(a=1, b=2),
+ )
+
+ res = create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, notify_res
+ )
+ dbsession.flush()
+ assert res.commit_id == commit.id_
+ assert res.decoration_type == notifier.decoration_type
+ assert res.notification_type == notifier.notification_type
+ assert res.state == NotificationState.error
+
+ def test_create_or_update_commit_notification_not_yet_exists_no_pull_but_ghapp_info(
+ self, dbsession, comparison
+ ):
+ comparison.enriched_pull = None
+ commit = comparison.head.commit
+ app = GithubAppInstallation(owner=commit.repository.owner, installation_id=1234)
+ dbsession.add(app)
+ dbsession.flush()
+ notifier = CommentNotifier(
+ repository=commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.standard,
+ )
+ notify_res = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_received=dict(id=123),
+ data_sent=dict(a=1, b=2),
+ github_app_used=app.id,
+ )
+
+ res = create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, notify_res
+ )
+ dbsession.flush()
+ assert res.commit_id == commit.id_
+ assert res.decoration_type == notifier.decoration_type
+ assert res.notification_type == notifier.notification_type
+ assert res.state == NotificationState.success
+ assert res.gh_app_id == app.id
+
+ def test_create_or_update_commit_notification_no_result(
+ self, dbsession, comparison
+ ):
+ commit = comparison.head.commit
+ notifier = CommentNotifier(
+ repository=commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.standard,
+ )
+ result_dict = None
+ res = create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, result_dict
+ )
+ dbsession.flush()
+ assert res.commit_id == commit.id_
+ assert res.decoration_type == notifier.decoration_type
+ assert res.notification_type == notifier.notification_type
+ assert res.state == NotificationState.error
+
+ def test_create_or_update_commit_notification_decoration_change(
+ self, dbsession, comparison
+ ):
+ head_commit = comparison.head.commit
+
+ cn = CommitNotificationFactory(
+ commit=head_commit,
+ notification_type=Notification.comment,
+ decoration_type=Decoration.upgrade,
+ state=NotificationState.success,
+ )
+ dbsession.add(cn)
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=head_commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.standard,
+ )
+ notify_res = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_received=dict(id=123),
+ data_sent=dict(a=1, b=2),
+ )
+ res = create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, notify_res
+ )
+ dbsession.flush()
+ assert cn.commit_id == head_commit.id_
+ assert cn.decoration_type == notifier.decoration_type
+ assert cn.notification_type == notifier.notification_type
+ assert cn.state == NotificationState.success
+
+ def test_create_or_update_commit_notification_now_successful(
+ self, dbsession, comparison
+ ):
+ head_commit = comparison.head.commit
+
+ cn = CommitNotificationFactory(
+ commit=head_commit,
+ notification_type=Notification.comment,
+ decoration_type=Decoration.upgrade,
+ state=NotificationState.error,
+ )
+ dbsession.add(cn)
+ dbsession.flush()
+
+ notifier = CommentNotifier(
+ repository=head_commit.repository,
+ title="title",
+ notifier_yaml_settings={"layout": "reach, diff, flags, files, footer"},
+ notifier_site_settings=True,
+ current_yaml={},
+ repository_service=None,
+ decoration_type=Decoration.standard,
+ )
+ notify_res = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_received=dict(id=123),
+ data_sent=dict(a=1, b=2),
+ )
+ res = create_or_update_commit_notification_from_notification_result(
+ comparison, notifier, notify_res
+ )
+ dbsession.flush()
+ assert cn.commit_id == head_commit.id_
+ assert cn.decoration_type == notifier.decoration_type
+ assert cn.notification_type == notifier.notification_type
+ assert cn.state == NotificationState.success
diff --git a/apps/worker/services/notification/tests/unit/test_comparison.py b/apps/worker/services/notification/tests/unit/test_comparison.py
new file mode 100644
index 0000000000..d61733c32c
--- /dev/null
+++ b/apps/worker/services/notification/tests/unit/test_comparison.py
@@ -0,0 +1,14 @@
+from services.comparison import ComparisonProxy, FilteredComparison
+
+
+class TestFilteredComparison(object):
+ def test_get_existing_statuses(self, mocker):
+ mocked_get_existing_statuses = mocker.patch.object(
+ ComparisonProxy, "get_existing_statuses"
+ )
+ flags, path_patterns = ["flag"], None
+ comparison = ComparisonProxy(mocker.MagicMock())
+ filtered_comparison = comparison.get_filtered_comparison(flags, path_patterns)
+ assert isinstance(filtered_comparison, FilteredComparison)
+ res = filtered_comparison.get_existing_statuses()
+ assert res == mocked_get_existing_statuses.return_value
diff --git a/apps/worker/services/notification/tests/unit/test_notification_result.py b/apps/worker/services/notification/tests/unit/test_notification_result.py
new file mode 100644
index 0000000000..52b2ff60c1
--- /dev/null
+++ b/apps/worker/services/notification/tests/unit/test_notification_result.py
@@ -0,0 +1,35 @@
+from services.notification.notifiers.base import NotificationResult
+
+
+def test_notification_result_or_operation():
+ result_default = NotificationResult()
+ result_explanation = NotificationResult(explanation="some_explanation")
+ result_not_attempted = NotificationResult(
+ notification_attempted=False,
+ notification_successful=False,
+ explanation="dont_want",
+ )
+ result_attempted = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ )
+ result_some_data = NotificationResult(
+ data_sent={"comment": "hi"}, data_received={"response": "hi"}
+ )
+ assert result_default.merge(result_explanation) == result_explanation
+ assert result_default.merge(result_not_attempted) == result_not_attempted
+ assert result_attempted.merge(result_some_data) == NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={"comment": "hi"},
+ data_received={"response": "hi"},
+ )
+ assert result_not_attempted.merge(result_some_data) == NotificationResult(
+ notification_attempted=False,
+ notification_successful=False,
+ explanation="dont_want",
+ data_sent={"comment": "hi"},
+ data_received={"response": "hi"},
+ )
diff --git a/apps/worker/services/notification/tests/unit/test_notification_service.py b/apps/worker/services/notification/tests/unit/test_notification_service.py
new file mode 100644
index 0000000000..ceaa1c9e5b
--- /dev/null
+++ b/apps/worker/services/notification/tests/unit/test_notification_service.py
@@ -0,0 +1,1181 @@
+import os
+from asyncio import CancelledError
+from asyncio import TimeoutError as AsyncioTimeoutError
+
+import mock
+import pytest
+from celery.exceptions import SoftTimeLimitExceeded
+from shared.plan.constants import PlanName
+from shared.reports.reportfile import ReportFile
+from shared.reports.resources import Report
+from shared.reports.types import Change, ReportLine, ReportTotals
+from shared.torngit.status import Status
+from shared.yaml import UserYaml
+
+from database.enums import Decoration, Notification, NotificationState
+from database.models.core import (
+ GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ GithubAppInstallation,
+)
+from database.tests.factories import CommitFactory, PullFactory, RepositoryFactory
+from services.comparison import ComparisonProxy
+from services.comparison.types import Comparison, EnrichedPull, FullCommit
+from services.notification import NotificationService
+from services.notification.notifiers import (
+ CommentNotifier,
+ PatchChecksNotifier,
+ StatusType,
+)
+from services.notification.notifiers.base import NotificationResult
+from services.notification.notifiers.checks import ProjectChecksNotifier
+from services.notification.notifiers.checks.checks_with_fallback import (
+ ChecksWithFallback,
+)
+from services.notification.notifiers.mixins.status import (
+ HelperTextKey,
+ HelperTextTemplate,
+)
+from tests.helpers import mock_all_plans_and_tiers
+
+
+@pytest.fixture
+def sample_comparison(dbsession, request):
+ repository = RepositoryFactory.create(
+ owner__username=request.node.name,
+ owner__service="github",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ base_commit = CommitFactory.create(repository=repository)
+ head_commit = CommitFactory.create(repository=repository, branch="new_branch")
+ pull = PullFactory.create(
+ repository=repository, base=base_commit.commitid, head=head_commit.commitid
+ )
+ dbsession.add(base_commit)
+ dbsession.add(head_commit)
+ dbsession.add(pull)
+ dbsession.flush()
+ repository = base_commit.repository
+ base_full_commit = FullCommit(commit=base_commit, report=None)
+ head_full_commit = FullCommit(commit=head_commit, report=None)
+ return ComparisonProxy(
+ Comparison(
+ head=head_full_commit,
+ project_coverage_base=base_full_commit,
+ patch_coverage_base_commitid=base_commit.commitid,
+ enriched_pull=EnrichedPull(
+ database_pull=pull,
+ provider_pull={
+ "head": {"commitid": head_full_commit.commit.commitid},
+ "base": {
+ "commitid": base_full_commit.commit.commitid,
+ "branch": {},
+ },
+ },
+ ),
+ )
+ )
+
+
+class TestNotificationService(object):
+ @pytest.fixture(autouse=True)
+ def mock_all_plans_and_tiers_fixture(self):
+ mock_all_plans_and_tiers()
+
+ @pytest.mark.django_db
+ def test_should_use_checks_notifier_yaml_field_false(self, dbsession):
+ repository = RepositoryFactory.create()
+ current_yaml = {"github_checks": False}
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == False
+ )
+
+ @pytest.mark.parametrize(
+ "repo_data,outcome",
+ [
+ (
+ dict(
+ using_integration=True,
+ owner__integration_id=12341234,
+ owner__service="github",
+ ),
+ True,
+ ),
+ (
+ dict(
+ using_integration=True,
+ owner__integration_id=12341234,
+ owner__service="gitlab",
+ ),
+ False,
+ ),
+ (
+ dict(
+ using_integration=True,
+ owner__integration_id=12341234,
+ owner__service="github_enterprise",
+ ),
+ True,
+ ),
+ (
+ dict(
+ using_integration=False,
+ owner__integration_id=None,
+ owner__service="github",
+ ),
+ False,
+ ),
+ ],
+ )
+ @pytest.mark.django_db
+ def test_should_use_checks_notifier_deprecated_flow(
+ self, repo_data, outcome, dbsession
+ ):
+ repository = RepositoryFactory.create(**repo_data)
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == []
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == outcome
+ )
+
+ @pytest.mark.django_db
+ def test_should_use_checks_notifier_ghapp_all_repos_covered(self, dbsession):
+ repository = RepositoryFactory.create(owner__service="github")
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=456789,
+ owner=repository.owner,
+ repository_service_ids=None,
+ )
+ dbsession.add(ghapp_installation)
+ dbsession.flush()
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == [ghapp_installation]
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == True
+ )
+
+ @pytest.mark.django_db
+ def test_use_checks_notifier_for_team_plan(
+ self,
+ dbsession,
+ ):
+ repository = RepositoryFactory.create(
+ owner__service="github", owner__plan=PlanName.TEAM_MONTHLY.value
+ )
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=456789,
+ owner=repository.owner,
+ repository_service_ids=None,
+ )
+ dbsession.add(ghapp_installation)
+ dbsession.flush()
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == [ghapp_installation]
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == False
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.CHANGES.value)
+ == False
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PATCH.value)
+ == True
+ )
+
+ @pytest.mark.django_db
+ def test_use_status_notifier_for_team_plan(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__service="github", owner__plan=PlanName.TEAM_MONTHLY.value
+ )
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=456789,
+ owner=repository.owner,
+ repository_service_ids=None,
+ )
+ dbsession.add(ghapp_installation)
+ dbsession.flush()
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == [ghapp_installation]
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_status_notifier(status_type=StatusType.PROJECT.value)
+ == False
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.CHANGES.value)
+ == False
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PATCH.value)
+ == True
+ )
+
+ @pytest.mark.django_db
+ def test_use_status_notifier_for_non_team_plan(self, dbsession):
+ repository = RepositoryFactory.create(
+ owner__service="github", owner__plan=PlanName.CODECOV_PRO_MONTHLY.value
+ )
+ ghapp_installation = GithubAppInstallation(
+ name=GITHUB_APP_INSTALLATION_DEFAULT_NAME,
+ installation_id=456789,
+ owner=repository.owner,
+ repository_service_ids=None,
+ )
+ dbsession.add(ghapp_installation)
+ dbsession.flush()
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == [ghapp_installation]
+ service = NotificationService(repository, current_yaml, None)
+ assert (
+ service._should_use_status_notifier(status_type=StatusType.PROJECT.value)
+ == True
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.CHANGES.value)
+ == True
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PATCH.value)
+ == True
+ )
+
+ @pytest.mark.parametrize(
+ "gh_installation_name",
+ [GITHUB_APP_INSTALLATION_DEFAULT_NAME, "notifications-app"],
+ )
+ @pytest.mark.django_db
+ def test_should_use_checks_notifier_ghapp_some_repos_covered(
+ self, dbsession, gh_installation_name
+ ):
+ repository = RepositoryFactory.create(owner__service="github")
+ other_repo_same_owner = RepositoryFactory.create(owner=repository.owner)
+ ghapp_installation = GithubAppInstallation(
+ name=gh_installation_name,
+ installation_id=456789,
+ owner=repository.owner,
+ repository_service_ids=[repository.service_id],
+ app_id=123123,
+ pem_path="path_to_pem_file",
+ )
+ dbsession.add(ghapp_installation)
+ dbsession.flush()
+ current_yaml = {"github_checks": True}
+ assert repository.owner.github_app_installations == [ghapp_installation]
+ service = NotificationService(
+ repository,
+ current_yaml,
+ None,
+ gh_installation_name_to_use=gh_installation_name,
+ )
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == True
+ )
+ service = NotificationService(other_repo_same_owner, current_yaml, None)
+ assert (
+ service._should_use_checks_notifier(status_type=StatusType.PROJECT.value)
+ == False
+ )
+
+ @pytest.mark.django_db
+ def test_get_notifiers_instances_only_third_party(
+ self, dbsession, mock_configuration
+ ):
+ mock_configuration.params["services"] = {
+ "notifications": {"slack": ["slack.com"]}
+ }
+ repository = RepositoryFactory.create(
+ owner__unencrypted_oauth_token="testlln8sdeec57lz83oe3l8y9qq4lhqat2f1kzm",
+ owner__username="ThiagoCodecov",
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ current_yaml = {
+ "coverage": {"notify": {"slack": {"default": {"field": "1y ago"}}}}
+ }
+ service = NotificationService(repository, current_yaml, None)
+ instances = list(service.get_notifiers_instances())
+ assert len(instances) == 2
+ instance = instances[0]
+ assert instance.repository == repository
+ assert instance.title == "default"
+ assert instance.notifier_yaml_settings == {"field": "1y ago"}
+ assert instance.site_settings == ["slack.com"]
+ assert instance.current_yaml == current_yaml
+
+ @pytest.mark.django_db
+ def test_get_notifiers_instances_checks(
+ self, dbsession, mock_configuration, mocker
+ ):
+ repository = RepositoryFactory.create(
+ owner__integration_id=123,
+ owner__service="github",
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ using_integration=True,
+ )
+
+ dbsession.add(repository)
+ dbsession.flush()
+ current_yaml = {
+ "coverage": {"status": {"project": True, "patch": True, "changes": True}}
+ }
+ mocker.patch.dict(
+ os.environ, {"CHECKS_WHITELISTED_OWNERS": f"0,{repository.owner.ownerid}"}
+ )
+ service = NotificationService(repository, current_yaml, None)
+ instances = list(service.get_notifiers_instances())
+ names = sorted([instance.name for instance in instances])
+ types = sorted(instance.notification_type.value for instance in instances)
+ assert names == [
+ "checks-changes-with-fallback",
+ "checks-patch-with-fallback",
+ "checks-project-with-fallback",
+ "codecov-slack-app",
+ ]
+ assert types == [
+ "checks_changes",
+ "checks_patch",
+ "checks_project",
+ "codecov_slack_app",
+ ]
+
+ @pytest.mark.django_db
+ def test_get_notifiers_instances_slack_app_false(
+ self, dbsession, mock_configuration, mocker
+ ):
+ mocker.patch("services.notification.get_config", return_value=False)
+ repository = RepositoryFactory.create(
+ owner__integration_id=123,
+ owner__service="github",
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ using_integration=True,
+ )
+
+ dbsession.add(repository)
+ dbsession.flush()
+ current_yaml = {
+ "coverage": {"status": {"project": True, "patch": True, "changes": True}}
+ }
+ mocker.patch.dict(
+ os.environ, {"CHECKS_WHITELISTED_OWNERS": f"0,{repository.owner.ownerid}"}
+ )
+ service = NotificationService(repository, current_yaml, None)
+ instances = list(service.get_notifiers_instances())
+ names = sorted([instance.name for instance in instances])
+ assert names == [
+ "checks-changes-with-fallback",
+ "checks-patch-with-fallback",
+ "checks-project-with-fallback",
+ ]
+
+ @pytest.mark.parametrize(
+ "gh_installation_name",
+ [GITHUB_APP_INSTALLATION_DEFAULT_NAME, "notifications-app"],
+ )
+ @pytest.mark.django_db
+ def test_get_notifiers_instances_checks_percentage_whitelist(
+ self,
+ dbsession,
+ mock_configuration,
+ mocker,
+ gh_installation_name,
+ ):
+ repository = RepositoryFactory.create(
+ owner__integration_id=123,
+ owner__service="github",
+ owner__ownerid=1234,
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ current_yaml = {
+ "coverage": {"status": {"project": True, "patch": True, "changes": True}}
+ }
+ mocker.patch.dict(
+ os.environ,
+ {
+ "CHECKS_WHITELISTED_OWNERS": "0,1",
+ "CHECKS_WHITELISTED_PERCENTAGE": "35",
+ },
+ )
+ service = NotificationService(
+ repository,
+ current_yaml,
+ gh_installation_name,
+ )
+ instances = list(service.get_notifiers_instances())
+ # we don't need that for slack-app notifier
+ names = [
+ instance._checks_notifier.name
+ for instance in instances
+ if instance.name != "codecov-slack-app"
+ ]
+ assert names == ["checks-project", "checks-patch", "checks-changes"]
+ for instance in instances:
+ if isinstance(instance, ChecksWithFallback):
+ assert (
+ instance._checks_notifier.repository_service == gh_installation_name
+ )
+ assert (
+ instance._status_notifier.repository_service == gh_installation_name
+ )
+ else:
+ assert instance.repository_service == gh_installation_name
+
+ @pytest.mark.parametrize(
+ "gh_installation_name",
+ [GITHUB_APP_INSTALLATION_DEFAULT_NAME, "notifications-app"],
+ )
+ @pytest.mark.django_db
+ def test_get_notifiers_instances_comment(
+ self, dbsession, mock_configuration, mocker, gh_installation_name
+ ):
+ repository = RepositoryFactory.create(
+ owner__integration_id=123,
+ owner__service="github",
+ owner__ownerid=1234,
+ yaml={"codecov": {"max_report_age": "1y ago"}},
+ name="example-python",
+ using_integration=True,
+ )
+ dbsession.add(repository)
+ dbsession.flush()
+ current_yaml = {"comment": {"layout": "condensed_header"}, "slack_app": False}
+ service = NotificationService(
+ repository,
+ current_yaml,
+ gh_installation_name,
+ )
+ instances = list(service.get_notifiers_instances())
+ assert len(instances) == 1
+ assert instances[0].repository_service == gh_installation_name
+
+ @pytest.mark.django_db
+ def test_notify_general_exception(self, mocker, dbsession, sample_comparison):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ good_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ title="good_notifier",
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ notify=mock.Mock(),
+ )
+ bad_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ title="bad_notifier",
+ notification_type=Notification.status_project,
+ decoration_type=Decoration.standard,
+ )
+ disabled_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=False),
+ title="disabled_notifier",
+ notification_type=Notification.status_patch,
+ decoration_type=Decoration.standard,
+ notify=mock.Mock(),
+ )
+ good_notifier.notify.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ )
+ good_notifier.name = "good_name"
+ bad_notifier.name = "bad_name"
+ disabled_notifier.name = "disabled_notifier_name"
+ bad_notifier.notify.side_effect = Exception("This is bad")
+ mocker.patch.object(
+ NotificationService,
+ "get_notifiers_instances",
+ return_value=[bad_notifier, good_notifier, disabled_notifier],
+ )
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ expected_result = [
+ {"notifier": "bad_name", "title": "bad_notifier", "result": None},
+ {
+ "notifier": "good_name",
+ "title": "good_notifier",
+ "result": NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ data_received=None,
+ ),
+ },
+ ]
+ res = notifications_service.notify(sample_comparison)
+ assert expected_result == res
+
+ @pytest.mark.django_db
+ def test_notify_data_sent_None(self, mocker, dbsession, sample_comparison):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ good_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ title="good_notifier",
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ notify=mock.Mock(),
+ )
+ skipped_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ title="skippy_notifier",
+ notification_type=Notification.status_project,
+ decoration_type=Decoration.standard,
+ )
+ good_notifier.notify.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ )
+ skipped_expected_return = NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="exclude_flag_coverage_not_uploaded_checks",
+ data_sent=None,
+ data_received=None,
+ github_app_used=None,
+ )
+ skipped_notifier.notify.return_value = skipped_expected_return
+ good_notifier.name = "good_name"
+ skipped_notifier.name = "skippy"
+
+ mocker.patch.object(
+ NotificationService,
+ "get_notifiers_instances",
+ return_value=[skipped_notifier, good_notifier],
+ )
+
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ expected_result = [
+ {
+ "notifier": "skippy",
+ "title": "skippy_notifier",
+ "result": skipped_expected_return,
+ },
+ {
+ "notifier": "good_name",
+ "title": "good_notifier",
+ "result": NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ data_received=None,
+ ),
+ },
+ ]
+ res = notifications_service.notify(sample_comparison)
+ assert expected_result == res
+
+ @pytest.mark.django_db
+ def test_notify_individual_notifier_timeout(self, mocker, sample_comparison):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ notifier = mocker.MagicMock(
+ title="fake_notifier",
+ notify=mock.Mock(),
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ )
+ notifier.notify.side_effect = AsyncioTimeoutError
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ res = notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+ assert res == (notifier, None)
+
+ @pytest.mark.django_db
+ def test_notify_individual_checks_project_notifier(
+ self, mocker, sample_comparison, mock_repo_provider, mock_configuration
+ ):
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ mock_configuration._params["setup"] = {"codecov_dashboard_url": "test"}
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ report = Report()
+ first_deleted_file = ReportFile("file_1.go")
+ first_deleted_file.append(1, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(2, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(3, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(5, ReportLine.create(coverage=0, sessions=[]))
+ report.append(first_deleted_file)
+ sample_comparison.head.report = report
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ notifier = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={"flags": ["flagone"]},
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, mock_repo_provider
+ )
+ res = notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+
+ assert res == (
+ notifier,
+ NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation=None,
+ data_sent={
+ "state": "success",
+ "output": {
+ "title": "No coverage information found on head",
+ "summary": f"[View this Pull Request on Codecov](test/gh/test_notify_individual_checks_project_notifier/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo coverage information found on head",
+ "annotations": [],
+ },
+ "included_helper_text": {},
+ "url": f"test/gh/test_notify_individual_checks_project_notifier/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}",
+ },
+ data_received=None,
+ ),
+ )
+
+ @pytest.mark.django_db
+ def test_notify_individual_checks_patch_and_project_notifier_included_helper_text(
+ self,
+ mocker,
+ sample_comparison,
+ mock_repo_provider,
+ mock_configuration,
+ dbsession,
+ ):
+ """
+ A failed check/status notification with included_helper_text must pass the
+ included_helper_text along to the comment notifier.
+ """
+ pull_with_coverage = PullFactory(
+ repository=sample_comparison.enriched_pull.database_pull.repository,
+ commentid="1234",
+ ) # add this so we don't get is_first_coverage_pull
+ dbsession.add(pull_with_coverage)
+ dbsession.flush()
+ mock_repo_provider.get_commit_statuses.return_value = Status([])
+ # add this to satisfy create_or_update_commit_notification_from_notification_result
+ mock_repo_provider.post_comment.return_value = {"id": 9865}
+ mock_configuration._params["setup"] = {"codecov_dashboard_url": "test"}
+ current_yaml = {
+ "coverage": {"status": {"patch": True, "project": True}},
+ "comment": {"layout": "condensed_header"},
+ "slack_app": False,
+ "github_checks": {"annotations": False},
+ }
+ commit = sample_comparison.head.commit
+ report = Report()
+ first_deleted_file = ReportFile("file_1.go")
+ first_deleted_file.append(1, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(2, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(3, ReportLine.create(coverage=0, sessions=[]))
+ first_deleted_file.append(5, ReportLine.create(coverage=0, sessions=[]))
+ report.append(first_deleted_file)
+ sample_comparison.head.report = report
+ sample_comparison.project_coverage_base.report = report
+ mock_repo_provider.create_check_run.return_value = 2234563
+ mock_repo_provider.update_check_run.return_value = "success"
+ patch_check = PatchChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "paths": ["pathone"],
+ "layout": "reach, diff, flags, files, footer",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ proj_check = ProjectChecksNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "paths": ["pathone"],
+ "layout": "reach, diff, flags, files, footer",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ comment = CommentNotifier(
+ repository=sample_comparison.head.commit.repository,
+ title="title",
+ notifier_yaml_settings={
+ "target": "70%",
+ "paths": ["pathone"],
+ "layout": "reach, diff, flags, files, footer",
+ },
+ notifier_site_settings=True,
+ current_yaml=UserYaml({}),
+ repository_service=mock_repo_provider,
+ )
+ mocker.patch.object(
+ NotificationService,
+ "get_notifiers_instances",
+ return_value=[
+ patch_check,
+ proj_check,
+ comment,
+ ],
+ )
+
+ service = NotificationService(
+ commit.repository, current_yaml, mock_repo_provider
+ )
+ instances = list(service.get_notifiers_instances())
+ names = sorted([instance.name for instance in instances])
+ types = sorted(instance.notification_type.value for instance in instances)
+ assert names == ["checks-patch", "checks-project", "comment"]
+ assert types == ["checks_patch", "checks_project", "comment"]
+
+ checks_patch_result = {
+ "state": "failure",
+ "output": {
+ "title": "66.67% of diff hit (target 70.00%)",
+ "summary": f"[View this Pull Request on Codecov](test.example.br/gh/test_notify_individual_checks_patch_and_project_notifier_included_helper_text/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\n66.67% of diff hit (target 70.00%)",
+ "annotations": [],
+ },
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PATCH: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="patch",
+ notification_type="check",
+ point_of_comparison="patch",
+ coverage=66.67,
+ target="70.00",
+ )
+ },
+ }
+ # forcing this outcome from patch check notifier to test how that affects comment notifier
+ mocker.patch(
+ "services.notification.notifiers.checks.patch.PatchChecksNotifier.build_payload",
+ return_value=checks_patch_result,
+ )
+
+ checks_proj_result = {
+ "state": "failure",
+ "output": {
+ "title": "50.00% (target 70.00%)",
+ "summary": f"[View this Pull Request on Codecov](test/gh/test_notify_individual_checks_patch_and_project_notifier_included_helper_text/{sample_comparison.head.commit.repository.name}/pull/{sample_comparison.pull.pullid}?dropdown=coverage&src=pr&el=h1)\n\nNo coverage information found on head",
+ "annotations": [],
+ },
+ "included_helper_text": {
+ HelperTextKey.CUSTOM_TARGET_PROJECT: HelperTextTemplate.CUSTOM_TARGET.format(
+ context="project",
+ notification_type="check",
+ point_of_comparison="head",
+ coverage="50.00",
+ target="70.00",
+ )
+ },
+ }
+ # forcing this outcome from project check notifier to test how that affects comment notifier
+ mocker.patch(
+ "services.notification.notifiers.checks.project.ProjectChecksNotifier.build_payload",
+ return_value=checks_proj_result,
+ )
+
+ mocker.patch(
+ "services.comparison.ComparisonProxy.get_behind_by",
+ return_value=None,
+ )
+ mock_changes = [
+ Change(
+ path="modified.py",
+ new=False,
+ deleted=False,
+ in_diff=True,
+ old_path=None,
+ totals=ReportTotals(
+ files=0,
+ lines=0,
+ hits=-3,
+ misses=2,
+ partials=0,
+ coverage=-35.714290000000005,
+ branches=0,
+ methods=0,
+ messages=0,
+ sessions=0,
+ complexity=0,
+ complexity_total=0,
+ diff=0,
+ ),
+ ),
+ ]
+ mocker.patch(
+ "services.comparison.ComparisonProxy.get_changes", return_value=mock_changes
+ )
+
+ # this gets the patched results from PatchChecksNotifier and ProjectChecksNotifier, with included_helper_text
+ # CommentNotifier is called next, and should have the included_helper_text in the payload
+ res = service.notify(sample_comparison)
+
+ assert len(res) == 3
+ for r in res:
+ if r["notifier"] == "checks-patch":
+ assert (
+ r["result"].data_sent["included_helper_text"]
+ == checks_patch_result["included_helper_text"]
+ )
+
+ if r["notifier"] == "checks-project":
+ assert (
+ r["result"].data_sent["included_helper_text"]
+ == checks_proj_result["included_helper_text"]
+ )
+
+ if r["notifier"] == "comment":
+ assert (
+ ":x: "
+ + checks_patch_result["included_helper_text"][
+ HelperTextKey.CUSTOM_TARGET_PATCH
+ ]
+ and ":x: "
+ + checks_proj_result["included_helper_text"][
+ HelperTextKey.CUSTOM_TARGET_PROJECT
+ ]
+ in r["result"].data_sent["message"]
+ )
+
+ @pytest.mark.django_db
+ def test_notify_individual_notifier_timeout_notification_created(
+ self, mocker, dbsession, sample_comparison
+ ):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ notifier = mocker.MagicMock(
+ title="fake_notifier",
+ notify=mock.Mock(),
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ )
+ notifier.notify.side_effect = AsyncioTimeoutError
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ res = notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+ assert res == (notifier, None)
+
+ dbsession.flush()
+ pull = sample_comparison.enriched_pull.database_pull
+ pull_commit_notifications = pull.get_head_commit_notifications()
+ assert len(pull_commit_notifications) == 1
+
+ pull_commit_notification = pull_commit_notifications[0]
+ assert pull_commit_notification is not None
+ assert pull_commit_notification.notification_type == notifier.notification_type
+ assert pull_commit_notification.decoration_type == notifier.decoration_type
+ assert pull_commit_notification.state == NotificationState.error
+
+ @pytest.mark.django_db
+ def test_notify_individual_notifier_notification_created_then_updated(
+ self, mocker, dbsession, sample_comparison
+ ):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ notifier = mocker.MagicMock(
+ title="fake_notifier",
+ notify=mock.Mock(),
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ )
+ # first attempt not successful
+ notifier.notify.side_effect = AsyncioTimeoutError
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ res = notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+ assert res == (notifier, None)
+
+ dbsession.flush()
+ pull = sample_comparison.enriched_pull.database_pull
+ pull_commit_notifications = pull.get_head_commit_notifications()
+ assert len(pull_commit_notifications) == 1
+
+ pull_commit_notification = pull_commit_notifications[0]
+ assert pull_commit_notification is not None
+ assert pull_commit_notification.decoration_type == notifier.decoration_type
+ assert pull_commit_notification.state == NotificationState.error
+
+ # second attempt successful
+ notifier.notify.side_effect = [
+ NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ )
+ ]
+ res = notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+ dbsession.commit()
+ assert pull_commit_notification.state == NotificationState.success
+
+ @pytest.mark.django_db
+ def test_notify_individual_notifier_cancellation(self, mocker, sample_comparison):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ notifier = mocker.MagicMock(
+ title="fake_notifier",
+ notify=mock.Mock(),
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ )
+ notifier.notify.side_effect = CancelledError()
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ with pytest.raises(CancelledError):
+ notifications_service.notify_individual_notifier(
+ notifier, sample_comparison
+ )
+
+ @pytest.mark.django_db
+ def test_notify_timeout_exception(self, mocker, dbsession, sample_comparison):
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ good_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ notify=mock.Mock(),
+ title="good_notifier",
+ notification_type=Notification.comment,
+ decoration_type=Decoration.standard,
+ )
+ no_attempt_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ notify=mock.Mock(),
+ title="no_attempt_notifier",
+ notification_type=Notification.status_project,
+ decoration_type=Decoration.standard,
+ )
+ bad_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=True),
+ notify=mock.Mock(),
+ title="bad_notifier",
+ notification_type=Notification.status_patch,
+ decoration_type=Decoration.standard,
+ )
+ disabled_notifier = mocker.MagicMock(
+ is_enabled=mocker.MagicMock(return_value=False),
+ title="disabled_notifier",
+ notification_type=Notification.status_changes,
+ decoration_type=Decoration.standard,
+ )
+ good_notifier.notify.return_value = NotificationResult(
+ notification_attempted=True,
+ notification_successful=True,
+ explanation="",
+ data_sent={"some": "data"},
+ )
+ no_attempt_notifier.notify.return_value = NotificationResult(
+ notification_attempted=False,
+ notification_successful=None,
+ explanation="no_need_to_send",
+ data_sent=None,
+ )
+ good_notifier.name = "good_name"
+ bad_notifier.name = "bad_name"
+ disabled_notifier.name = "disabled_notifier_name"
+ bad_notifier.notify.side_effect = SoftTimeLimitExceeded()
+ mocker.patch.object(
+ NotificationService,
+ "get_notifiers_instances",
+ return_value=[
+ bad_notifier,
+ good_notifier,
+ disabled_notifier,
+ no_attempt_notifier,
+ ],
+ )
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ with pytest.raises(SoftTimeLimitExceeded):
+ notifications_service.notify(sample_comparison)
+
+ dbsession.flush()
+ pull_commit_notifications = sample_comparison.enriched_pull.database_pull.get_head_commit_notifications()
+ assert len(pull_commit_notifications) == 1
+ for commit_notification in pull_commit_notifications:
+ assert commit_notification.state in (
+ NotificationState.success,
+ NotificationState.error,
+ )
+ assert commit_notification.decoration_type == Decoration.standard
+ assert commit_notification.notification_type in (
+ Notification.comment,
+ Notification.status_patch,
+ )
+
+ @pytest.mark.django_db
+ def test_not_licensed_enterprise(self, mocker, dbsession, sample_comparison):
+ mocker.patch("services.notification.is_properly_licensed", return_value=False)
+ mock_notify_individual_notifier = mocker.patch.object(
+ NotificationService, "notify_individual_notifier"
+ )
+ current_yaml = {}
+ commit = sample_comparison.head.commit
+ notifications_service = NotificationService(
+ commit.repository, current_yaml, None
+ )
+ expected_result = []
+ res = notifications_service.notify(sample_comparison)
+ assert expected_result == res
+ assert not mock_notify_individual_notifier.called
+
+ @pytest.mark.django_db
+ def test_get_statuses(self, mocker, dbsession, sample_comparison):
+ current_yaml = {
+ "coverage": {"status": {"project": True, "patch": True, "changes": True}},
+ "flags": {"banana": {"carryforward": False}},
+ "flag_management": {
+ "default_rules": {"carryforward": False},
+ "individual_flags": [
+ {
+ "name": "strawberry",
+ "carryforward": True,
+ "statuses": [{"name_prefix": "haha", "type": "patch"}],
+ }
+ ],
+ },
+ }
+ commit = sample_comparison.head.commit
+ notifications_service = NotificationService(
+ commit.repository, UserYaml(current_yaml), None
+ )
+ expected_result = [
+ ("project", "default", {}),
+ ("patch", "default", {}),
+ ("changes", "default", {}),
+ (
+ "patch",
+ "hahastrawberry",
+ {"flags": ["strawberry"], "name_prefix": "haha", "type": "patch"},
+ ),
+ ]
+ res = list(notifications_service.get_statuses(["unit", "banana", "strawberry"]))
+ assert expected_result == res
+
+ @pytest.mark.django_db
+ def test_get_component_statuses(self, mocker, dbsession, sample_comparison):
+ current_yaml = {
+ "component_management": {
+ "default_rules": {
+ "paths": [r"src/important/.*\.cpp"],
+ "flag_regexes": [r"critical.*"],
+ "statuses": [
+ {"name_prefix": "important/", "type": "project"},
+ {
+ "name_prefix": "legacy/",
+ "type": "project",
+ "enabled": False,
+ }, # this won't be in the results because it's not enabled
+ ],
+ },
+ "individual_components": [
+ {
+ "component_id": "my-special-component",
+ "flag_regexes": [r"special.*"],
+ "statuses": [
+ {"name_prefix": "special/", "type": "patch"},
+ {"name_prefix": "legacy/", "type": "project"},
+ ],
+ },
+ {
+ "component_id": "inner-app",
+ "paths": [r"src/inner_app/.*"],
+ "statuses": [{"type": "patch"}],
+ },
+ {"component_id": "from_default"},
+ ],
+ }
+ }
+ commit = sample_comparison.head.commit
+ notifications_service = NotificationService(
+ commit.repository, UserYaml(current_yaml), None
+ )
+ expected_result = [
+ (
+ "patch",
+ "special/my-special-component",
+ {
+ "flags": ["special_flag"],
+ "paths": [r"src/important/.*\.cpp"],
+ "type": "patch",
+ "name_prefix": "special/",
+ },
+ ),
+ (
+ "project",
+ "legacy/my-special-component",
+ {
+ "flags": ["special_flag"],
+ "paths": [r"src/important/.*\.cpp"],
+ "type": "project",
+ "name_prefix": "legacy/",
+ },
+ ),
+ (
+ "patch",
+ "inner-app",
+ {
+ "flags": ["critical_files"],
+ "paths": [r"src/inner_app/.*"],
+ "type": "patch",
+ },
+ ),
+ (
+ "project",
+ "important/from_default",
+ {
+ "flags": ["critical_files"],
+ "name_prefix": "important/",
+ "type": "project",
+ "paths": [r"src/important/.*\.cpp"],
+ },
+ ),
+ ]
+ res = list(
+ notifications_service.get_statuses(
+ ["special_flag", "critical_files", "banana"]
+ )
+ )
+ assert expected_result == res
diff --git a/apps/worker/services/owner.py b/apps/worker/services/owner.py
new file mode 100644
index 0000000000..ac7e0771cc
--- /dev/null
+++ b/apps/worker/services/owner.py
@@ -0,0 +1,56 @@
+import logging
+
+import shared.torngit as torngit
+from shared.bots import get_adapter_auth_information
+from shared.config import get_config, get_verify_ssl
+from shared.django_apps.codecov_auth.models import Service
+from shared.typings.torngit import (
+ OwnerInfo,
+ TorngitInstanceData,
+)
+
+from helpers.token_refresh import get_token_refresh_callback
+
+log = logging.getLogger(__name__)
+
+
+def get_owner_provider_service(
+ owner, *, ignore_installation=False, additional_data=None
+):
+ _timeouts = [
+ get_config("setup", "http", "timeouts", "connect", default=15),
+ get_config("setup", "http", "timeouts", "receive", default=30),
+ ]
+ service = Service(owner.service)
+ adapter_auth_info = get_adapter_auth_information(
+ owner, ignore_installations=ignore_installation
+ )
+ if additional_data is None:
+ additional_data = {}
+ data = TorngitInstanceData(
+ owner=OwnerInfo(
+ service_id=owner.service_id, ownerid=owner.ownerid, username=owner.username
+ ),
+ installation=adapter_auth_info["selected_installation_info"],
+ fallback_installations=adapter_auth_info["fallback_installations"],
+ additional_data=additional_data,
+ )
+
+ adapter_params = dict(
+ token=adapter_auth_info["token"],
+ verify_ssl=get_verify_ssl(service.value),
+ timeouts=_timeouts,
+ oauth_consumer_token=dict(
+ key=get_config(service, "client_id"),
+ secret=get_config(service, "client_secret"),
+ ),
+ # if using integration we will use the integration token
+ # not the owner's token
+ on_token_refresh=get_token_refresh_callback(adapter_auth_info["token_owner"]),
+ **data,
+ )
+ return _get_owner_provider_service_instance(service, **adapter_params)
+
+
+def _get_owner_provider_service_instance(service_name, **adapter_params):
+ return torngit.get(service_name, **adapter_params)
diff --git a/apps/worker/services/path_fixer/__init__.py b/apps/worker/services/path_fixer/__init__.py
new file mode 100644
index 0000000000..baf95712b3
--- /dev/null
+++ b/apps/worker/services/path_fixer/__init__.py
@@ -0,0 +1,176 @@
+import logging
+import os.path
+from pathlib import PurePosixPath, PureWindowsPath
+from typing import Sequence
+
+import sentry_sdk
+from shared.yaml import UserYaml
+
+from helpers.pathmap import Tree
+from services.path_fixer.fixpaths import remove_known_bad_paths
+from services.path_fixer.user_path_fixes import UserPathFixes
+from services.path_fixer.user_path_includes import UserPathIncludes
+from services.yaml import read_yaml_field
+
+log = logging.getLogger(__name__)
+
+
+def invert_pattern(string: str) -> str:
+ if string.startswith("!"):
+ return string[1:]
+ else:
+ return "!%s" % string
+
+
+class PathFixer(object):
+ """
+ Applies default path fixes and any fixes specified in the codecov yaml file to resolve file paths in coverage reports.
+ Also applies any "ignore" and "paths" yaml fields to determine which files to include in the report.
+ """
+
+ tree: Tree | None
+
+ @classmethod
+ @sentry_sdk.trace
+ def init_from_user_yaml(
+ cls,
+ commit_yaml: UserYaml,
+ toc: list[str],
+ flags: list[str] | None = None,
+ extra_fixes: list[str] | None = None,
+ ):
+ """
+ :param commit_yaml: Codecov yaml file in effect for this commit.
+ :param toc: List of files prepended to the uploaded report. Not all report formats provide this.
+ :param flags: Coverage flags specified by the user, if any.
+ """
+ ignore = read_yaml_field(commit_yaml, ("ignore",)) or []
+ path_patterns = [invert_pattern(p) for p in ignore]
+
+ for flag in flags or []:
+ flag_configuration = commit_yaml.get_flag_configuration(flag) or {}
+ path_patterns.extend(
+ invert_pattern(p) for p in flag_configuration.get("ignore") or []
+ )
+ path_patterns.extend(flag_configuration.get("paths") or [])
+
+ disable_default_path_fixes = read_yaml_field(
+ commit_yaml, ("codecov", "disable_default_path_fixes")
+ )
+ yaml_fixes = read_yaml_field(commit_yaml, ("fixes",)) or []
+
+ if extra_fixes:
+ yaml_fixes.extend(extra_fixes)
+
+ return cls(
+ yaml_fixes=yaml_fixes,
+ path_patterns=path_patterns,
+ toc=toc,
+ should_disable_default_pathfixes=disable_default_path_fixes,
+ )
+
+ def __init__(
+ self,
+ yaml_fixes: list[str],
+ path_patterns: list[str],
+ toc: list[str],
+ should_disable_default_pathfixes=False,
+ ) -> None:
+ self.toc = toc or []
+
+ self.yaml_fixes = yaml_fixes or []
+ self.path_patterns = set(path_patterns) or set([])
+ self.should_disable_default_pathfixes = should_disable_default_pathfixes
+
+ self.custom_fixes = UserPathFixes(self.yaml_fixes)
+ self.path_matcher = UserPathIncludes(self.path_patterns)
+
+ if self.toc and not should_disable_default_pathfixes:
+ self.tree = Tree(self.toc)
+ else:
+ self.tree = None
+
+ def clean_path(self, path: str | None) -> str | None:
+ if not path:
+ return None
+ path = os.path.relpath(path.replace("\\", "/").lstrip("./").lstrip("../"))
+ if self.yaml_fixes:
+ # applies pre
+ path = self.custom_fixes(path, False)
+ if self.tree:
+ path = self.tree.resolve_path(path, ancestors=1)
+ if not path:
+ return None
+ elif not self.toc:
+ path = remove_known_bad_paths("", path)
+ if self.yaml_fixes:
+ # applied pre and post
+ path = self.custom_fixes(path, True)
+ if not self.path_matcher(path):
+ # don't include the file if yaml specified paths to include/ignore and it's not in the list to include
+ return None
+ return path
+
+ def __call__(self, path: str, bases_to_try=None) -> str | None:
+ return self.clean_path(path)
+
+ def get_relative_path_aware_pathfixer(self, base_path) -> "BasePathAwarePathFixer":
+ return BasePathAwarePathFixer(original_path_fixer=self, base_path=base_path)
+
+
+class BasePathAwarePathFixer(PathFixer):
+ def __init__(self, original_path_fixer, base_path) -> None:
+ self._resolved_paths: dict[tuple[str, Sequence[str]], str | None] = {}
+ self.original_path_fixer = original_path_fixer
+
+ # base_path argument is the file path after the "# path=" in the report containing report location, if provided.
+ # to get the base path we use, strip the coverage report from the path to get the base path
+ # e.g.: "path/to/coverage.xml" --> "path/to/"
+
+ self.base_path = []
+
+ if base_path:
+ # We want to use a `PurePath`, but we have to handle both Windows
+ # and POSIX paths. The cleanest way to do that is:
+ # - start by assuming it's a Windows path
+ # - if it doesn't have a drive letter like C:\, convert to POSIX
+ pure_path = PureWindowsPath(base_path)
+ if not pure_path.drive:
+ pure_path = PurePosixPath(pure_path.as_posix())
+
+ self.base_path = [pure_path.parent]
+
+ def _try_fix_path(self, path: str, bases_to_try: Sequence[str]) -> str | None:
+ original_path_fixer_result = self.original_path_fixer(path)
+ if (
+ original_path_fixer_result is not None
+ or (not self.base_path and not bases_to_try)
+ or not self.original_path_fixer.toc
+ ):
+ return original_path_fixer_result
+
+ if not os.path.isabs(path):
+ all_base_paths_to_try = (
+ self.base_path + list(bases_to_try) if bases_to_try else self.base_path
+ )
+
+ for base_path in all_base_paths_to_try:
+ adjusted_path = os.path.join(base_path, path)
+ base_path_aware_result = self.original_path_fixer(adjusted_path)
+ if base_path_aware_result is not None:
+ return base_path_aware_result
+
+ return original_path_fixer_result
+
+ def __call__(
+ self, path: str, bases_to_try: Sequence[str] | None = None
+ ) -> str | None:
+ if not path:
+ return None
+ bases_to_try = bases_to_try or tuple()
+ key = (path, bases_to_try)
+
+ if key not in self._resolved_paths:
+ self._resolved_paths[key] = self._try_fix_path(path, bases_to_try)
+
+ return self._resolved_paths[key]
diff --git a/apps/worker/services/path_fixer/fixpaths.py b/apps/worker/services/path_fixer/fixpaths.py
new file mode 100644
index 0000000000..b30f4ac8ee
--- /dev/null
+++ b/apps/worker/services/path_fixer/fixpaths.py
@@ -0,0 +1,120 @@
+import logging
+import re
+import string
+
+log = logging.getLogger(__name__)
+
+remove_known_bad_paths = re.compile(
+ r"^(\.*\/)*(%s)?"
+ % "|".join(
+ (
+ r"((home|Users)/travis/build/[^\/\n]+/[^\/\n]+/)",
+ r"((home|Users)/jenkins/jobs/[^\/\n]+/workspace/)",
+ r"(Users/distiller/[^\/\n]+/)",
+ r"(home/[^\/\n]+/src/([^\/\n]+/){3})", # home/rof/src/github.com|bitbucket.org/owner/repo/
+ r"((home|Users)/[^\/\n]+/workspace/[^\/\n]+/[^\/\n]+/)", # /Users/user/workspace/owner/repo
+ r"(.*/jenkins/workspace/[^\/\n]+/)",
+ r"((.+/src/)?github\.com/[^\/\n]+/[^\/\n]+/)",
+ r"(\w:/Repos/[^\/\n]+/[^\/\n]+/)",
+ r"([\w:/]+projects/[^\/\n]+/)",
+ r"(\w:/_build/GitHub/[^\/\n]+/)",
+ r"(build/lib\.[^\/\n]+/)",
+ r"(home/circleci/code/)",
+ r"(home/circleci/repo/)",
+ r"(vendor/src/.*)",
+ r"(pipeline/source/)",
+ r"(var/snap-ci/repo/)",
+ r"(home/ubuntu/[^\/\n]+/)",
+ r"(.*/site-packages/[^\/\n]+\.egg/)", # python3+
+ r"(.*/site-packages/)",
+ r"(usr/local/lib/[^\/\n]+/dist-packages/)",
+ r"(.*/slather/spec/fixtures/[^\n]*)",
+ r"(.*/target/generated-sources/[^\n]*)",
+ r"(.*/\.phpenv/.*)",
+ r"(.*/Debug-iphonesimulator/ReactiveCocoa\.build/DerivedSources/RA.*)",
+ r"(usr/include/.*)",
+ r"(.*/handlebars\.js/dist/.*)",
+ r"(node_modules/.*)",
+ r"(bower_components/.*)",
+ r"(.*/lib/clang/.*)",
+ r"(.*[\<\>].*)",
+ r"(\w\:\/)", # E:/ C:/
+ r"(.*/mac-coverage/build/src/.*)",
+ r"(opt/.*/dist-packages/.*)", # opt/ros/indigo/lib/python2.7/dist-packages/...
+ r"(.*/iPhoneSimulator.platform/Developer/SDKs/.*)",
+ r"(Applications/Xcode\.app/Contents/Developer/Toolchains/.*)",
+ r"((.*/)?\.?v?(irtual)?\.?envs?(-[^\/\n]+)?/.*/[^\/\n]+\.py$)",
+ r"(Users/[^\/\n]+/Projects/.*/Pods/.*)",
+ r"(Users/[^\/\n]+/Projects/[^\/\n]+/)",
+ r"(home/[^\/\n]+/[^\/\n]+/[^\/\n]+/)", # /home/:user/:owner/:repo/
+ )
+ ),
+ re.I | re.M,
+).sub
+
+
+def unquote_git_path(path: str) -> str:
+ """
+ Undo git-style Unicode armor for paths.
+ """
+
+ # This armoring is documented under git-config, `core.quotePath`, e.g. at
+ # https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
+ # There is no builtin codec for this, so we'll do it ourselves.
+
+ # We'll use a string-builder technique. We'll put each byte into a list and
+ # then turn the list into a string.
+ rv = []
+ i = 0
+ while i < len(path):
+ if path[i] == "\\":
+ if path[i + 1] == "r":
+ # The examples all match "users/.../Icon\r"
+ # https://apple.stackexchange.com/questions/31867/what-is-icon-r-file-and-how-do-i-delete-them
+ i += 2
+ elif path[i + 1] in string.octdigits:
+ # Decode an escaped byte; the next three characters are octets.
+ rv.append(int(path[i + 1 : i + 4], 8))
+ i += 4
+ else:
+ rv.append(ord(path[i + 1]))
+ i += 2
+ else:
+ # Just copy the codepoint.
+ rv.append(ord(path[i]))
+ i += 1
+ # Finally, decode with UTF-8.
+ return bytes(rv).decode("utf-8")
+
+
+def clean_toc(toc: str) -> list[str]:
+ """
+ Split a newline-delimited table of contents into a list of paths.
+
+ Each path will be cleaned up slightly.
+ """
+
+ rv = []
+ for path in toc.strip().split("\n"):
+ # Detect and undo git's Unicode armoring.
+ if path.startswith('"') and path.endswith('"'):
+ path = unquote_git_path(path[1:-1])
+
+ # Unescape escaped spaces.
+ path = path.replace("\\ ", " ")
+ # Windows: Fix backslashes.
+ path = path.replace("\\", "/")
+ # Fix relative paths which start in the current directory.
+ if path.startswith("./"):
+ path = path[2:]
+
+ # Unconditionally remove delombok'd Java source code.
+ # This can happen when folks upload code which uses the Lombok Java library.
+ # This code would confuse coverage, duplicating real code, so we discard it. ~ C.
+ if "/target/delombok/" in path:
+ continue
+
+ # This path is good; save it.
+ rv.append(path)
+
+ return rv
diff --git a/apps/worker/services/path_fixer/match.py b/apps/worker/services/path_fixer/match.py
new file mode 100644
index 0000000000..3518185100
--- /dev/null
+++ b/apps/worker/services/path_fixer/match.py
@@ -0,0 +1,8 @@
+import re
+
+
+def regexp_match_one(regexp_patterns: list[re.Pattern], path: str) -> bool:
+ for pattern in regexp_patterns:
+ if pattern.match(path):
+ return True
+ return False
diff --git a/apps/worker/services/path_fixer/tests/unit/test_fixpaths.py b/apps/worker/services/path_fixer/tests/unit/test_fixpaths.py
new file mode 100644
index 0000000000..8b129c6dde
--- /dev/null
+++ b/apps/worker/services/path_fixer/tests/unit/test_fixpaths.py
@@ -0,0 +1,56 @@
+import os
+
+import pytest
+
+from services.path_fixer import fixpaths
+from test_utils.base import BaseTestCase
+
+# Hand-written TOCs.
+paths = [
+ ("./a\\b", ["a/b"]),
+ ("./a\n./b", ["a", "b"]),
+ ("path/target/delombok/a\n./b", ["b"]),
+ ("comma,txt\nb", ["comma,txt", "b"]),
+ ('a\n"\\360\\237\\215\\255.txt"\nb', ["a", "🍭.txt", "b"]),
+]
+
+# Hand-written filenames.
+unquoted_files = {
+ "boring.txt": "boring.txt",
+ "back\\\\slash.txt": "back\\slash.txt",
+ "\\360\\237\\215\\255.txt": "🍭.txt",
+ "users/crovercraft/bootstrap/Icon\\r": "users/crovercraft/bootstrap/Icon",
+ 'test/fixture/vcr_cassettes/clickhouse/get_breakdown_values_escaped_\\".json': 'test/fixture/vcr_cassettes/clickhouse/get_breakdown_values_escaped_".json',
+}
+
+
+class TestFixpaths(BaseTestCase):
+ @pytest.mark.parametrize("toc, result", paths)
+ def test_clean_toc(self, toc, result):
+ assert fixpaths.clean_toc(toc) == result
+
+ def test_clean_toc_with_space(self):
+ assert fixpaths.clean_toc("a\\ b") == ["a b"]
+
+ @pytest.mark.parametrize("path, result", list(unquoted_files.items()))
+ def test_unquote_git_path(self, path, result):
+ assert fixpaths.unquote_git_path(path) == result
+
+ def test_some_real_git_paths(self):
+ prefix = "services/path_fixer/tests/testdir"
+ filenames = [
+ "café.txt",
+ "comma,txt",
+ "🍭.txt",
+ 'fixture/get_breakdown_values_escaped_".json',
+ ]
+ joined = [os.path.join(prefix, filename) for filename in filenames]
+ toc = """"services/path_fixer/tests/testdir/caf\\303\\251.txt"
+services/path_fixer/tests/testdir/comma,txt
+"services/path_fixer/tests/testdir/\\360\\237\\215\\255.txt"
+"services/path_fixer/tests/testdir/fixture/get_breakdown_values_escaped_\\".json"
+"""
+ cleaned = fixpaths.clean_toc(toc)
+ joined.sort()
+ cleaned.sort()
+ assert joined == cleaned
diff --git a/apps/worker/services/path_fixer/tests/unit/test_path_fixer.py b/apps/worker/services/path_fixer/tests/unit/test_path_fixer.py
new file mode 100644
index 0000000000..085a2cfb48
--- /dev/null
+++ b/apps/worker/services/path_fixer/tests/unit/test_path_fixer.py
@@ -0,0 +1,179 @@
+from pathlib import PurePosixPath, PureWindowsPath
+
+from shared.yaml import UserYaml
+
+from services.path_fixer import PathFixer, invert_pattern
+from test_utils.base import BaseTestCase
+
+
+class TestPathFixerHelpers(BaseTestCase):
+ def test_invert_pattern(self):
+ assert invert_pattern("aaaa") == "!aaaa"
+ assert invert_pattern("!aaaa") == "aaaa"
+
+
+class TestPathFixer(BaseTestCase):
+ def test_path_fixer_empty(self):
+ pf = PathFixer([], [], [])
+ assert pf("simple/path/to/something.py") == "simple/path/to/something.py"
+ assert pf("") is None
+ assert pf("bower_components/sample.js") == ""
+
+ def test_path_fixer_with_toc(self):
+ pf = PathFixer([], [], ["file_1.py", "folder/file_2.py"])
+ assert pf("fafafa/file_2.py") is None
+ assert pf("folder/file_2.py") == "folder/file_2.py"
+ assert pf("file_1.py") == "file_1.py"
+ assert pf("bad_path.py") is None
+ assert pf("") is None
+
+ def test_path_fixer_one_exclude_path_pattern(self):
+ pf = PathFixer([], ["!simple/path"], [])
+ assert pf("notsimple/path/to/something.py") == "notsimple/path/to/something.py"
+ assert (
+ pf("simple/notapath/to/something.py") == "simple/notapath/to/something.py"
+ )
+ assert pf("simple/path/to/something.py") is None
+
+ def test_path_fixer_one_custom_pathfix(self):
+ pf = PathFixer(["before/::after/"], [], [])
+ assert pf("before/path/to/something.py") == "after/path/to/something.py"
+ assert pf("after/path/to/something.py") == "after/path/to/something.py"
+ assert (
+ pf("after/before/path/to/something.py")
+ == "after/before/path/to/something.py"
+ )
+ assert (
+ pf("simple/notapath/to/something.py") == "simple/notapath/to/something.py"
+ )
+
+ def test_init_from_user_yaml(self):
+ commit_yaml = {
+ "fixes": [r"(?s:before/tests\-[^\/]+)::after/"],
+ "ignore": ["complex/path"],
+ "flags": {
+ "flagone": {"paths": ["!simple/notapath.*"]},
+ "flagtwo": {"paths": ["af"]},
+ },
+ }
+ toc = []
+ flags = ["flagone"]
+ pf = PathFixer.init_from_user_yaml(UserYaml(commit_yaml), toc, flags)
+ assert pf("notsimple/path/to/something.py") == "notsimple/path/to/something.py"
+ assert pf("complex/path/to/something.py") is None
+ assert pf("before/tests-apples/test.js") == "after/test.js"
+ assert pf("after/path/to/something.py") == "after/path/to/something.py"
+ assert (
+ pf("after/before/path/to/something.py")
+ == "after/before/path/to/something.py"
+ )
+ assert pf("simple/notapath/to/something.py") is None
+
+ def test_init_from_user_yaml_extra_fixes(self):
+ commit_yaml = {
+ "fixes": [r"(?s:before/tests\-[^\/]+)::after/"],
+ "ignore": ["complex/path"],
+ "flags": {
+ "flagone": {"paths": ["!simple/notapath.*"]},
+ "flagtwo": {"paths": ["af"]},
+ },
+ }
+ toc = []
+ flags = ["flagone"]
+ pf = PathFixer.init_from_user_yaml(
+ UserYaml(commit_yaml), toc, flags, extra_fixes=[r"notsimple::goal"]
+ )
+ # This next path is expected to change with the extra fixes
+ assert pf("notsimple/path/to/something.py") == "goal/path/to/something.py"
+ assert pf("complex/path/to/something.py") is None
+ assert pf("before/tests-apples/test.js") == "after/test.js"
+ assert pf("after/path/to/something.py") == "after/path/to/something.py"
+ assert (
+ pf("after/before/path/to/something.py")
+ == "after/before/path/to/something.py"
+ )
+ assert pf("simple/notapath/to/something.py") is None
+
+
+class TestBasePathAwarePathFixer(object):
+ def test_basepath_uses_main_result_if_not_none_when_disagreement(self):
+ commit_yaml = {
+ "fixes": [r"(?s:home/thiago)::root/"],
+ "ignore": ["complex/path"],
+ }
+ toc = ["path.c", "another/path.py", "root/another/path.py"]
+ flags = []
+ pf = PathFixer.init_from_user_yaml(commit_yaml, toc, flags)
+ base_path = "/home/thiago/testing"
+ base_aware_pf = pf.get_relative_path_aware_pathfixer(base_path)
+ assert base_aware_pf("sample/path.c") == "path.c"
+ assert base_aware_pf("another/path.py") == "another/path.py"
+ assert base_aware_pf("/another/path.py") == "another/path.py"
+
+ def test_basepath_uses_own_result_if_main_is_none(self):
+ toc = ["project/__init__.py", "tests/__init__.py", "tests/test_project.py"]
+ pf = PathFixer.init_from_user_yaml({}, toc, [])
+ base_path = "/home/travis/build/project/coverage.xml"
+ base_aware_pf = pf.get_relative_path_aware_pathfixer(base_path)
+ assert pf("__init__.py") is None
+ assert base_aware_pf("__init__.py") == "project/__init__.py"
+
+ def test_basepath_uses_own_result_if_main_is_none_multuple_base_paths(self):
+ toc = ["project/__init__.py", "tests/__init__.py", "tests/test_project.py"]
+ pf = PathFixer.init_from_user_yaml({}, toc, [])
+ base_path = "/home/something/coverage.xml"
+ base_aware_pf = pf.get_relative_path_aware_pathfixer(base_path)
+ assert pf("__init__.py") is None
+ assert base_aware_pf("__init__.py") is None
+ assert (
+ base_aware_pf("__init__.py", bases_to_try=("/home/travis/build/project",))
+ == "project/__init__.py"
+ )
+
+ def test_basepath_does_not_resolve_empty_paths(self):
+ toc = ["project/__init__.py", "tests/__init__.py", "tests/test_project.py"]
+ pf = PathFixer.init_from_user_yaml({}, toc, [])
+ coverage_file = "/some/coverage.xml"
+ base_aware_pf = pf.get_relative_path_aware_pathfixer(coverage_file)
+
+ assert base_aware_pf("") is None
+
+ def test_basepath_with_win_and_posix_paths(self):
+ toc = ["project/__init__.py", "tests/__init__.py", "tests/test_project.py"]
+ pf = PathFixer.init_from_user_yaml({}, toc, [])
+
+ posix_coverage_file_path = "/posix_base_path/coverage.xml"
+ posix_base_aware_pf = pf.get_relative_path_aware_pathfixer(
+ posix_coverage_file_path
+ )
+ assert posix_base_aware_pf.base_path == [PurePosixPath("/posix_base_path")]
+
+ windows_coverage_file_path = "C:\\windows_base_path\\coverage.xml"
+ windows_base_aware_pf = pf.get_relative_path_aware_pathfixer(
+ windows_coverage_file_path
+ )
+ assert windows_base_aware_pf.base_path == [
+ PureWindowsPath("C:\\windows_base_path")
+ ]
+
+
+def test_ambiguous_paths():
+ toc = [
+ "foobar/bar/baz.py",
+ "barfoo/bar/baz.py",
+ ]
+ base_path = "/home/runner/work/owner/repo/foobar/build/coverage/coverage.xml"
+ # ~~~~~~
+ bases_to_try = ("/app",)
+ # The problem here is that the given `file_name` is ambiguous, and neither the
+ # `base_path` nor the `bases_to_try` is helping us narrow this down.
+ # The `base_path` does include one of the relevant parent directories,
+ # but the paths within the coverage file are not relative to *that* file,
+ # and the `bases_to_try` seem to be completely unrelated.
+ file_name = "bar/baz.py"
+
+ pf = PathFixer.init_from_user_yaml({}, toc, [])
+ base_aware_pf = pf.get_relative_path_aware_pathfixer(base_path)
+
+ assert pf(file_name) is None
+ assert base_aware_pf(file_name, bases_to_try=bases_to_try) is None
diff --git a/apps/worker/services/path_fixer/tests/unit/test_user_path_fixes.py b/apps/worker/services/path_fixer/tests/unit/test_user_path_fixes.py
new file mode 100644
index 0000000000..bba6fb8671
--- /dev/null
+++ b/apps/worker/services/path_fixer/tests/unit/test_user_path_fixes.py
@@ -0,0 +1,53 @@
+from services.path_fixer.user_path_fixes import UserPathFixes
+from test_utils.base import BaseTestCase
+
+
+class TestUserPathFixes(BaseTestCase):
+ def test_user_path_fixes_empty(self):
+ yaml_fixes = []
+ upf = UserPathFixes(yaml_fixes)
+ assert upf("simple/path.c") == "simple/path.c"
+
+ def test_user_path_fixes_add_prefix_only(self):
+ yaml_fixes = ["::added_prefix"]
+ upf = UserPathFixes(yaml_fixes)
+ assert upf("simple/path.c") == "added_prefix/simple/path.c"
+ assert (
+ upf("added_prefix/second_path.java")
+ == "added_prefix/added_prefix/second_path.java"
+ )
+
+ def test_user_path_fixes_remove_prefix_only(self):
+ yaml_fixes = ["prefix_to_remove::"]
+ upf = UserPathFixes(yaml_fixes)
+ assert upf("simple/path.c") == "simple/path.c"
+ assert upf("added_prefix/second_path.java") == "added_prefix/second_path.java"
+ assert upf("prefix_to_remove/third_path.py") == "third_path.py"
+ assert (
+ upf("thisisnot/prefix_to_remove/third_path.py")
+ == "thisisnot/prefix_to_remove/third_path.py"
+ )
+
+ def test_user_path_fixes_remove_add(self):
+ yaml_fixes = ["prefix_to_remove::add"]
+ upf = UserPathFixes(yaml_fixes)
+ assert upf("simple/path.c") == "simple/path.c"
+ assert upf("added_prefix/second_path.java") == "added_prefix/second_path.java"
+ assert upf("prefix_to_remove/third_path.py") == "add/third_path.py"
+ assert (
+ upf("thisisnot/prefix_to_remove/third_path.py")
+ == "thisisnot/prefix_to_remove/third_path.py"
+ )
+
+ def test_user_path_fixes_remove_add_with_regex(self):
+ yaml_fixes = [r"(?s:prefix_to_remove/test\-[^\/]+)::add"]
+ upf = UserPathFixes(yaml_fixes)
+ assert upf("simple/path.c") == "simple/path.c"
+ assert upf("added_prefix/second_path.java") == "added_prefix/second_path.java"
+ assert upf("prefix_to_remove/test-third_folder/path.py") == "add/path.py"
+ assert upf("prefix_to_remove/test-fourth_folder/path.py") == "add/path.py"
+ assert upf("prefix_to_remove/test-fourth_oops.py") == "add"
+ assert (
+ upf("thisisnot/prefix_to_remove/test-third_path.py")
+ == "thisisnot/prefix_to_remove/test-third_path.py"
+ )
diff --git a/apps/worker/services/path_fixer/tests/unit/test_user_path_includes.py b/apps/worker/services/path_fixer/tests/unit/test_user_path_includes.py
new file mode 100644
index 0000000000..23bb013b8e
--- /dev/null
+++ b/apps/worker/services/path_fixer/tests/unit/test_user_path_includes.py
@@ -0,0 +1,30 @@
+from services.path_fixer.user_path_includes import UserPathIncludes
+from test_utils.base import BaseTestCase
+
+
+class TestUserPathIncludes(BaseTestCase):
+ def test_user_path_fixes_empty(self):
+ path_patterns = []
+ upi = UserPathIncludes(path_patterns)
+ assert upi("sample/path/to/file.go")
+ assert upi("any/to/file.cpp")
+
+ def test_user_path_fixes_star(self):
+ path_patterns = [".*", "whatever"]
+ upi = UserPathIncludes(path_patterns)
+ assert upi("sample/path/to/file.go")
+ assert upi("any/to/file.cpp")
+
+ def test_user_path_regex(self):
+ path_patterns = ["sample/[^/]+/to/.*"]
+ upi = UserPathIncludes(path_patterns)
+ assert upi("sample/path/to/file.go")
+ assert upi("sample/something/to/haha.cpp")
+ assert not upi("any/to/file.cpp")
+
+ def test_user_path_no_regex_elements(self):
+ path_patterns = ["normal/sample/path/file.py"]
+ upi = UserPathIncludes(path_patterns)
+ assert upi("normal/sample/path/file.py")
+ assert upi("normal/sample/path/file.pyc")
+ assert not upi("any/to/file.cpp")
diff --git a/apps/worker/services/path_fixer/user_path_fixes.py b/apps/worker/services/path_fixer/user_path_fixes.py
new file mode 100644
index 0000000000..8048282258
--- /dev/null
+++ b/apps/worker/services/path_fixer/user_path_fixes.py
@@ -0,0 +1,71 @@
+import re
+
+_star_to_glob = re.compile(r"(? str:
+ # [DEPRECATED] because handled by validators, but some data is cached in db
+ # a/**/b => a/.*/b
+ fix = fix.replace("**", r".*")
+ # a/*/b => a/[^\/\n]+/b
+ fix = _star_to_glob(r"[^\/\n]+", fix)
+ return fix.lstrip("/")
+
+
+class UserPathFixes:
+ """
+ This class contains the logic for apply path-fixes to the user, as described in
+ https://docs.codecov.io/docs/fixing-paths
+
+ There is an initializer and one function: __call__.
+ The usage of it is:
+
+ yaml_fixes = ['prefix_to_remove::', '::added_prefix', 'prefix_to_remove::add']
+ upf = UserPathFixes(yaml_fixes)
+ fixed_path = upf('simple/path.c')
+ """
+
+ yaml_fixes: list[str]
+
+ prefix: str
+ sub_regex: re.Pattern | None
+ sub_replacements: list[str]
+
+ def __init__(self, yaml_fixes: list[str] | None):
+ yaml_fixes = yaml_fixes or []
+ self.yaml_fixes = yaml_fixes
+ self.prefix = ""
+ self.sub_regex = None
+ self.sub_replacements = []
+
+ prefixes = set(f for f in yaml_fixes if f.startswith("::"))
+ custom_fixes = list(set(yaml_fixes) - prefixes)
+
+ if prefixes:
+ self.prefix = "/".join(p[2:].rstrip("/") for p in prefixes)
+
+ if custom_fixes:
+ self.sub_regex = re.compile(
+ r"^(%s)"
+ % ")|(".join(_fixpaths_regs(fix.split("::")[0]) for fix in custom_fixes)
+ )
+ self.sub_replacements = [fix.split("::")[1] for fix in custom_fixes]
+
+ def _replacement_fn(self, group: re.Match) -> str:
+ for group, replacement in zip(group.groups(), self.sub_replacements):
+ if group:
+ return replacement
+ assert False, "unreachable" # this is only ever called with one truthy group
+
+ def __call__(self, path: str, should_add_prefixes=True) -> str:
+ if should_add_prefixes and self.prefix:
+ path = "%s/%s" % (self.prefix, path)
+
+ if self.sub_regex:
+ path = self.sub_regex.sub(
+ self._replacement_fn,
+ path,
+ count=1,
+ )
+
+ return path.replace("//", "/").lstrip("/")
diff --git a/apps/worker/services/path_fixer/user_path_includes.py b/apps/worker/services/path_fixer/user_path_includes.py
new file mode 100644
index 0000000000..a7503e81a3
--- /dev/null
+++ b/apps/worker/services/path_fixer/user_path_includes.py
@@ -0,0 +1,74 @@
+import re
+
+from services.path_fixer.match import regexp_match_one
+
+
+class UserPathIncludes:
+ """
+ This class has one purpose: To determine whether a specific path should
+ be included in the report or not/
+
+ Its usage is:
+
+ path_patterns = ['.*', 'whatever']
+ upi = UserPathIncludes(path_patterns)
+ should_be_included = upi('sample/path/to/file.go')
+ """
+
+ path_patterns: set[str]
+
+ includes: list[re.Pattern]
+ include_all: bool
+
+ excludes: list[re.Pattern]
+ exclude_all: bool
+
+ def __init__(self, path_patterns: set[str], assume=True):
+ self.path_patterns = path_patterns
+ self.includes = []
+ self.include_all = False
+ self.excludes = []
+ self.exclude_all = False
+
+ if not self.path_patterns:
+ return
+
+ includes = set(p for p in path_patterns if not p.startswith("!"))
+ excludes = set(path_patterns) - includes
+
+ # create lists of pass/fails
+ if ".*" in path_patterns:
+ # match everything, just make sure it is not negative
+ self.include_all = True
+ self.includes = []
+ elif assume and len(includes) == 0:
+ self.include_all = True
+ else:
+ self.include_all = False
+ self.includes = [re.compile(i) for i in includes]
+
+ if "!.*" in self.path_patterns:
+ self.exclude_all = False
+ else:
+ self.excludes = [re.compile(e[1:]) for e in excludes]
+
+ def __call__(self, value: str) -> bool:
+ if not self.path_patterns:
+ return True
+ if value:
+ if self.include_all:
+ # everything is included
+ if self.excludes:
+ # make sure it is not excluded
+ return not regexp_match_one(self.excludes, value)
+ else:
+ return True
+ # we have to match once
+ if regexp_match_one(self.includes, value) is True:
+ # make sure it's not excluded
+ if self.excludes and regexp_match_one(self.excludes, value):
+ return False
+ else:
+ return True
+ return False
+ return False
diff --git a/apps/worker/services/processing/__init__.py b/apps/worker/services/processing/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/processing/flake_processing.py b/apps/worker/services/processing/flake_processing.py
new file mode 100644
index 0000000000..f6b6c3ee5a
--- /dev/null
+++ b/apps/worker/services/processing/flake_processing.py
@@ -0,0 +1,196 @@
+import logging
+
+from django.db import transaction as django_transaction
+from django.db.models import Q
+from shared.django_apps.reports.models import (
+ CommitReport,
+ DailyTestRollup,
+ Flake,
+ ReportSession,
+ TestInstance,
+)
+
+from services.test_analytics.ta_metrics import process_flakes_summary
+
+log = logging.getLogger(__name__)
+
+
+FLAKE_EXPIRY_COUNT = 30
+
+
+@process_flakes_summary.labels("old").time()
+def process_flake_for_repo_commit(
+ repo_id: int,
+ commit_id: str,
+):
+ with django_transaction.atomic():
+ process_flake_in_transaction(repo_id, commit_id)
+
+ log.info(
+ "Successfully processed flakes",
+ extra=dict(repoid=repo_id, commit=commit_id),
+ )
+
+ return {"successful": True}
+
+
+def process_flake_in_transaction(
+ repo_id: int,
+ commit_id: str,
+):
+ uploads = ReportSession.objects.filter(
+ report__report_type=CommitReport.ReportType.TEST_RESULTS.value,
+ report__commit__repository__repoid=repo_id,
+ report__commit__commitid=commit_id,
+ state__in=["processed"],
+ )
+
+ curr_flakes = fetch_curr_flakes(repo_id)
+ new_flakes: dict[str, Flake] = dict()
+
+ rollups_to_update: list[DailyTestRollup] = []
+
+ flaky_tests = list(curr_flakes.keys())
+
+ for upload in uploads:
+ test_instances = get_test_instances(upload, flaky_tests)
+ for test_instance in test_instances:
+ if test_instance.outcome == TestInstance.Outcome.PASS.value:
+ flake = new_flakes.get(test_instance.test_id) or curr_flakes.get(
+ test_instance.test_id
+ )
+ if flake is not None:
+ update_flake(flake, test_instance)
+ elif test_instance.outcome in (
+ TestInstance.Outcome.FAILURE.value,
+ TestInstance.Outcome.ERROR.value,
+ ):
+ flake = new_flakes.get(test_instance.test_id) or curr_flakes.get(
+ test_instance.test_id
+ )
+ if flake:
+ update_flake(flake, test_instance)
+ else:
+ flake, rollup = create_flake(test_instance, repo_id)
+
+ new_flakes[test_instance.test_id] = flake
+
+ if rollup:
+ rollups_to_update.append(rollup)
+
+ if rollups_to_update:
+ DailyTestRollup.objects.bulk_update(
+ rollups_to_update,
+ ["flaky_fail_count"],
+ )
+
+ merge_flake_dict = {}
+
+ if new_flakes:
+ flakes_to_merge = Flake.objects.bulk_create(new_flakes.values())
+ merge_flake_dict: dict[str, Flake] = {
+ flake.test_id: flake for flake in flakes_to_merge
+ }
+
+ Flake.objects.bulk_update(
+ curr_flakes.values(),
+ [
+ "count",
+ "fail_count",
+ "recent_passes_count",
+ "end_date",
+ ],
+ )
+
+ curr_flakes = {**merge_flake_dict, **curr_flakes}
+
+ new_flakes.clear()
+
+ upload.state = "flake_processed"
+ upload.save()
+
+
+def get_test_instances(
+ upload: ReportSession,
+ flaky_tests: list[str],
+) -> list[TestInstance]:
+ # get test instances on this upload that either:
+ # - failed
+ # - passed but belong to an already flaky test
+
+ upload_filter = Q(upload_id=upload.id)
+ test_failed_filter = Q(outcome=TestInstance.Outcome.ERROR.value) | Q(
+ outcome=TestInstance.Outcome.FAILURE.value
+ )
+ test_passed_but_flaky_filter = Q(outcome=TestInstance.Outcome.PASS.value) & Q(
+ test_id__in=flaky_tests
+ )
+ test_instances = list(
+ TestInstance.objects.filter(
+ upload_filter & (test_failed_filter | test_passed_but_flaky_filter)
+ )
+ .select_related("test")
+ .all()
+ )
+ return test_instances
+
+
+def fetch_curr_flakes(repo_id: int) -> dict[str, Flake]:
+ flakes = Flake.objects.filter(repository_id=repo_id, end_date__isnull=True)
+ return {flake.test_id: flake for flake in flakes}
+
+
+def create_flake(
+ test_instance: TestInstance,
+ repo_id: int,
+) -> tuple[Flake, DailyTestRollup | None]:
+ # retroactively mark newly caught flake as flaky failure in its rollup
+ rollup = DailyTestRollup.objects.filter(
+ repoid=repo_id,
+ date=test_instance.created_at.date(),
+ branch=test_instance.branch,
+ test_id=test_instance.test_id,
+ ).first()
+
+ if rollup:
+ rollup.flaky_fail_count += 1
+ else:
+ log.warning(
+ "Could not find rollup when trying to update its flaky fail count",
+ extra=dict(
+ repoid=repo_id,
+ testid=test_instance.test_id,
+ branch=test_instance.branch,
+ date=test_instance.created_at.date(),
+ ),
+ )
+
+ f = Flake(
+ repository_id=repo_id,
+ test=test_instance.test,
+ reduced_error=None,
+ count=1,
+ fail_count=1,
+ start_date=test_instance.created_at,
+ recent_passes_count=0,
+ )
+
+ return f, rollup
+
+
+def update_flake(
+ flake: Flake,
+ test_instance: TestInstance,
+) -> None:
+ flake.count += 1
+
+ match test_instance.outcome:
+ case TestInstance.Outcome.PASS.value:
+ flake.recent_passes_count += 1
+ if flake.recent_passes_count == FLAKE_EXPIRY_COUNT:
+ flake.end_date = test_instance.created_at
+ case TestInstance.Outcome.FAILURE.value | TestInstance.Outcome.ERROR.value:
+ flake.fail_count += 1
+ flake.recent_passes_count = 0
+ case _:
+ pass
diff --git a/apps/worker/services/processing/intermediate.py b/apps/worker/services/processing/intermediate.py
new file mode 100644
index 0000000000..cbcb404c01
--- /dev/null
+++ b/apps/worker/services/processing/intermediate.py
@@ -0,0 +1,92 @@
+import orjson
+import sentry_sdk
+import zstandard
+from shared.helpers.redis import get_redis_connection
+from shared.reports.resources import Report
+
+from .metrics import INTERMEDIATE_REPORT_SIZE
+from .types import IntermediateReport
+
+REPORT_TTL = 24 * 60 * 60
+
+
+@sentry_sdk.trace
+def load_intermediate_reports(upload_ids: list[int]) -> list[IntermediateReport]:
+ redis = get_redis_connection()
+ dctx = zstandard.ZstdDecompressor()
+ intermediate_reports: list[IntermediateReport] = []
+
+ for upload_id in upload_ids:
+ key = intermediate_report_key(upload_id)
+ report_dict: dict = redis.hgetall(key)
+ if not report_dict:
+ intermediate_reports.append(IntermediateReport(upload_id, Report()))
+ continue
+
+ # NOTE: our redis client is configured to return `bytes` everywhere,
+ # so the dict keys are `bytes` as well.
+ chunks = dctx.decompress(report_dict[b"chunks"]).decode(errors="replace")
+ report_json = orjson.loads(dctx.decompress(report_dict[b"report_json"]))
+
+ report = Report.from_chunks(
+ chunks=chunks,
+ files=report_json["files"],
+ sessions=report_json["sessions"],
+ totals=report_json.get("totals"),
+ )
+ intermediate_reports.append(IntermediateReport(upload_id, report))
+
+ return intermediate_reports
+
+
+@sentry_sdk.trace
+def save_intermediate_report(upload_id: int, report: Report):
+ report_json, chunks, _totals = report.serialize(with_totals=False)
+ zstd_report_json, zstd_chunks = emit_size_metrics(report_json, chunks)
+
+ report_key = intermediate_report_key(upload_id)
+ redis = get_redis_connection()
+ mapping = {
+ "report_json": zstd_report_json,
+ "chunks": zstd_chunks,
+ }
+ with redis.pipeline() as pipeline:
+ pipeline.hmset(report_key, mapping)
+ pipeline.expire(report_key, REPORT_TTL)
+ pipeline.execute()
+ return
+
+
+@sentry_sdk.trace
+def cleanup_intermediate_reports(
+ upload_ids: list[int],
+):
+ keys = [intermediate_report_key(upload_id) for upload_id in upload_ids]
+ redis = get_redis_connection()
+ redis.delete(*keys)
+ return
+
+
+def intermediate_report_key(upload_id: int):
+ return f"intermediate-report/{upload_id}"
+
+
+def emit_size_metrics(report_json: bytes, chunks: bytes) -> tuple[bytes, bytes]:
+ INTERMEDIATE_REPORT_SIZE.labels(type="report_json", compression="none").observe(
+ len(report_json)
+ )
+ INTERMEDIATE_REPORT_SIZE.labels(type="chunks", compression="none").observe(
+ len(chunks)
+ )
+
+ zstd_report_json = zstandard.compress(report_json)
+ zstd_chunks = zstandard.compress(chunks)
+
+ INTERMEDIATE_REPORT_SIZE.labels(type="report_json", compression="zstd").observe(
+ len(zstd_report_json)
+ )
+ INTERMEDIATE_REPORT_SIZE.labels(type="chunks", compression="zstd").observe(
+ len(zstd_chunks)
+ )
+
+ return zstd_report_json, zstd_chunks
diff --git a/apps/worker/services/processing/merging.py b/apps/worker/services/processing/merging.py
new file mode 100644
index 0000000000..0ade0521a1
--- /dev/null
+++ b/apps/worker/services/processing/merging.py
@@ -0,0 +1,181 @@
+import functools
+import logging
+from decimal import Decimal
+
+import sentry_sdk
+from shared.reports.enums import UploadState
+from shared.reports.resources import Report, ReportTotals
+from shared.yaml import UserYaml
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.orm import Session as DbSession
+
+from database.models.reports import Upload, UploadError, UploadLevelTotals
+from helpers.number import precise_round
+from services.report import delete_uploads_by_sessionid
+from services.report.raw_upload_processor import clear_carryforward_sessions
+from services.yaml.reader import read_yaml_field
+
+from .types import IntermediateReport, MergeResult, ProcessingResult
+
+log = logging.getLogger(__name__)
+
+
+@sentry_sdk.trace
+def merge_reports(
+ commit_yaml: UserYaml,
+ master_report: Report,
+ intermediate_reports: list[IntermediateReport],
+) -> tuple[Report, MergeResult]:
+ session_mapping: dict[int, int] = dict()
+ deleted_sessions: set[int] = set()
+
+ for intermediate_report in intermediate_reports:
+ report = intermediate_report.report
+ if report.is_empty():
+ continue
+
+ old_sessionid = next(iter(report.sessions))
+ new_sessionid = master_report.next_session_number()
+ session_mapping[intermediate_report.upload_id] = new_sessionid
+
+ if master_report.is_empty() and old_sessionid == new_sessionid:
+ # if the master report is empty, we can avoid a costly merge operation
+ master_report = report
+ continue
+
+ report.change_sessionid(old_sessionid, new_sessionid)
+ session = report.sessions[new_sessionid]
+
+ _session_id, session = master_report.add_session(
+ session, use_id_from_session=True
+ )
+
+ joined = True
+ if flags := session.flags:
+ session_adjustment = clear_carryforward_sessions(
+ master_report, report, flags, commit_yaml
+ )
+ deleted_sessions.update(session_adjustment.fully_deleted_sessions)
+ joined = get_joined_flag(commit_yaml, flags)
+
+ master_report.merge(report, joined)
+
+ return master_report, MergeResult(session_mapping, deleted_sessions)
+
+
+@sentry_sdk.trace
+def update_uploads(
+ db_session: DbSession,
+ commit_yaml: UserYaml,
+ processing_results: list[ProcessingResult],
+ intermediate_reports: list[IntermediateReport],
+ merge_result: MergeResult,
+):
+ """
+ Updates all the `Upload` records with the `MergeResult`.
+ In particular, this updates the `order_number` to match the new `session_id`,
+ and it deletes all the `Upload` records matching removed carry-forwarded `Session`s.
+ """
+
+ # first, delete removed sessions, as report merging can reuse deleted `session_id`s.
+ if merge_result.deleted_sessions:
+ any_upload_id = next(iter(merge_result.session_mapping.keys()))
+ report_id = (
+ db_session.query(Upload.report_id)
+ .filter(Upload.id_ == any_upload_id)
+ .first()[0]
+ )
+
+ delete_uploads_by_sessionid(
+ db_session, report_id, merge_result.deleted_sessions
+ )
+
+ precision: int = read_yaml_field(commit_yaml, ("coverage", "precision"), 2)
+ rounding: str = read_yaml_field(commit_yaml, ("coverage", "round"), "nearest")
+ make_totals = functools.partial(make_upload_totals, precision, rounding)
+
+ reports = {ir.upload_id: ir.report for ir in intermediate_reports}
+
+ # then, update all the `Upload`s with their state, and the final `order_number`,
+ # as well as add a `UploadLevelTotals` or `UploadError`s where appropriate.
+ all_errors: list[UploadError] = []
+ all_totals: list[dict] = []
+ all_upload_updates: list[dict] = []
+ for result in processing_results:
+ upload_id = result["upload_id"]
+
+ if result["successful"]:
+ update = {
+ "state_id": UploadState.PROCESSED.db_id,
+ "state": "processed",
+ }
+ report = reports.get(upload_id)
+ if report is not None:
+ all_totals.append(make_totals(upload_id, report.totals))
+ elif result["error"]:
+ update = {
+ "state_id": UploadState.ERROR.db_id,
+ "state": "error",
+ }
+ error = UploadError(
+ upload_id=upload_id,
+ error_code=result["error"]["code"],
+ error_params=result["error"]["params"],
+ )
+ all_errors.append(error)
+
+ update["id_"] = upload_id
+ order_number = merge_result.session_mapping.get(upload_id)
+ update["order_number"] = order_number
+ all_upload_updates.append(update)
+
+ db_session.bulk_update_mappings(Upload, all_upload_updates)
+ db_session.bulk_save_objects(all_errors)
+
+ if all_totals:
+ # the `UploadLevelTotals` have a unique constraint for the `upload`,
+ # so we have to use a manual `insert` statement:
+ stmt = (
+ insert(UploadLevelTotals.__table__)
+ .values(all_totals)
+ .on_conflict_do_nothing()
+ )
+ db_session.execute(stmt)
+
+ db_session.flush()
+
+
+# TODO(swatinem): we should eventually remove `UploadLevelTotals` completely
+def make_upload_totals(
+ precision: int, rounding: str, upload_id: int, totals: ReportTotals
+) -> dict:
+ if totals.coverage is not None:
+ coverage = precise_round(Decimal(totals.coverage), precision, rounding)
+ else:
+ coverage = Decimal(0)
+
+ return dict(
+ upload_id=upload_id,
+ branches=totals.branches,
+ coverage=coverage,
+ hits=totals.hits,
+ lines=totals.lines,
+ methods=totals.methods,
+ misses=totals.misses,
+ partials=totals.partials,
+ files=totals.files,
+ )
+
+
+def get_joined_flag(commit_yaml: UserYaml, flags: list[str]) -> bool:
+ for flag in flags:
+ if read_yaml_field(commit_yaml, ("flags", flag, "joined")) is False:
+ log.info(
+ "Customer is using joined=False feature", extra={"flag_used": flag}
+ )
+ sentry_sdk.capture_message(
+ "Customer is using joined=False feature", tags={"flag_used": flag}
+ )
+ return False
+
+ return True
diff --git a/apps/worker/services/processing/metrics.py b/apps/worker/services/processing/metrics.py
new file mode 100644
index 0000000000..1160b73321
--- /dev/null
+++ b/apps/worker/services/processing/metrics.py
@@ -0,0 +1,30 @@
+from shared.metrics import Counter, Histogram
+
+from helpers.metrics import BYTE_SIZE_BUCKETS
+
+LABELS_USAGE = Counter(
+ "worker_labels_usage",
+ "Number of various real-world `carryforward_mode=labels` usages",
+ ["codepath"],
+)
+
+# The final serialized `Report` sizes, split into `report_json` and `chunks`.
+# As the report is often incrementally updated multiple times, this value can
+# be biased towards smaller sizes.
+PYREPORT_REPORT_JSON_SIZE = Histogram(
+ "worker_tasks_upload_finisher_report_json_size",
+ "Size (in bytes) of a report's `report_json`.",
+ buckets=BYTE_SIZE_BUCKETS,
+)
+PYREPORT_CHUNKS_FILE_SIZE = Histogram(
+ "worker_tasks_upload_finisher_chunks_file_size",
+ "Size (in bytes) of a report's `chunks` file.",
+ buckets=BYTE_SIZE_BUCKETS,
+)
+
+INTERMEDIATE_REPORT_SIZE = Histogram(
+ "worker_intermediate_report_size",
+ "Size (in bytes) of a serialized intermediate report. The `type` can be `report_json` or `chunks`.",
+ ["type", "compression"],
+ buckets=BYTE_SIZE_BUCKETS,
+)
diff --git a/apps/worker/services/processing/processing.py b/apps/worker/services/processing/processing.py
new file mode 100644
index 0000000000..07d2e1a357
--- /dev/null
+++ b/apps/worker/services/processing/processing.py
@@ -0,0 +1,105 @@
+import logging
+from collections.abc import Callable
+
+import sentry_sdk
+from celery.exceptions import CeleryError
+from shared.yaml import UserYaml
+from sqlalchemy.orm import Session as DbSession
+
+from database.models.core import Commit
+from database.models.reports import Upload
+from helpers.reports import delete_archive_setting
+from services.archive import ArchiveService
+from services.report import ProcessingError, RawReportInfo, ReportService
+from services.report.parser.types import VersionOneParsedRawReport
+
+from .intermediate import save_intermediate_report
+from .state import ProcessingState
+from .types import ProcessingResult, UploadArguments
+
+log = logging.getLogger(__name__)
+
+
+@sentry_sdk.trace
+def process_upload(
+ on_processing_error: Callable[[ProcessingError], None],
+ db_session: DbSession,
+ repo_id: int,
+ commit_sha: str,
+ commit_yaml: UserYaml,
+ arguments: UploadArguments,
+) -> ProcessingResult:
+ upload_id = arguments["upload_id"]
+
+ commit = (
+ db_session.query(Commit)
+ .filter(Commit.repoid == repo_id, Commit.commitid == commit_sha)
+ .first()
+ )
+ assert commit
+
+ upload = db_session.query(Upload).filter_by(id_=upload_id).first()
+ assert upload
+
+ state = ProcessingState(repo_id, commit_sha)
+ # this in a noop in normal cases, but relevant for task retries:
+ state.mark_uploads_as_processing([upload_id])
+
+ report_service = ReportService(commit_yaml)
+ archive_service = report_service.get_archive_service(commit.repository)
+
+ result = ProcessingResult(
+ upload_id=upload_id, arguments=arguments, successful=False
+ )
+ db_session.commit()
+ try:
+ report_info = RawReportInfo()
+ processing_result = report_service.build_report_from_raw_content(
+ report_info, upload
+ )
+
+ if error := processing_result.error:
+ on_processing_error(error) # NOTE: this might throw a `Retry`
+ result["error"] = error.as_dict()
+ else:
+ result["successful"] = True
+ log.info("Finished processing upload", extra={"result": result})
+
+ if processing_result.report:
+ save_intermediate_report(upload_id, processing_result.report)
+ state.mark_upload_as_processed(upload_id)
+
+ rewrite_or_delete_upload(archive_service, commit_yaml, report_info)
+
+ except CeleryError:
+ raise
+ except Exception:
+ commit.state = "error"
+ db_session.commit()
+ log.exception("Could not properly process commit")
+ raise
+
+ finally:
+ # this is a noop in the success case, but makes sure unrecoverable errors
+ # or retries are not blocking later merge/notify stages
+ state.clear_in_progress_uploads([upload_id])
+
+ return result
+
+
+def rewrite_or_delete_upload(
+ archive_service: ArchiveService, commit_yaml: UserYaml, report_info: RawReportInfo
+):
+ should_delete_archive_setting = delete_archive_setting(commit_yaml)
+ archive_url = report_info.archive_url
+
+ if should_delete_archive_setting and not report_info.error:
+ if not archive_url.startswith("http"):
+ archive_service.delete_file(archive_url)
+
+ elif isinstance(report_info.raw_report, VersionOneParsedRawReport):
+ # only a version 1 report needs to be "rewritten readable"
+
+ archive_service.write_file(
+ archive_url, report_info.raw_report.content().getvalue()
+ )
diff --git a/apps/worker/services/processing/state.py b/apps/worker/services/processing/state.py
new file mode 100644
index 0000000000..3966b1c0fb
--- /dev/null
+++ b/apps/worker/services/processing/state.py
@@ -0,0 +1,118 @@
+"""
+This abstracts the "processing state" for a commit.
+
+It takes care that each upload for a specific commit is going through the following
+states:
+
+- "processing": when an upload was received and is being parsed/processed.
+- "processed": the upload has been processed and an "intermediate report" has been stored,
+ the upload is now waiting to be merged into the "master report".
+- "merged": the upload was fully merged into the "master report".
+
+The logic in this file also makes sure that processing and merging happens in an "optimal" way
+meaning that:
+
+- "postprocessing", which means triggering notifications and other followup work
+ only happens once for a commit.
+- merging should happen in batches, as that involves loading a bunch of "intermediate report"s
+ into memory, which should be bounded.
+- (ideally in the future) an upload that has been processed into an "intermediate report"
+ should be merged directly into the "master report" without doing a storage roundtrip for that
+ "intermediate report".
+"""
+
+from dataclasses import dataclass
+
+from shared.helpers.redis import get_redis_connection
+from shared.metrics import Counter
+
+MERGE_BATCH_SIZE = 10
+
+CLEARED_UPLOADS = Counter(
+ "worker_processing_cleared_uploads",
+ "Number of uploads cleared from queue because of errors",
+)
+
+
+@dataclass
+class UploadNumbers:
+ processing: int
+ """
+ The number of uploads currently being processed.
+ """
+
+ processed: int
+ """
+ The number of uploads that have been processed,
+ and are waiting on being merged into the "master report".
+ """
+
+
+def should_perform_merge(uploads: UploadNumbers) -> bool:
+ """
+ Determines whether a merge should be performed.
+
+ This is the case when no more uploads are expected,
+ or we reached the desired batch size for merging.
+ """
+ return uploads.processing == 0 or uploads.processed >= MERGE_BATCH_SIZE
+
+
+def should_trigger_postprocessing(uploads: UploadNumbers) -> bool:
+ """
+ Determines whether post-processing steps, such as notifications, etc,
+ should be performed.
+
+ This is the case when no more uploads are expected,
+ and all the processed uploads have been merged into the "master report".
+ """
+ return uploads.processing == 0 and uploads.processed == 0
+
+
+class ProcessingState:
+ def __init__(self, repoid: int, commitsha: str) -> None:
+ self._redis = get_redis_connection()
+ self.repoid = repoid
+ self.commitsha = commitsha
+
+ def get_upload_numbers(self):
+ processing = self._redis.scard(self._redis_key("processing"))
+ processed = self._redis.scard(self._redis_key("processed"))
+ return UploadNumbers(processing, processed)
+
+ def mark_uploads_as_processing(self, upload_ids: list[int]):
+ self._redis.sadd(self._redis_key("processing"), *upload_ids)
+
+ def clear_in_progress_uploads(self, upload_ids: list[int]):
+ removed_uploads = self._redis.srem(self._redis_key("processing"), *upload_ids)
+ if removed_uploads > 0:
+ # the normal flow would move the uploads from the "processing" set
+ # to the "processed" set via `mark_upload_as_processed`.
+ # this function here is only called in the error case and we don't expect
+ # this to be triggered often, if at all.
+ CLEARED_UPLOADS.inc(removed_uploads)
+
+ def mark_upload_as_processed(self, upload_id: int):
+ res = self._redis.smove(
+ self._redis_key("processing"), self._redis_key("processed"), upload_id
+ )
+ if not res:
+ # this can happen when `upload_id` was never in the source set,
+ # which probably is the case during initial deployment as
+ # the code adding this to the initial set was not deployed yet
+ # TODO: make sure to remove this code after a grace period
+ self._redis.sadd(self._redis_key("processed"), upload_id)
+
+ def mark_uploads_as_merged(self, upload_ids: list[int]):
+ self._redis.srem(self._redis_key("processed"), *upload_ids)
+
+ def get_uploads_for_merging(self) -> set[int]:
+ return set(
+ int(id)
+ for id in self._redis.srandmember(
+ self._redis_key("processed"), MERGE_BATCH_SIZE
+ )
+ )
+
+ def _redis_key(self, state: str) -> str:
+ return f"upload-processing-state/{self.repoid}/{self.commitsha}/{state}"
diff --git a/apps/worker/services/processing/types.py b/apps/worker/services/processing/types.py
new file mode 100644
index 0000000000..7e054b574b
--- /dev/null
+++ b/apps/worker/services/processing/types.py
@@ -0,0 +1,66 @@
+from dataclasses import dataclass
+from typing import Any, NotRequired, TypedDict
+
+from shared.reports.resources import Report
+from shared.upload.constants import UploadErrorCode
+
+
+class UploadArguments(TypedDict, total=False):
+ upload_id: int
+
+ # TODO(swatinem): migrate this over to `upload_id`
+ upload_pk: int
+
+ flags: list[str]
+ url: str
+
+ name: NotRequired[str]
+ reportid: NotRequired[str]
+ build: NotRequired[str]
+ build_url: NotRequired[str]
+ job: NotRequired[str]
+ service: NotRequired[str]
+
+ # TODO(swatinem): remove these fields completely being passed from API:
+ # `redis_key` being removed in https://github.com/codecov/codecov-api/pull/960
+ redis_key: NotRequired[str]
+ token: NotRequired[str]
+
+
+class ProcessingErrorDict(TypedDict):
+ code: UploadErrorCode
+ params: dict[str, Any]
+
+
+class ProcessingResult(TypedDict):
+ upload_id: int
+ arguments: UploadArguments
+ successful: bool
+ error: NotRequired[ProcessingErrorDict]
+
+
+@dataclass
+class IntermediateReport:
+ upload_id: int
+ """
+ The `Upload` id for which this report was loaded.
+ """
+
+ report: Report
+ """
+ The loaded Report.
+ """
+
+
+@dataclass
+class MergeResult:
+ session_mapping: dict[int, int]
+ """
+ This is a mapping from the input `upload_id` to the output `session_id`
+ as it exists in the merged "master Report".
+ """
+
+ deleted_sessions: set[int]
+ """
+ The Set of carryforwarded `session_id`s that have been removed from the "master Report".
+ """
diff --git a/apps/worker/services/report/README.md b/apps/worker/services/report/README.md
new file mode 100644
index 0000000000..9277520b1e
--- /dev/null
+++ b/apps/worker/services/report/README.md
@@ -0,0 +1,23 @@
+This is the part of the system responsible for reading the uploaded-reports.
+
+The classes follow the `adapter` design pattern. Each implementation class is responsible for reading one type of file (like one implentation for gcov, one implementation for cobertura etc)
+
+- It is an `adapter` in that it tries to read the existing format provider by a third-party into a standard format we have (it's not a textbook adapter design pattern, but it matches the concept of an adapter)
+
+# Flow
+
+The flow is as follows:
+
+- The `services/report/report_processor.py` function `process_report` is called. It first tries to do a simple parsing of the user-uploaded report, determining if it is a json/xml/plist/txt file (or other high-level format).
+
+- After that is determined, it fetches all the `BaseLanguageProcessor` implementations that can parse that type of file (ie, all implementations that deal with json files).
+
+- With that list, it tries to see which of the implementations can deal with that specific file. For such, it calls `matches_content` on every implementation until the first one that returns True
+
+- On that implementation, it calls the `process`, which takes the uploaded report (a string), a few more parameters, and returns a `Report` with the actual coverage information that such report gives.
+
+# Parsing a new type of file
+
+To implement a new type of file, one must create a new implementation of `BaseLanguageProcessor` present in `services/report/languages/base.py`.
+
+Then one needs to add this to the relevant high-level format in `get_possible_processors_list`. Where it is inside that function determines what exact type object is passed to the `matches_content` and `process` methods. For example, if added to the `xml` section, the implementation can expect a python `etree.ElementTree` object passed to it.
\ No newline at end of file
diff --git a/apps/worker/services/report/__init__.py b/apps/worker/services/report/__init__.py
new file mode 100644
index 0000000000..3957fbec70
--- /dev/null
+++ b/apps/worker/services/report/__init__.py
@@ -0,0 +1,816 @@
+import copy
+import itertools
+import logging
+import uuid
+from dataclasses import dataclass
+from time import time
+from typing import Any
+
+import orjson
+import sentry_sdk
+from asgiref.sync import async_to_sync
+from celery.exceptions import SoftTimeLimitExceeded
+from shared.django_apps.reports.models import ReportType
+from shared.reports.carryforward import generate_carryforward_report
+from shared.reports.enums import UploadState, UploadType
+from shared.reports.resources import Report
+from shared.reports.types import TOTALS_MAP
+from shared.storage.exceptions import FileNotInStorageError
+from shared.torngit.exceptions import TorngitError
+from shared.upload.constants import UploadErrorCode
+from shared.utils.sessions import Session, SessionType
+from shared.yaml import UserYaml
+from sqlalchemy.orm import Session as DbSession
+
+from database.models import Commit, Repository, Upload, UploadError
+from database.models.reports import (
+ CommitReport,
+ ReportLevelTotals,
+ RepositoryFlag,
+ UploadLevelTotals,
+ uploadflagmembership,
+)
+from helpers.exceptions import (
+ OwnerWithoutValidBotError,
+ ReportEmptyError,
+ ReportExpiredException,
+ RepositoryWithoutValidBotError,
+)
+from rollouts import CARRYFORWARD_BASE_SEARCH_RANGE_BY_OWNER
+from services.archive import ArchiveService
+from services.processing.metrics import (
+ PYREPORT_CHUNKS_FILE_SIZE,
+ PYREPORT_REPORT_JSON_SIZE,
+)
+from services.processing.types import ProcessingErrorDict, UploadArguments
+from services.report.parser import get_proper_parser
+from services.report.parser.types import ParsedRawReport
+from services.report.parser.version_one import VersionOneReportParser
+from services.report.prometheus_metrics import (
+ RAW_UPLOAD_RAW_REPORT_COUNT,
+ RAW_UPLOAD_SIZE,
+)
+from services.report.raw_upload_processor import process_raw_upload
+from services.repository import get_repo_provider_service
+from services.yaml.reader import get_paths_from_flags, read_yaml_field
+
+
+@dataclass
+class ProcessingError:
+ code: UploadErrorCode
+ params: dict[str, Any]
+ is_retryable: bool = False
+
+ def as_dict(self) -> ProcessingErrorDict:
+ return {"code": self.code, "params": self.params}
+
+
+@dataclass
+class ProcessingResult:
+ session: Session
+ report: Report | None = None
+ error: ProcessingError | None = None
+
+
+@dataclass
+class RawReportInfo:
+ raw_report: ParsedRawReport | None = None
+ archive_url: str = ""
+ upload: str = ""
+ error: ProcessingError | None = None
+
+
+log = logging.getLogger(__name__)
+
+
+class NotReadyToBuildReportYetError(Exception):
+ pass
+
+
+class BaseReportService:
+ """
+ This is the class that will handle anything report-handling related
+
+ Attributes:
+ current_yaml (Mapping[str, Any]): The configuration we need to follow.
+ It's always the user yaml, but might have different uses on different places
+ """
+
+ def __init__(self, current_yaml: UserYaml | dict):
+ if isinstance(current_yaml, dict):
+ current_yaml = UserYaml(current_yaml)
+ self.current_yaml = current_yaml
+
+ def initialize_and_save_report(
+ self, commit: Commit, report_code: str | None = None
+ ) -> CommitReport:
+ raise NotImplementedError()
+
+ def create_report_upload(
+ self, arguments: UploadArguments, commit_report: CommitReport
+ ) -> Upload:
+ """
+ Creates an `Upload` from the user-given arguments to a job
+
+ The end goal here is that the `Upload` should have all the information needed to
+ hypothetically redo the job later.
+ """
+ db_session = commit_report.get_db_session()
+ name = arguments.get("name")
+ upload = Upload(
+ report_id=commit_report.id_,
+ external_id=arguments.get("reportid"),
+ build_code=arguments.get("build"),
+ build_url=arguments.get("build_url"),
+ env=None,
+ job_code=arguments.get("job"),
+ name=(name[:100] if name else None),
+ provider=arguments.get("service"),
+ state="started",
+ storage_path=arguments.get("url"),
+ order_number=None,
+ upload_extras={},
+ upload_type=SessionType.uploaded.value,
+ state_id=UploadState.UPLOADED.db_id,
+ upload_type_id=UploadType.UPLOADED.db_id,
+ )
+ db_session.add(upload)
+ db_session.flush()
+ return upload
+
+
+class ReportService(BaseReportService):
+ def __init__(
+ self, current_yaml: UserYaml | dict, gh_app_installation_name: str | None = None
+ ):
+ super().__init__(current_yaml)
+ self.flag_dict: dict[str, RepositoryFlag] | None = None
+ self.gh_app_installation_name = gh_app_installation_name
+
+ def has_initialized_report(self, commit: Commit) -> bool:
+ """
+ Says whether a commit has already initialized its report or not
+ """
+ return (
+ commit._report_json is not None
+ or commit._report_json_storage_path is not None
+ )
+
+ @sentry_sdk.trace
+ def initialize_and_save_report(
+ self, commit: Commit, report_code: str | None = None
+ ) -> CommitReport:
+ """
+ Initializes the commit report
+
+
+ This is one of the main entrypoint of this class. It takes care of:
+ - Creating the `CommitReport`, if needed
+ - If that commit is old-style (was created before the report models were installed),
+ it takes care of backfilling all the information from the report into the new
+ report models
+ - If that commit needs something to be carryforwarded, it does that logic and
+ already saves the report into the database and storage
+
+ Args:
+ commit (Commit): The commit we want to initialize
+
+ Returns:
+ CommitReport: The CommitReport for that commit
+ """
+ db_session = commit.get_db_session()
+ current_report_row = (
+ db_session.query(CommitReport)
+ .filter_by(commit_id=commit.id_, code=report_code)
+ .filter(
+ (CommitReport.report_type == None) # noqa: E711
+ | (CommitReport.report_type == ReportType.COVERAGE.value)
+ )
+ .first()
+ )
+ if not current_report_row:
+ # This happens if the commit report is being created for the first time
+ # or backfilled
+ current_report_row = CommitReport(
+ commit_id=commit.id_,
+ code=report_code,
+ report_type=ReportType.COVERAGE.value,
+ )
+ db_session.add(current_report_row)
+ db_session.flush()
+
+ actual_report = self.get_existing_report_for_commit(
+ commit, report_code=report_code
+ )
+ if actual_report is not None:
+ log.info(
+ "Backfilling reports tables from commits.report",
+ extra=dict(commitid=commit.commitid),
+ )
+ # This case means the report exists in our system, it was just not saved
+ # yet into the new models therefore it needs backfilling
+ self.save_full_report(commit, actual_report)
+
+ if not self.has_initialized_report(commit):
+ report = self.create_new_report_for_commit(commit)
+ if not report.is_empty():
+ # This means there is a report to carryforward
+ self.save_full_report(commit, report, report_code)
+
+ return current_report_row
+
+ def _attach_flags_to_upload(self, upload: Upload, flag_names: list[str]):
+ """
+ Internal function that manages creating the proper `RepositoryFlag`s,
+ and attach them to the `Upload`
+ """
+
+ all_flags = []
+ db_session = upload.get_db_session()
+ repoid = upload.report.commit.repoid
+ flag_dict = self.fetch_repo_flags(db_session, repoid)
+
+ for individual_flag in flag_names:
+ flag_obj = flag_dict.get(individual_flag, None)
+ if flag_obj is None:
+ flag_obj = RepositoryFlag(
+ repository_id=repoid, flag_name=individual_flag
+ )
+ db_session.add(flag_obj)
+ db_session.flush()
+ flag_dict[individual_flag] = flag_obj
+ all_flags.append(flag_obj)
+
+ upload.flags = all_flags
+ db_session.flush()
+ return all_flags
+
+ def fetch_repo_flags(self, db_session, repoid: int) -> dict[str, RepositoryFlag]:
+ if self.flag_dict is None:
+ existing_flags_on_repo = (
+ db_session.query(RepositoryFlag).filter_by(repository_id=repoid).all()
+ )
+ self.flag_dict = {flag.flag_name: flag for flag in existing_flags_on_repo}
+ return self.flag_dict
+
+ def get_archive_service(self, repository: Repository) -> ArchiveService:
+ return ArchiveService(repository)
+
+ @sentry_sdk.trace
+ def get_existing_report_for_commit(
+ self, commit: Commit, report_class=None, report_code=None
+ ) -> Report | None:
+ commitid = commit.commitid
+ if not self.has_initialized_report(commit):
+ return None
+
+ try:
+ archive_service = self.get_archive_service(commit.repository)
+ chunks = archive_service.read_chunks(commitid, report_code)
+ except FileNotInStorageError:
+ log.warning(
+ "File for chunks not found in storage",
+ extra=dict(
+ commit=commitid, repo=commit.repoid, report_code=report_code
+ ),
+ )
+ return None
+
+ if chunks is None:
+ return None
+
+ files = commit.report_json["files"]
+ sessions = commit.report_json["sessions"]
+ totals = commit.totals
+
+ if report_class is None:
+ report_class = Report
+
+ # TODO(swatinem): move this logic into the `Report` constructor
+ for session_id, session in sessions.items():
+ if not isinstance(session, Session):
+ session["id"] = int(session_id)
+
+ return report_class.from_chunks(
+ chunks=chunks, files=files, sessions=sessions, totals=totals
+ )
+
+ def get_appropriate_commit_to_carryforward_from(
+ self, commit: Commit, max_parenthood_deepness: int = 10
+ ) -> Commit | None:
+ parent_commit = commit.get_parent_commit()
+ parent_commit_tracking = []
+ count = 1 # `parent_commit` is already the first parent
+ while (
+ parent_commit is not None
+ and parent_commit.state not in ("complete", "skipped")
+ and count < max_parenthood_deepness
+ ):
+ parent_commit_tracking.append(parent_commit.commitid)
+ if (
+ parent_commit.state == "pending"
+ and parent_commit.parent_commit_id is None
+ ):
+ log.warning(
+ "One of the ancestors commit doesn't seem to have determined its parent yet",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ current_parent_commit=parent_commit.commitid,
+ ),
+ )
+ raise NotReadyToBuildReportYetError()
+ log.info(
+ "Going from parent to their parent since they dont match the requisites for CFF",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ current_parent_commit=parent_commit.commitid,
+ parent_tracking=parent_commit_tracking,
+ current_state=parent_commit.state,
+ new_parent_commit=parent_commit.parent_commit_id,
+ ),
+ )
+ parent_commit = parent_commit.get_parent_commit()
+ count += 1
+ if parent_commit is None:
+ log.warning(
+ "No parent commit was found to be carriedforward from",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ parent_tracing=parent_commit_tracking,
+ ),
+ )
+ return None
+ if parent_commit.state not in ("complete", "skipped"):
+ log.warning(
+ "None of the parent commits were in a complete state to be used as CFing base",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ parent_tracking=parent_commit_tracking,
+ would_be_state=parent_commit.state,
+ would_be_parent=parent_commit.commitid,
+ ),
+ )
+ return None
+ return parent_commit
+
+ def _possibly_shift_carryforward_report(
+ self, carryforward_report: Report, base_commit: Commit, head_commit: Commit
+ ) -> Report:
+ try:
+ provider_service = get_repo_provider_service(
+ repository=head_commit.repository,
+ installation_name_to_use=self.gh_app_installation_name,
+ )
+ diff = async_to_sync(provider_service.get_compare)(
+ base=base_commit.commitid, head=head_commit.commitid
+ )
+ # Volatile function, alters carryforward_report
+ carryforward_report.shift_lines_by_diff(diff["diff"])
+ except (RepositoryWithoutValidBotError, OwnerWithoutValidBotError) as exp:
+ log.error(
+ "Failed to shift carryforward report lines",
+ extra=dict(
+ reason="Can't get provider_service",
+ commit=head_commit.commitid,
+ error=str(exp),
+ ),
+ )
+ except TorngitError as exp:
+ log.error(
+ "Failed to shift carryforward report lines.",
+ extra=dict(
+ reason="Can't get diff",
+ commit=head_commit.commitid,
+ error=str(exp),
+ error_type=type(exp),
+ ),
+ )
+ except SoftTimeLimitExceeded:
+ raise
+ except Exception:
+ log.exception(
+ "Failed to shift carryforward report lines.",
+ extra=dict(
+ reason="Unknown",
+ commit=head_commit.commitid,
+ ),
+ )
+ return carryforward_report
+
+ def create_new_report_for_commit(self, commit: Commit) -> Report:
+ log.info(
+ "Creating new report for commit",
+ extra=dict(commit=commit.commitid, repoid=commit.repoid),
+ )
+ if not self.current_yaml or not self.current_yaml.has_any_carryforward():
+ return Report()
+
+ repo = commit.repository
+ # This experiment is inactive because the data went back and forth
+ # on whether it was impactful or not. The `Feature` is left here as
+ # a knob to turn for support requests about carryforward flags, and
+ # maybe we'll revisit a general rollout at a later time.
+ max_parenthood_deepness = CARRYFORWARD_BASE_SEARCH_RANGE_BY_OWNER.check_value(
+ identifier=repo.ownerid, default=10
+ )
+
+ parent_commit = self.get_appropriate_commit_to_carryforward_from(
+ commit, max_parenthood_deepness
+ )
+ if parent_commit is None:
+ log.warning(
+ "Could not find parent for possible carryforward",
+ extra=dict(commit=commit.commitid, repoid=commit.repoid),
+ )
+ return Report()
+
+ parent_report = self.get_existing_report_for_commit(parent_commit)
+ if parent_report is None:
+ log.warning(
+ "Could not carryforward report from another commit because parent has no report",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ parent_commit=parent_commit.commitid,
+ ),
+ )
+ return Report()
+
+ flags_to_carryforward = [
+ flag_name
+ for flag_name in parent_report.get_flag_names()
+ if self.current_yaml.flag_has_carryfoward(flag_name)
+ ]
+ if not flags_to_carryforward:
+ return Report()
+
+ paths_to_carryforward = get_paths_from_flags(
+ self.current_yaml, flags_to_carryforward
+ )
+ log.info(
+ "Generating carriedforward report",
+ extra=dict(
+ commit=commit.commitid,
+ repoid=commit.repoid,
+ parent_commit=parent_commit.commitid,
+ flags_to_carryforward=flags_to_carryforward,
+ paths_to_carryforward=paths_to_carryforward,
+ parent_sessions=parent_report.sessions,
+ ),
+ )
+ carryforward_report = generate_carryforward_report(
+ parent_report,
+ flags_to_carryforward,
+ paths_to_carryforward,
+ session_extras=dict(carriedforward_from=parent_commit.commitid),
+ )
+ # If the parent report has labels we also need to carryforward the label index
+ # Considerations:
+ # 1. It's necessary for labels flags to be carryforward, so it's ok to carryforward the entire index
+ # 2. As tests are renamed the index might start to be filled with stale labels. This is not good.
+ # but I'm unsure if we should try to clean it up at this point. Cleaning it up requires going through
+ # all lines of the report. It will be handled by a dedicated task that is encoded by the UploadFinisher
+ # 3. We deepcopy the header so we can change them independently
+ # 4. The parent_commit always uses the default report to carryforward (i.e. report_code for parent_commit is None)
+ # parent_commit and commit should belong to the same repository
+ carryforward_report.header = copy.deepcopy(parent_report.header)
+
+ self._possibly_shift_carryforward_report(
+ carryforward_report, parent_commit, commit
+ )
+ return carryforward_report
+
+ @sentry_sdk.trace
+ def parse_raw_report_from_storage(
+ self, repo: Repository, upload: Upload
+ ) -> ParsedRawReport:
+ """Pulls the raw uploaded report from storage and parses it so it's
+ easier to access different parts of the raw upload.
+
+ Raises:
+ shared.storage.exceptions.FileNotInStorageError
+ """
+ archive_service = self.get_archive_service(repo)
+ archive_url = upload.storage_path
+
+ log.info(
+ "Parsing the raw report from storage",
+ extra=dict(
+ commit=upload.report.commit_id,
+ repoid=repo.repoid,
+ archive_url=archive_url,
+ ),
+ )
+
+ archive_file = archive_service.read_file(archive_url)
+
+ parser = get_proper_parser(upload, archive_file)
+ upload_version = (
+ "v1" if isinstance(parser, VersionOneReportParser) else "legacy"
+ )
+ RAW_UPLOAD_SIZE.labels(version=upload_version).observe(len(archive_file))
+
+ raw_uploaded_report = parser.parse_raw_report_from_bytes(archive_file)
+
+ raw_report_count = len(raw_uploaded_report.get_uploaded_files())
+ if raw_report_count < 1:
+ log.warning(
+ "Raw upload contains no uploaded files",
+ extra=dict(
+ commit=upload.report.commit_id,
+ repoid=repo.repoid,
+ raw_report_count=raw_report_count,
+ upload_version=upload_version,
+ archive_url=archive_url,
+ ),
+ )
+ RAW_UPLOAD_RAW_REPORT_COUNT.labels(version=upload_version).observe(
+ raw_report_count
+ )
+
+ return raw_uploaded_report
+
+ @sentry_sdk.trace
+ def build_report_from_raw_content(
+ self,
+ raw_report_info: RawReportInfo,
+ upload: Upload,
+ ) -> ProcessingResult:
+ """
+ Processes an upload on top of an existing report `master` and returns
+ a result, which could be successful or not
+
+ Note that this function does not modify the `upload` object, as this should
+ be done by a separate function
+ """
+ commit = upload.report.commit
+ flags = upload.flag_names
+ archive_url = upload.storage_path
+ reportid = upload.external_id
+
+ session = Session(
+ provider=upload.provider,
+ build=upload.build_code,
+ job=upload.job_code,
+ name=upload.name,
+ time=int(time()),
+ flags=flags,
+ archive=archive_url,
+ url=upload.build_url,
+ )
+ result = ProcessingResult(session=session)
+
+ raw_report_info.archive_url = archive_url
+ raw_report_info.upload = upload.external_id
+
+ try:
+ raw_report = self.parse_raw_report_from_storage(commit.repository, upload)
+ raw_report_info.raw_report = raw_report
+ except FileNotInStorageError as e:
+ sentry_sdk.capture_exception(e)
+ log.info(
+ "Raw report file was not found",
+ extra=dict(
+ reportid=reportid,
+ commit_yaml=self.current_yaml.to_dict(),
+ archive_url=archive_url,
+ ),
+ )
+ result.error = ProcessingError(
+ code=UploadErrorCode.FILE_NOT_IN_STORAGE,
+ params={"location": archive_url},
+ is_retryable=True,
+ )
+ raw_report_info.error = result.error
+ return result
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ log.exception(
+ "Unknown error when fetching raw report from storage",
+ extra=dict(archive_path=archive_url),
+ )
+ result.error = ProcessingError(
+ code=UploadErrorCode.UNKNOWN_STORAGE,
+ params={"location": archive_url},
+ is_retryable=False,
+ )
+ raw_report_info.error = result.error
+ return result
+
+ log.debug("Retrieved report for processing from url %s", archive_url)
+ try:
+ result.report = process_raw_upload(self.current_yaml, raw_report, session)
+
+ log.info(
+ "Successfully processed report",
+ extra=dict(
+ session=session.id,
+ ci=f"{session.provider}:{session.build}:{session.job}",
+ reportid=reportid,
+ commit_yaml=self.current_yaml.to_dict(),
+ content_len=raw_report.size,
+ ),
+ )
+ return result
+ except ReportExpiredException as r:
+ sentry_sdk.capture_exception(r)
+ log.info(
+ "Report is expired",
+ extra=dict(
+ reportid=reportid, archive_path=archive_url, file_name=r.filename
+ ),
+ )
+ result.error = ProcessingError(
+ code=UploadErrorCode.REPORT_EXPIRED, params={}
+ )
+ raw_report_info.error = result.error
+ return result
+ except ReportEmptyError as e:
+ sentry_sdk.capture_exception(e)
+ log.warning("Report is empty", extra=dict(reportid=reportid))
+ result.error = ProcessingError(code=UploadErrorCode.REPORT_EMPTY, params={})
+ raw_report_info.error = result.error
+ return result
+ except SoftTimeLimitExceeded as e:
+ sentry_sdk.capture_exception(e)
+ log.warning(
+ "Timed out while processing report", extra=dict(reportid=reportid)
+ )
+ result.error = ProcessingError(
+ code=UploadErrorCode.PROCESSING_TIMEOUT, params={}
+ )
+ raw_report_info.error = result.error
+ # Return and attempt to save the error result rather than re-raise
+ return result
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ log.exception(
+ "Unknown error when processing raw upload",
+ extra=dict(archive_path=archive_url),
+ )
+ result.error = ProcessingError(
+ code=UploadErrorCode.UNKNOWN_PROCESSING,
+ params={"location": archive_url},
+ is_retryable=False,
+ )
+ raw_report_info.error = result.error
+ return result
+
+ @sentry_sdk.trace
+ def save_report(self, commit: Commit, report: Report, report_code=None):
+ archive_service = self.get_archive_service(commit.repository)
+
+ report_json, chunks, _totals = report.serialize()
+
+ PYREPORT_REPORT_JSON_SIZE.observe(len(report_json))
+ PYREPORT_CHUNKS_FILE_SIZE.observe(len(chunks))
+
+ chunks_url = archive_service.write_chunks(commit.commitid, chunks, report_code)
+
+ commit.state = "complete" if report else "error"
+ commit.totals = legacy_totals(report)
+ if (
+ commit.totals is not None
+ and "c" in commit.totals
+ and commit.totals["c"] is None
+ ):
+ # temporary measure until we ensure the API and frontend don't expect not-null coverages
+ commit.totals["c"] = 0
+
+ log.info(
+ "Calling update to Commit.Report",
+ extra=dict(
+ size=len(report_json),
+ ownerid=commit.repository.ownerid,
+ repoid=commit.repoid,
+ commitid=commit.commitid,
+ ),
+ )
+ # `report_json` is an `ArchiveField`, so this will trigger an upload
+ # FIXME: we do an unnecessary `loads` roundtrip because of this abstraction,
+ # and we should just save the `report_json` to archive storage directly instead.
+ commit.report_json = orjson.loads(report_json)
+
+ # `report` is an accessor which implicitly queries `CommitReport`
+ if commit_report := commit.report:
+ db_session = commit.get_db_session()
+
+ report_totals = commit_report.totals
+ if report_totals is None:
+ report_totals = ReportLevelTotals(report_id=commit_report.id)
+ db_session.add(report_totals)
+
+ rounding: str = read_yaml_field(
+ self.current_yaml, ("coverage", "round"), "nearest"
+ )
+ precision: int = read_yaml_field(
+ self.current_yaml, ("coverage", "precision"), 2
+ )
+ report_totals.update_from_totals(
+ report.totals, precision=precision, rounding=rounding
+ )
+ db_session.flush()
+ log.info(
+ "Archived report",
+ extra=dict(
+ repoid=commit.repoid,
+ commit=commit.commitid,
+ url=chunks_url,
+ number_sessions=len(report.sessions),
+ new_report_sessions=dict(itertools.islice(report.sessions.items(), 20)),
+ ),
+ )
+ return {"url": chunks_url}
+
+ @sentry_sdk.trace
+ def save_full_report(
+ self, commit: Commit, report: Report, report_code=None
+ ) -> dict:
+ """
+ Saves the report (into database and storage) AND takes care of backfilling its sessions
+ like they were never in the database (useful for backfilling and carryforward cases)
+ """
+ rounding: str = read_yaml_field(
+ self.current_yaml, ("coverage", "round"), "nearest"
+ )
+ precision: int = read_yaml_field(
+ self.current_yaml, ("coverage", "precision"), 2
+ )
+ res = self.save_report(commit, report, report_code)
+ db_session = commit.get_db_session()
+ for sess_id, session in report.sessions.items():
+ upload = Upload(
+ build_code=session.build,
+ build_url=session.url,
+ env=session.env,
+ external_id=uuid.uuid4(),
+ job_code=session.job,
+ name=session.name[:100] if session.name is not None else None,
+ order_number=sess_id,
+ provider=session.provider,
+ report_id=commit.report.id_,
+ state="complete",
+ storage_path=session.archive if session.archive is not None else "",
+ upload_extras=session.session_extras or {},
+ upload_type=(
+ session.session_type.value
+ if session.session_type is not None
+ else "unknown"
+ ),
+ )
+ db_session.add(upload)
+ db_session.flush()
+ self._attach_flags_to_upload(upload, session.flags if session.flags else [])
+ if session.totals is not None:
+ upload_totals = UploadLevelTotals(upload_id=upload.id_)
+ db_session.add(upload_totals)
+ upload_totals.update_from_totals(
+ session.totals, precision=precision, rounding=rounding
+ )
+ db_session.flush()
+
+ return res
+
+
+@sentry_sdk.trace
+def delete_uploads_by_sessionid(
+ db_session: DbSession, report_id: int, session_ids: set[int]
+):
+ """
+ This deletes all the `Upload` records belonging to the `CommitReport` with `report_id`,
+ and having an `order_number` corresponding to the given `session_ids`.
+ """
+ uploads = (
+ db_session.query(Upload.id_)
+ .filter(
+ Upload.report_id == report_id,
+ Upload.upload_type == SessionType.carriedforward.value,
+ Upload.order_number.in_(session_ids),
+ )
+ .all()
+ )
+ upload_ids = [upload[0] for upload in uploads]
+
+ db_session.query(UploadError).filter(UploadError.upload_id.in_(upload_ids)).delete(
+ synchronize_session=False
+ )
+ db_session.query(UploadLevelTotals).filter(
+ UploadLevelTotals.upload_id.in_(upload_ids)
+ ).delete(synchronize_session=False)
+ db_session.query(uploadflagmembership).filter(
+ uploadflagmembership.c.upload_id.in_(upload_ids)
+ ).delete(synchronize_session=False)
+ db_session.query(Upload).filter(Upload.id_.in_(upload_ids)).delete(
+ synchronize_session=False
+ )
+ db_session.flush()
+
+
+def legacy_totals(report: Report) -> dict:
+ totals = dict(zip(TOTALS_MAP, report.totals))
+ totals["diff"] = report.diff_totals
+ return totals
diff --git a/apps/worker/services/report/fixes.py b/apps/worker/services/report/fixes.py
new file mode 100644
index 0000000000..0c137d5e55
--- /dev/null
+++ b/apps/worker/services/report/fixes.py
@@ -0,0 +1,68 @@
+from services.path_fixer import PathFixer
+
+
+def get_fixes_from_raw(content: str, fix: PathFixer) -> dict[str, dict]:
+ files: dict[str, dict] = {}
+ files_long_comments: dict[str, tuple[list[int], list[int]]] = {}
+ _cur_file = None
+
+ for line in content.splitlines():
+ if line:
+ try:
+ if line[:5] == "EOF: ":
+ _, line, filename = line.split(" ", 2)
+ files.setdefault(fix(filename), {})["eof"] = int(line)
+
+ else:
+ # filename:5
+ # filename:5,10,20
+ filename, line = line.split(":", 1)
+
+ if _cur_file != filename:
+ _cur_file = filename
+ _fixed = fix(filename)
+ if not _fixed:
+ continue
+ lines: set[int] = files.setdefault(_fixed, {"lines": set()})[
+ "lines"
+ ]
+
+ if ":" not in line:
+ # multi line
+ for sp in line.split(","):
+ lines.add(int(sp))
+
+ else:
+ ln, source = line.split(":", 1)
+ ln = int(ln)
+ source = source.strip()
+
+ if source[:2] == "/*" or "LCOV_EXCL_START" in source:
+ files_long_comments.setdefault(_fixed, ([], []))[0].append(
+ ln
+ )
+
+ elif (
+ source[-2:] == "*/"
+ or "LCOV_EXCL_STOP" in source
+ or "LCOV_EXCL_END" in source
+ ):
+ files_long_comments.setdefault(_fixed, ([], []))[1].append(
+ ln
+ )
+
+ lines.add(ln)
+
+ except Exception:
+ pass
+
+ for filename, (starts, stops) in files_long_comments.items():
+ if filename and starts and stops:
+ starts = sorted(starts)
+ stops = sorted(stops)
+ lines = files[filename]["lines"]
+ for x in range(len(starts)):
+ if len(stops) > x:
+ lines.update(range(int(starts[x]) + 1, int(stops[x])))
+
+ return files
diff --git a/apps/worker/services/report/languages/__init__.py b/apps/worker/services/report/languages/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/report/languages/base.py b/apps/worker/services/report/languages/base.py
new file mode 100644
index 0000000000..dd6de9d95f
--- /dev/null
+++ b/apps/worker/services/report/languages/base.py
@@ -0,0 +1,41 @@
+from typing import Any
+
+from services.report.report_builder import ReportBuilderSession
+
+
+class BaseLanguageProcessor(object):
+ def __init__(self, *args, **kwargs) -> None:
+ pass
+
+ def matches_content(self, content: Any, first_line: str, name: str) -> bool:
+ """
+ Determines whether this processor is capable of processing this file.
+
+ This is meant to be a high-level verification, and should not go through the whole file
+ to extensively check if everything is correct.
+
+ One example here is to check something on the first line, or check if a
+ certain key is present at the top-level json and has the right type of value under
+ it. Or maybe if a certain set ot XML tags that are unique to this format are here.
+
+ As long as this file can make sure to not accidentally try to parse formats that
+ belong with other processors, it is not a big deal (for now)
+
+ Args:
+ content (Any): The actual report content
+ first_line (str): The first line of the report, as a string
+ name (str): The filename of the report (as provided by the upload)
+ Returns:
+ bool: True if we can read this file, False otherwise
+ """
+ return False
+
+ def process(self, content: Any, report_builder_session: ReportBuilderSession):
+ """
+ Processes a report uploaded by the user, appending coverage information
+ to the provided `ReportBuilderSession`.
+
+ Raises:
+ ReportExpiredException: If the report is considered expired
+ """
+ pass
diff --git a/apps/worker/services/report/languages/bullseye.py b/apps/worker/services/report/languages/bullseye.py
new file mode 100644
index 0000000000..0b3dd6cb80
--- /dev/null
+++ b/apps/worker/services/report/languages/bullseye.py
@@ -0,0 +1,70 @@
+import sentry_sdk
+from lxml.etree import Element
+from timestring import Date
+
+from helpers.exceptions import ReportExpiredException
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+
+class BullseyeProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return "BullseyeCoverage" in content.tag
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ if max_age := report_builder_session.yaml_field(
+ ("codecov", "max_report_age"), "12h ago"
+ ):
+ build_id = xml.attrib.get("buildId")
+ # build_id format has timestamp at the end "4362c668_2020-10-28_17:55:47"
+ timestamp = " ".join(build_id.split("_")[1:])
+ if timestamp and Date(timestamp) < max_age:
+ raise ReportExpiredException("Bullseye report expired %s" % timestamp)
+
+ for folder in xml.iter("{https://www.bullseye.com/covxml}folder"):
+ for file in folder.iter("{https://www.bullseye.com/covxml}src"):
+ # Get filepath from parent folder(s)
+ filepath = ""
+ element = file
+ while element.getparent().tag == "{https://www.bullseye.com/covxml}folder":
+ element = element.getparent()
+ filepath = f"{element.attrib.get('name')}/{filepath}"
+ filepath += file.attrib.get("name")
+
+ _file = report_builder_session.create_coverage_file(filepath)
+ if _file is None:
+ continue
+
+ for function in file.iter("{https://www.bullseye.com/covxml}fn"):
+ for probe in function.iter("{https://www.bullseye.com/covxml}probe"):
+ attribs = probe.attrib
+ ln = int(attribs["line"])
+ if attribs["kind"] in ("condition", "decision", "switch-label"):
+ _type = CoverageType.branch
+
+ elif attribs["kind"] == "function":
+ _type = CoverageType.method
+ else:
+ _type = CoverageType.line
+
+ if attribs["event"] == "full":
+ coverage = 1
+ elif attribs["event"] == "none":
+ coverage = 0
+ else:
+ coverage = "1/2"
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ _type,
+ ),
+ )
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/clover.py b/apps/worker/services/report/languages/clover.py
new file mode 100644
index 0000000000..3010e3a0da
--- /dev/null
+++ b/apps/worker/services/report/languages/clover.py
@@ -0,0 +1,109 @@
+import sentry_sdk
+from lxml.etree import Element
+from timestring import Date
+
+from helpers.exceptions import ReportExpiredException
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+
+class CloverProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "coverage" and bool(content.attrib.get("generated"))
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def get_end_of_file(filename, xmlfile):
+ """
+ php reports have shown to include
+ exrta coverage data that extend
+ past the source code line count
+ """
+ if filename.endswith(".php"):
+ for metrics in xmlfile.iter("metrics"):
+ try:
+ return int(metrics.attrib["loc"])
+ except Exception:
+ pass
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ if max_age := report_builder_session.yaml_field(
+ ("codecov", "max_report_age"), "12h ago"
+ ):
+ try:
+ timestamp = next(xml.iter("coverage")).get("generated")
+ if "-" in timestamp:
+ t = timestamp.split("-")
+ timestamp = t[1] + "-" + t[0] + "-" + t[2]
+ if timestamp and Date(timestamp) < max_age:
+ # report expired over 12 hours ago
+ raise ReportExpiredException("Clover report expired %s" % timestamp)
+ except StopIteration:
+ pass
+
+ for file in xml.iter("file"):
+ filename = file.attrib.get("path") or file.attrib["name"]
+
+ # skip empty file documents
+ if (
+ "{" in filename
+ or ("/vendor/" in ("/" + filename) and filename.endswith(".php"))
+ or file.find("line") is None
+ ):
+ continue
+
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is None:
+ continue
+
+ # fix extra lines
+ eof = get_end_of_file(filename, file)
+
+ # process coverage
+ for line in file.iter("line"):
+ attribs = line.attrib
+ ln = int(attribs["num"])
+ complexity = None
+
+ # skip line
+ if ln < 1 or (eof and ln > eof):
+ continue
+
+ # [typescript] https://github.com/gotwarlost/istanbul/blob/89e338fcb1c8a7dea3b9e8f851aa55de2bc3abee/lib/report/clover.js#L108-L110
+ if attribs["type"] == "cond":
+ _type = CoverageType.branch
+ t, f = int(attribs["truecount"]), int(attribs["falsecount"])
+ if t == f == 0:
+ coverage = "0/2"
+ elif t == 0 or f == 0:
+ coverage = "1/2"
+ else:
+ coverage = "2/2"
+
+ elif attribs["type"] == "method":
+ coverage = int(attribs.get("count") or 0)
+ _type = CoverageType.method
+ complexity = int(attribs.get("complexity") or 0)
+ #
+
+ else:
+ coverage = int(attribs.get("count") or 0)
+ _type = CoverageType.line
+
+ # add line to report
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ _type,
+ complexity=complexity,
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/cobertura.py b/apps/worker/services/report/languages/cobertura.py
new file mode 100644
index 0000000000..a6e9d2e4ec
--- /dev/null
+++ b/apps/worker/services/report/languages/cobertura.py
@@ -0,0 +1,212 @@
+import logging
+import re
+from typing import Sequence
+
+import sentry_sdk
+from lxml.etree import Element
+from timestring import Date, TimestringInvalid
+
+from helpers.exceptions import ReportExpiredException
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+log = logging.getLogger(__name__)
+
+
+class CoberturaProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag in ("coverage", "scoverage")
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def Int(value):
+ try:
+ return int(value)
+ except ValueError:
+ return int(float(value))
+
+
+def get_sources_to_attempt(xml) -> Sequence[str]:
+ sources = (source.text for source in xml.iter("source"))
+ return tuple(s for s in sources if isinstance(s, str) and s.startswith("/"))
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ # # process timestamp
+ if max_age := report_builder_session.yaml_field(
+ ("codecov", "max_report_age"), "12h ago"
+ ):
+ try:
+ timestamp = xml.get("timestamp")
+ parsed_datetime = Date(timestamp)
+ is_valid_timestamp = True
+ except TimestringInvalid:
+ parsed_datetime = None
+ is_valid_timestamp = False
+
+ if timestamp and is_valid_timestamp and parsed_datetime < max_age:
+ # report expired over 12 hours ago
+ raise ReportExpiredException("Cobertura report expired " + timestamp)
+
+ handle_missing_conditions = report_builder_session.yaml_field(
+ ("parsers", "cobertura", "handle_missing_conditions"),
+ False,
+ )
+ partials_as_hits = report_builder_session.yaml_field(
+ ("parsers", "cobertura", "partials_as_hits"),
+ False,
+ )
+
+ for _class in xml.iter("class"):
+ filename = _class.attrib["filename"]
+ if not filename:
+ continue
+ _file = report_builder_session.create_coverage_file(filename, do_fix_path=False)
+ assert _file is not None, (
+ "`create_coverage_file` with pre-fixed path is infallible"
+ )
+
+ for line in _class.iter("line"):
+ _line = line.attrib
+ ln: str | int = _line["number"]
+ if ln == "undefined":
+ continue
+ ln = int(ln)
+ if ln > 0:
+ coverage: str | int
+ _type = CoverageType.line
+ missing_branches = None
+
+ # coverage
+ branch = _line.get("branch", "")
+ condition_coverage = _line.get("condition-coverage", "")
+ if (
+ branch.lower() == "true"
+ and re.search(r"\(\d+\/\d+\)", condition_coverage) is not None
+ ):
+ coverage = condition_coverage.split(" ", 1)[1][1:-1] # 1/2
+ _type = CoverageType.branch
+ else:
+ coverage = Int(_line.get("hits"))
+
+ # [python] [scoverage] [groovy] Conditions
+ conditions_text = _line.get("missing-branches", None)
+ if conditions_text:
+ conditions = conditions_text.split(",")
+ if len(conditions) > 1 and set(conditions) == set(("exit",)):
+ # python: "return [...] missed"
+ conditions = ["loop", "exit"]
+ missing_branches = conditions
+
+ else:
+ # [groovy] embedded conditions
+ conditions = [
+ "%(number)s:%(type)s" % _.attrib
+ for _ in line.iter("condition")
+ if _.attrib.get("coverage") != "100%"
+ ]
+ if handle_missing_conditions:
+ if isinstance(coverage, str):
+ covered_conditions, total_conditions = coverage.split("/")
+ if len(conditions) < int(total_conditions):
+ #
+ #
+ #
+ #
+ #
+
+ #
+
+ coverage_difference = int(total_conditions) - int(
+ covered_conditions
+ )
+ missing_condition_elements = range(
+ len(conditions), coverage_difference
+ )
+ conditions.extend(
+ [
+ str(condition)
+ for condition in missing_condition_elements
+ ]
+ )
+ else: # previous behaviour
+ if (
+ isinstance(coverage, str)
+ and coverage[0] == "0"
+ and len(conditions) < int(coverage.split("/")[1])
+ ):
+ #
+ #
+ #
+ #
+ #
+ conditions.extend(
+ map(
+ str,
+ range(len(conditions), int(coverage.split("/")[1])),
+ )
+ )
+ if conditions:
+ missing_branches = conditions
+ if (
+ isinstance(coverage, str)
+ and not coverage[0] == "0"
+ and partials_as_hits
+ ): # if coverage[0] is 0 this is a miss
+ missing_branches = None
+ coverage = 1
+ _type = CoverageType.line
+
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ _type,
+ missing_branches=missing_branches,
+ ),
+ )
+
+ # [scala] [scoverage]
+ for stmt in _class.iter("statement"):
+ # scoverage will have repeated data
+ attr = stmt.attrib
+ if attr.get("ignored") == "true":
+ continue
+ coverage = Int(attr["invocation-count"])
+ line_no = int(attr["line"])
+ coverage_type = CoverageType.line
+ if attr["branch"] == "true":
+ coverage_type = CoverageType.branch
+ elif attr["method"]:
+ coverage_type = CoverageType.method
+
+ _file.append(
+ line_no,
+ report_builder_session.create_coverage_line(
+ coverage,
+ coverage_type,
+ ),
+ )
+ report_builder_session.append(_file)
+
+ # path rename
+ path_fixer = report_builder_session.path_fixer
+ source_path_list = get_sources_to_attempt(xml)
+ path_name_fixing = []
+
+ for _class in xml.iter("class"):
+ filename = _class.attrib["filename"]
+ fixed_name = path_fixer(filename, bases_to_try=source_path_list)
+ path_name_fixing.append((filename, fixed_name))
+
+ # paths with `X-packages` should be sorted to the end
+ path_name_fixing.sort(
+ key=lambda a: "/dist-packages/" in a[0] or "/site-packages/" in a[0]
+ )
+
+ report_builder_session.resolve_paths(path_name_fixing)
diff --git a/apps/worker/services/report/languages/coveralls.py b/apps/worker/services/report/languages/coveralls.py
new file mode 100644
index 0000000000..804518d295
--- /dev/null
+++ b/apps/worker/services/report/languages/coveralls.py
@@ -0,0 +1,35 @@
+import orjson
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class CoverallsProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return "source_files" in content
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(report: dict, report_builder_session: ReportBuilderSession) -> None:
+ for file in report["source_files"]:
+ _file = report_builder_session.create_coverage_file(file["name"])
+ if _file is None:
+ continue
+
+ # for some reason, the `coverage` field is either a list directly,
+ # or a string with a json-encoded list.
+ coverage: str | list = file["coverage"]
+ if isinstance(coverage, str):
+ coverage = orjson.loads(coverage)
+
+ for ln, cov in enumerate(coverage, start=1):
+ if cov is not None:
+ _line = report_builder_session.create_coverage_line(cov)
+ _file.append(ln, _line)
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/csharp.py b/apps/worker/services/report/languages/csharp.py
new file mode 100644
index 0000000000..0133e68085
--- /dev/null
+++ b/apps/worker/services/report/languages/csharp.py
@@ -0,0 +1,117 @@
+from collections import defaultdict
+from itertools import repeat
+
+import sentry_sdk
+from lxml.etree import Element
+from shared.reports.resources import ReportFile
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+
+class CSharpProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "CoverageSession"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def _build_branches(branch_gen):
+ branches = defaultdict(list)
+ for branch in branch_gen:
+ if branch.attrib["vc"] == "0":
+ attribs = dict(branch.attrib)
+ if "sl" in attribs:
+ if attribs.get("offsetend") is not None:
+ branches[int(attribs["sl"])].append(
+ ("%(offset)s:%(offsetend)s" % attribs)
+ )
+ else:
+ branches[int(attribs["sl"])].append(("%(offset)s" % attribs))
+ return branches
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ """
+ https://github.com/OpenCover/opencover/issues/293#issuecomment-94598145
+ @sl - start line
+ @sc - start column
+ @el - end line
+ @ec - end column
+ @bec - branch count
+ @bev - branches executed
+ @vc - statement executed
+
+ """
+
+ file_by_id: dict[str, ReportFile] = {}
+ for f in xml.iter("File"):
+ filename = f.attrib["fullPath"].replace("\\", "/")
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is not None:
+ file_by_id[f.attrib["uid"]] = _file
+
+ for method in xml.iter("Method"):
+ fileref = method.find("FileRef")
+ if fileref is None:
+ continue
+ _file = file_by_id.get(fileref.attrib["uid"])
+ if _file is None:
+ continue
+
+ branches = _build_branches(method.iter("BranchPoint"))
+
+ for _type, node in zip(repeat(None), method.iter("SequencePoint")):
+ attrib = node.attrib.get
+ sl, el = attrib("sl"), attrib("el")
+ if sl and el:
+ complexity = (
+ int(attrib("cyclomaticComplexity", 0))
+ if _type == CoverageType.method
+ else None
+ )
+ sl, el = int(sl), int(el)
+ vc, bec = int(attrib("vc")), attrib("bec")
+ if bec is not None:
+ bev = attrib("bev")
+ if bec != "0":
+ coverage = "%s/%s" % (bev, bec)
+ _type = _type or CoverageType.branch
+ elif vc > 0:
+ coverage = vc
+ else:
+ coverage = 0
+ else:
+ coverage = vc
+
+ coverage_type = _type or CoverageType.line
+ # spans > 1 line
+ if el > sl:
+ for ln in range(sl, el + 1):
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ coverage_type,
+ missing_branches=branches.get(ln),
+ complexity=complexity,
+ ),
+ )
+ # spans = 1 line
+ else:
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ coverage,
+ coverage_type,
+ missing_branches=branches.get(sl),
+ complexity=complexity,
+ ),
+ )
+
+ for _file in file_by_id.values():
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/dlst.py b/apps/worker/services/report/languages/dlst.py
new file mode 100644
index 0000000000..79ed822629
--- /dev/null
+++ b/apps/worker/services/report/languages/dlst.py
@@ -0,0 +1,51 @@
+from io import BytesIO
+
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class DLSTProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: bytes, first_line: str, name: str) -> bool:
+ return content[-7:] == b"covered"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: bytes, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_string(content, report_builder_session)
+
+
+def from_string(string: bytes, report_builder_session: ReportBuilderSession) -> None:
+ filename = report_builder_session.filepath
+ if filename:
+ # src/file.lst => src/file.d
+ filename = report_builder_session.path_fixer("%sd" % filename[:-3])
+
+ if not filename:
+ # file.d => src/file.d
+ last_line = string[string.rfind(b"\n") :].decode(errors="replace").strip()
+ filename = last_line.split(" is ", 1)[0]
+ if filename.startswith("source "):
+ filename = filename[7:]
+
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is None:
+ return None
+
+ for ln, encoded_line in enumerate(BytesIO(string), start=1):
+ line = encoded_line.decode(errors="replace").rstrip("\n")
+ try:
+ coverage = int(line.split("|", 1)[0].strip())
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ ),
+ )
+ except Exception:
+ # not a vaild line
+ pass
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/elm.py b/apps/worker/services/report/languages/elm.py
new file mode 100644
index 0000000000..c7a59b325a
--- /dev/null
+++ b/apps/worker/services/report/languages/elm.py
@@ -0,0 +1,55 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class ElmProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return isinstance(content, dict) and bool(content.get("coverageData"))
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(json: dict, report_builder_session: ReportBuilderSession) -> None:
+ for name, data in json["coverageData"].items():
+ _file = report_builder_session.create_coverage_file(json["moduleMap"][name])
+ if _file is None:
+ continue
+
+ for sec in data:
+ cov = sec.get("count", 0)
+ complexity = sec.get("complexity")
+ sl, sc = sec["from"]["line"], sec["from"]["column"]
+ el, ec = sec["to"]["line"], sec["to"]["column"]
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ cov,
+ complexity=complexity,
+ partials=[[sc, ec if el == sl else None, cov]],
+ ),
+ )
+ if el > sl:
+ for ln in range(sl, el):
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ complexity=complexity,
+ ),
+ )
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ cov,
+ complexity=complexity,
+ partials=[[None, ec, cov]],
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/flowcover.py b/apps/worker/services/report/languages/flowcover.py
new file mode 100644
index 0000000000..ad9de2cd8b
--- /dev/null
+++ b/apps/worker/services/report/languages/flowcover.py
@@ -0,0 +1,54 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class FlowcoverProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return isinstance(content, dict) and bool(content.get("flowStatus"))
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(json: dict, report_builder_session: ReportBuilderSession) -> None:
+ for fn, data in json["files"].items():
+ _file = report_builder_session.create_coverage_file(fn)
+ if _file is None:
+ continue
+
+ for loc in data["expressions"].get("covered_locs", []):
+ start, end = loc["start"], loc["end"]
+ partials = (
+ [[start["column"], end["column"], 1]]
+ if start["line"] == end["line"]
+ else None
+ )
+ _file.append(
+ start["line"],
+ report_builder_session.create_coverage_line(
+ 1,
+ partials=partials,
+ ),
+ )
+
+ for loc in data["expressions"].get("uncovered_locs", []):
+ start, end = loc["start"], loc["end"]
+ partials = (
+ [[start["column"], end["column"], 0]]
+ if start["line"] == end["line"]
+ else None
+ )
+ _file.append(
+ start["line"],
+ report_builder_session.create_coverage_line(
+ 0,
+ partials=partials,
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/gap.py b/apps/worker/services/report/languages/gap.py
new file mode 100644
index 0000000000..4844bfb0fc
--- /dev/null
+++ b/apps/worker/services/report/languages/gap.py
@@ -0,0 +1,57 @@
+import typing
+from io import BytesIO
+
+import orjson
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class GapProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: typing.Any, first_line: str, name: str) -> bool:
+ try:
+ val = content if isinstance(content, dict) else orjson.loads(first_line)
+ return "Type" in val and "File" in val
+ except (TypeError, ValueError):
+ return False
+
+ @sentry_sdk.trace
+ def process(
+ self,
+ content: typing.Any,
+ report_builder_session: ReportBuilderSession,
+ ) -> None:
+ if isinstance(content, dict):
+ content = orjson.dumps(content)
+ if isinstance(content, str):
+ content = content.encode()
+ return from_string(content, report_builder_session)
+
+
+def from_string(string: bytes, report_builder_session: ReportBuilderSession) -> None:
+ _file = None
+ for encoded_line in BytesIO(string):
+ line_str = encoded_line.decode(errors="replace").rstrip("\n")
+ if not line_str:
+ continue
+
+ line = orjson.loads(line_str)
+ if line["Type"] == "S":
+ if _file is not None:
+ report_builder_session.append(_file)
+
+ _file = report_builder_session.create_coverage_file(line["File"])
+
+ elif _file is not None:
+ coverage = 0 if line["Type"] == "R" else 1
+ _file.append(
+ line["Line"],
+ report_builder_session.create_coverage_line(
+ coverage,
+ ),
+ )
+
+ # append last file
+ if _file is not None:
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/gcov.py b/apps/worker/services/report/languages/gcov.py
new file mode 100644
index 0000000000..074e1dc8da
--- /dev/null
+++ b/apps/worker/services/report/languages/gcov.py
@@ -0,0 +1,210 @@
+import re
+from collections import defaultdict
+from io import BytesIO
+
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+from services.yaml import read_yaml_field
+
+
+class GcovProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: bytes, first_line: str, name: str) -> bool:
+ return b"0:Source:" in content.split(b"\n", 1)[0]
+
+ @sentry_sdk.trace
+ def process(
+ self,
+ content: bytes,
+ report_builder_session: ReportBuilderSession,
+ ) -> None:
+ return from_txt(content, report_builder_session)
+
+
+ignored_lines = re.compile(r"(\{|\})(\s*\/\/.*)?").match
+detect_loop = re.compile(r"^\s+(for|while)\s?\(").match
+detect_conditional = re.compile(r"^\s+((if\s?\()|(\} else if\s?\())").match
+
+
+def from_txt(string: bytes, report_builder_session: ReportBuilderSession) -> None:
+ filepath = report_builder_session.filepath
+ path_fixer = report_builder_session.path_fixer
+
+ line_iterator = iter(BytesIO(string))
+ # clean and strip lines
+ filename = next(line_iterator).decode(errors="replace").rstrip("\n")
+ filename = filename.split(":")[3].lstrip("./")
+ if filepath and filepath.endswith(filename + ".gcov"):
+ filename = path_fixer(filepath[:-5]) or path_fixer(filename)
+ else:
+ filename = path_fixer(filename)
+ if not filename:
+ return None
+
+ settings = report_builder_session.yaml_field(("parsers", "gcov"))
+ detect_branches_in_methods = read_yaml_field(
+ settings, ("branch_detection", "method"), False
+ )
+ detect_branches_in_loops = read_yaml_field(
+ settings, ("branch_detection", "loop"), False
+ )
+ detect_branches_in_conditions = read_yaml_field(
+ settings, ("branch_detection", "conditional"), False
+ )
+ detect_branches_in_macros = read_yaml_field(
+ settings, ("branch_detection", "macro"), False
+ )
+
+ ignore = False
+ ln = None
+ next_is_func = False
+ data = None
+
+ _cur_branch_detected = None
+ _cur_line_branch = None
+ line_branches = {}
+ lines = defaultdict(list)
+ line_types = {}
+
+ for encoded_line in line_iterator:
+ line = encoded_line.decode(errors="replace").rstrip("\n")
+ if "LCOV_EXCL_START" in line:
+ ignore = True
+
+ elif "LCOV_EXCL_END" in line or "LCOV_EXCL_STOP" in line:
+ ignore = False
+
+ elif ignore:
+ pass
+
+ elif "LCOV_EXCL_LINE" in line:
+ pass
+
+ elif line[:4] == "func":
+ # for next line
+ next_is_func = True
+
+ elif line[:4] == "bran" and ln in lines:
+ if _cur_branch_detected is False:
+ # skip read_yaml_fielding/regexp checks because of repeated branchs
+ continue
+
+ elif _cur_branch_detected is None:
+ _cur_branch_detected = False # first set to false, prove me true
+
+ # class
+ if (
+ line_types[ln] == CoverageType.method
+ and not detect_branches_in_methods
+ ):
+ continue
+ # loop
+ elif detect_loop(data):
+ line_types[ln] = CoverageType.branch
+ if not detect_branches_in_loops:
+ continue
+ # conditional
+ elif detect_conditional(data):
+ line_types[ln] = CoverageType.branch
+ if not detect_branches_in_conditions:
+ continue
+ # else macro
+ elif not detect_branches_in_macros:
+ continue
+
+ _cur_branch_detected = True # proven true
+ _cur_line_branch = line_branches.setdefault(ln, [0, 0])
+
+ # add a hit
+ if "taken 0" not in line and "never executed" not in line:
+ _cur_line_branch[0] += 1
+
+ # add to total
+ _cur_line_branch[1] += 1
+
+ elif line[:4] == "call":
+ continue
+
+ else:
+ _cur_branch_detected = None
+ _cur_line_branch = None
+
+ line = line.split(":", 2)
+ if len(line) != 3:
+ ln = None
+ continue
+
+ elif line[2].strip() == "}":
+ # skip ending bracket lines
+ continue
+
+ elif line[2].startswith("@implementation"):
+ # skip @implementation string;
+ continue
+
+ if filename.endswith(".swift"):
+ # swift if reversed
+ ln, hit, data = tuple(line)
+ else:
+ hit, ln, data = tuple(line)
+
+ if ignored_lines(data):
+ # skip bracket lines
+ ln = None
+ continue
+
+ elif "-" in hit:
+ ln = None
+ continue
+
+ hit = hit.strip()
+ try:
+ ln = int(ln.strip())
+ except Exception:
+ continue
+
+ if hit == "#####":
+ if data.strip().startswith(("inline", "static")):
+ ln = None
+ continue
+
+ coverage = 0
+
+ elif hit == "=====":
+ coverage = 0
+
+ else:
+ try:
+ coverage = int(hit)
+ except Exception:
+ # https://app.getsentry.com/codecov/v4/issues/125373723/
+ ln = None
+ continue
+
+ if next_is_func:
+ line_types[ln] = CoverageType.method
+ else:
+ line_types[ln] = CoverageType.line
+ lines[ln].append(coverage)
+
+ next_is_func = False
+
+ _file = report_builder_session.create_coverage_file(filename, do_fix_path=False)
+ for ln, coverages in lines.items():
+ _type = line_types[ln]
+ branches = line_branches.get(ln)
+ if branches:
+ coverage = "%s/%s" % tuple(branches)
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(coverage, _type),
+ )
+ else:
+ for coverage in coverages:
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(coverage, _type),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/go.py b/apps/worker/services/report/languages/go.py
new file mode 100644
index 0000000000..1ccbfdbf14
--- /dev/null
+++ b/apps/worker/services/report/languages/go.py
@@ -0,0 +1,205 @@
+from collections import defaultdict
+from io import BytesIO
+from itertools import groupby
+
+import sentry_sdk
+from shared.utils import merge
+from shared.utils.merge import LineType, line_type, partials_to_line
+
+from helpers.exceptions import CorruptRawReportError
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.languages.helpers import Region, SourceLocation
+from services.report.report_builder import ReportBuilderSession
+
+
+class GoProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: bytes, first_line: str, name: str) -> bool:
+ return content[:6] == b"mode: " or ".go:" in first_line
+
+ @sentry_sdk.trace
+ def process(
+ self, content: bytes, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_txt(content, report_builder_session)
+
+
+def from_txt(string: bytes, report_builder_session: ReportBuilderSession) -> None:
+ partials_as_hits = report_builder_session.yaml_field(
+ ("parsers", "go", "partials_as_hits"),
+ False,
+ )
+
+ # Process the bytes from uploaded report to intermediary representation
+ files = process_bytes_into_files(string)
+
+ for filename, lines in files.items():
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is None:
+ continue
+
+ for ln, partials in lines.items():
+ best_in_partials = max(map(lambda p: p[2], partials))
+ partials = combine_partials(partials)
+ if partials:
+ cov = partials_to_line(partials)
+ cov_to_use = cov
+ else:
+ cov_to_use = best_in_partials
+ if partials_as_hits and line_type(cov_to_use) == LineType.partial:
+ cov_to_use = 1
+
+ _line = report_builder_session.create_coverage_line(cov_to_use)
+ _file.append(ln, _line)
+
+ report_builder_session.append(_file)
+
+
+def process_bytes_into_files(string: bytes) -> dict[str, dict[int, set]]:
+ """
+ mode: count
+ github.com/codecov/sample_go/sample_go.go:7.14,9.2 1 1
+ github.com/codecov/sample_go/sample_go.go:11.26,13.2 1 1
+ github.com/codecov/sample_go/sample_go.go:15.19,17.2 1 0
+
+ Ending bracket is here v
+ github.com/codecov/sample_go/sample_go.go:15.19,17.2 1 0
+
+ All other continuation > .2 should continue
+ github.com/codecov/sample_go/sample_go.go:15.19,17.9 1 0
+
+ Need to be cautious of customers who have reports merged in the following way:
+ FILE:1.0,2.0 1 0
+ ...
+ FILE:1.0,2.0 1 1
+ ...
+ FILE:1.0,2.0 1 0
+ Need to respect the coverage
+
+ Line format explanation:
+ - https://github.com/golang/go/blob/0104a31b8fbcbe52728a08867b26415d282c35d2/src/cmd/cover/profile.go#L56
+ - `name.go:line.column,line.column numberOfStatements count`
+ """
+
+ files: dict[str, dict[int, set]] = {}
+
+ for encoded_line in BytesIO(string):
+ line = encoded_line.decode(errors="replace").rstrip("\n")
+ if not line or line.startswith("mode: "):
+ continue
+
+ split = line.split(":", 1)
+ # File outline e.g., "github.com/nfisher/rsqf/rsqf.go:19: calcP 100.0%"
+ if len(split) < 2 or not split[1] or split[1].endswith("%"):
+ continue
+
+ filename = split[0]
+ try:
+ region = parse_coverage(split[1])
+ except ValueError:
+ # FIXME: do we actually want to raise an error here?
+ # Why not just skip over invalid lines, as the coverage file likely
+ # contains other valid lines we can use.
+ raise CorruptRawReportError(
+ "name.go:line.column,line.column numberOfStatements hits",
+ "Go coverage line does not match expected format",
+ )
+
+ lines = files.setdefault(filename, defaultdict(set))
+
+ # add start of line
+ if region.start.line == region.end.line:
+ lines[region.start.line].add(
+ (region.start.column, region.end.column, region.hits)
+ )
+ else:
+ lines[region.start.line].add((region.start.column, None, region.hits))
+ # add middles
+ for ln in range(region.start.line + 1, region.end.line):
+ lines[ln].add((0, None, region.hits))
+ if region.end.column > 2:
+ # add end of line
+ lines[region.end.line].add((None, region.end.column, region.hits))
+
+ return files
+
+
+def parse_coverage(line: str) -> Region:
+ region_str, _num_statements, hits = line.split(" ", 2)
+ start, end = region_str.split(",", 1)
+ start_line, start_column = start.split(".", 1)
+ end_line, end_column = end.split(".", 1)
+ return Region(
+ start=SourceLocation(line=int(start_line), column=int(start_column)),
+ end=SourceLocation(line=int(end_line), column=int(end_column)),
+ hits=int(hits),
+ )
+
+
+def combine_partials(partials):
+ """
+ [(INCLUSIVE, EXCLUSIVE, HITS), ...]
+ | . . . . . |
+ in: 0+ (2, None, 0)
+ in: 1 1 (1, 3, 1)
+ out: 1 1 1 0 0
+ out: 1 1 0+ (1, 3, 1), (4, None, 0)
+ """
+ # only 1 partial: return same
+ if len(partials) == 1:
+ return list(partials)
+
+ columns = defaultdict(list)
+ # fill in the partials WITH end values: (_, X, _)
+ for sc, ec, cov in partials:
+ if ec is not None:
+ for c in range(sc or 0, ec):
+ columns[c].append(cov)
+
+ # get the last column number (+1 for exclusiveness)
+ lc = (
+ max(columns.keys()) if columns else max([sc or 0 for (sc, ec, cov) in partials])
+ ) + 1
+ # hits for (lc, None, eol)
+ eol = []
+
+ # fill in the partials WITHOUT end values: (_, None, _)
+ for sc, ec, cov in partials:
+ if ec is None:
+ for c in range(sc or 0, lc):
+ columns[c].append(cov)
+ eol.append(cov)
+
+ columns = [(c, merge.merge_all(cov)) for c, cov in columns.items()]
+
+ # sum all the line hits && sort and group lines based on hits
+ columns = groupby(sorted(columns), lambda c: c[1])
+
+ results = []
+ for cov, cols in columns:
+ # unpack iter
+ cols = list(cols)
+ # sc from first column
+ # ec from last (or +1 if singular)
+ results.append([cols[0][0], (cols[-1] if cols else cols[0])[0] + 1, cov])
+
+ # remove duds
+ if results:
+ fp = results[0]
+ if fp[0] == 0 and fp[1] == 1:
+ results.pop(0)
+ if not results:
+ return [[0, None, fp[2]]]
+
+ # if there is eol data
+ if eol:
+ eol = merge.merge_all(eol)
+ # if the last partial ec == lc && same hits
+ lr = results[-1]
+ if lr[1] == lc and lr[2] == eol:
+ # then replace the last partial with no end
+ results[-1] = [lr[0], None, eol]
+ else:
+ # else append a new eol partial
+ results.append([lc, None, eol])
+
+ return results or None
diff --git a/apps/worker/services/report/languages/helpers.py b/apps/worker/services/report/languages/helpers.py
new file mode 100644
index 0000000000..9b66514553
--- /dev/null
+++ b/apps/worker/services/report/languages/helpers.py
@@ -0,0 +1,34 @@
+from dataclasses import dataclass
+
+from lxml.etree import Element
+
+
+def remove_non_ascii(string: str) -> str:
+ # ASCII control characters <=31, 127
+ # Extended ASCII characters: >=128
+ return "".join(c if 31 < ord(c) < 127 else "" for c in string)
+
+
+def child_text(parent: Element, element: str) -> str:
+ """
+ Returns the text content of the first element of type `element` of `parent`.
+
+ This defaults to the empty string if no child is found, or the child does not have any text.
+ """
+ child = parent.find(element)
+ if child is None:
+ return ""
+ return child.text or ""
+
+
+@dataclass
+class SourceLocation:
+ line: int
+ column: int
+
+
+@dataclass
+class Region:
+ start: SourceLocation
+ end: SourceLocation
+ hits: int
diff --git a/apps/worker/services/report/languages/jacoco.py b/apps/worker/services/report/languages/jacoco.py
new file mode 100644
index 0000000000..4bacd31beb
--- /dev/null
+++ b/apps/worker/services/report/languages/jacoco.py
@@ -0,0 +1,150 @@
+import logging
+from collections import defaultdict
+
+import sentry_sdk
+from lxml.etree import Element
+from shared.utils.merge import LineType, branch_type
+from timestring import Date
+
+from helpers.exceptions import ReportExpiredException
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+log = logging.getLogger(__name__)
+
+
+class JacocoProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "report"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ """
+ nr = line number
+ mi = missed instructions
+ ci = covered instructions
+ mb = missed branches
+ cb = covered branches
+ """
+ path_fixer = report_builder_session.path_fixer
+ if max_age := report_builder_session.yaml_field(
+ ("codecov", "max_report_age"), "12h ago"
+ ):
+ try:
+ timestamp = next(xml.iter("sessioninfo")).get("start")
+ if timestamp and Date(timestamp) < max_age:
+ # report expired over 12 hours ago
+ raise ReportExpiredException("Jacoco report expired %s" % timestamp)
+
+ except StopIteration:
+ pass
+
+ project = xml.attrib.get("name", "")
+ project = "" if " " in project else project.strip("/")
+
+ partials_as_hits = report_builder_session.yaml_field(
+ ("parsers", "jacoco", "partials_as_hits"), False
+ )
+
+ def try_to_fix_path(path: str) -> str | None:
+ if project:
+ # project/package/path
+ filename = path_fixer("%s/%s" % (project, path))
+ if filename:
+ return filename
+
+ # project/src/main/java/package/path
+ filename = path_fixer("%s/src/main/java/%s" % (project, path))
+ if filename:
+ return filename
+
+ # package/path
+ return path_fixer(path)
+
+ for package in xml.iter("package"):
+ base_name = package.attrib["name"]
+
+ file_method_complixity: dict[str, dict[int, tuple[int, int]]] = defaultdict(
+ dict
+ )
+ # Classes complexity
+ for _class in package.iter("class"):
+ class_name = _class.attrib["name"]
+ if "$" not in class_name:
+ method_complixity = file_method_complixity[class_name]
+ # Method Complexity
+ for method in _class.iter("method"):
+ ln = int(method.attrib.get("line", 0))
+ if ln > 0:
+ for counter in method.iter("counter"):
+ if counter.attrib["type"] == "COMPLEXITY":
+ m = int(counter.attrib["missed"])
+ c = int(counter.attrib["covered"])
+ method_complixity[ln] = (c, m + c)
+ break
+
+ # Statements
+ for source in package.iter("sourcefile"):
+ source_name = "%s/%s" % (base_name, source.attrib["name"])
+ filename = try_to_fix_path(source_name)
+ if filename is None:
+ continue
+
+ method_complixity = file_method_complixity[source_name.split(".")[0]]
+
+ _file = report_builder_session.create_coverage_file(
+ filename, do_fix_path=False
+ )
+ assert _file is not None, (
+ "`create_coverage_file` with pre-fixed path is infallible"
+ )
+
+ for line in source.iter("line"):
+ attr = line.attrib
+ cov: int | str
+ if attr["mb"] != "0":
+ cov = "%s/%s" % (attr["cb"], int(attr["mb"]) + int(attr["cb"]))
+ coverage_type = CoverageType.branch
+
+ elif attr["cb"] != "0":
+ cov = "%s/%s" % (attr["cb"], attr["cb"])
+ coverage_type = CoverageType.branch
+
+ else:
+ cov = int(attr["ci"])
+ coverage_type = CoverageType.line
+
+ if (
+ coverage_type == CoverageType.branch
+ and branch_type(cov) == LineType.partial
+ and partials_as_hits
+ ):
+ cov = 1
+
+ ln = int(attr["nr"])
+ if ln > 0:
+ complexity = method_complixity.get(ln)
+ if complexity:
+ coverage_type = CoverageType.method
+ # add line to file
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ coverage_type,
+ complexity=complexity,
+ ),
+ )
+ else:
+ log.warning(
+ f"Jacoco report has an invalid coverage line: nr={ln}. Skipping processing line."
+ )
+
+ # append file to report
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/jetbrainsxml.py b/apps/worker/services/report/languages/jetbrainsxml.py
new file mode 100644
index 0000000000..1bf6bbf5ad
--- /dev/null
+++ b/apps/worker/services/report/languages/jetbrainsxml.py
@@ -0,0 +1,55 @@
+import sentry_sdk
+from lxml.etree import Element
+from shared.reports.resources import ReportFile
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class JetBrainsXMLProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "Root"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ file_by_id: dict[str, ReportFile] = {}
+ for f in xml.iter("File"):
+ filename = f.attrib["Name"].replace("\\", "/")
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is not None:
+ file_by_id[str(f.attrib["Index"])] = _file
+
+ for statement in xml.iter("Statement"):
+ _file = file_by_id.get(str(statement.attrib["FileIndex"]))
+ if _file is None:
+ continue
+
+ sl = int(statement.attrib["Line"])
+ el = int(statement.attrib["EndLine"])
+ sc = int(statement.attrib["Column"])
+ ec = int(statement.attrib["EndColumn"])
+ cov = 1 if statement.attrib["Covered"] == "True" else 0
+ if sl == el:
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ cov,
+ partials=[[sc, ec, cov]],
+ ),
+ )
+ else:
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ cov,
+ ),
+ )
+
+ for content in file_by_id.values():
+ report_builder_session.append(content)
diff --git a/apps/worker/services/report/languages/lcov.py b/apps/worker/services/report/languages/lcov.py
new file mode 100644
index 0000000000..319e7e7cbb
--- /dev/null
+++ b/apps/worker/services/report/languages/lcov.py
@@ -0,0 +1,195 @@
+import logging
+from collections import defaultdict
+from decimal import Decimal, InvalidOperation
+from io import BytesIO
+
+import sentry_sdk
+from shared.reports.resources import ReportFile
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+log = logging.getLogger(__name__)
+
+
+class LcovProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: bytes, first_line: str, name: str) -> bool:
+ return b"\nend_of_record" in content
+
+ @sentry_sdk.trace
+ def process(
+ self, content: bytes, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_txt(content, report_builder_session)
+
+
+def from_txt(reports: bytes, report_builder_session: ReportBuilderSession) -> None:
+ # http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php
+ # merge same files
+ for string in reports.split(b"\nend_of_record"):
+ if (_file := _process_file(string, report_builder_session)) is not None:
+ report_builder_session.append(_file)
+
+
+def _process_file(
+ doc: bytes, report_builder_session: ReportBuilderSession
+) -> ReportFile | None:
+ branches: dict[str, dict[str, int]] = defaultdict(dict)
+ fn_lines: set[str] = set() # lines of function definitions
+
+ JS = False
+ CPP = False
+ skip_lines: list[str] = []
+ _file: ReportFile | None = None
+
+ for encoded_line in BytesIO(doc):
+ line = encoded_line.decode(errors="replace").rstrip("\n")
+ if line == "" or ":" not in line:
+ continue
+
+ method, content = line.split(":", 1)
+ content = content.strip()
+ if method in ("TN", "LF", "LH", "FNF", "FNH", "BRF", "BRH", "FNDA"):
+ # TN: test title
+ # LF: lines found
+ # LH: lines hit
+ # FNF: functions found
+ # FNH: functions hit
+ # BRF: branches found
+ # BRH: branches hit
+ # FNDA: function data
+ continue
+
+ if method == "SF":
+ """
+ For each source file referenced in the .da file, there is a section
+ containing filename and coverage data:
+
+ SF:
+ """
+ # file name
+ _file = report_builder_session.create_coverage_file(content)
+ JS = content[-3:] == ".js"
+ CPP = content[-4:] == ".cpp"
+ continue
+
+ if _file is None:
+ return None
+
+ if method == "DA":
+ """
+ Then there is a list of execution counts for each instrumented line
+ (i.e. a line which resulted in executable code):
+
+ DA:,[,]
+ """
+ # DA:,[,]
+ if line.startswith("undefined,"):
+ continue
+
+ split = content.split(",", 2)
+ if len(split) < 2:
+ continue
+ line_str = split[0]
+ hit = split[1]
+
+ if line_str in ("", "undefined") or hit in ("", "undefined"):
+ continue
+ if line_str[0] in ("0", "n") or hit[0] in ("=", "s"):
+ continue
+
+ try:
+ ln = int(line_str)
+ cov = parse_int(hit)
+ except (ValueError, InvalidOperation):
+ continue
+
+ cov = max(cov, 0) # clamp to 0
+
+ _line = report_builder_session.create_coverage_line(cov)
+ _file.append(ln, _line)
+
+ elif method == "FN" and not JS:
+ """
+ Following is a list of line numbers for each function name found in the
+ source file:
+
+ FN:,
+ """
+
+ split = content.split(",", 1)
+ if len(split) < 2:
+ continue
+ line_str, name = split
+
+ if CPP and name[:2] in ("_Z", "_G"):
+ skip_lines.append(line_str)
+ continue
+
+ fn_lines.add(line_str)
+
+ elif method == "BRDA" and not JS:
+ """
+ Branch coverage information is stored with one line per branch:
+
+ BRDA:,,,
+
+ Block number and branch number are gcc internal IDs for the branch.
+ Taken is either "-" if the basic block containing the branch was never
+ executed or a number indicating how often that branch was taken.
+ """
+ # BRDA:,,,
+ split = content.split(",", 3)
+ if len(split) < 4:
+ continue
+ line_str, block, branch, taken = split
+
+ if line_str == "1" and _file.name.endswith(".ts"):
+ continue
+
+ elif line_str not in ("0", ""):
+ branches[line_str]["%s:%s" % (block, branch)] = (
+ 0 if taken in ("-", "0") else 1
+ )
+
+ if _file is None:
+ return None
+
+ # remove skipped branches
+ for sl in skip_lines:
+ branches.pop(sl, None)
+
+ # work branches
+ for line_str, br in branches.items():
+ try:
+ ln = int(line_str)
+ except ValueError:
+ continue
+
+ branch_num = len(br.values())
+ branch_sum = sum(br.values())
+ missing_branches = [bid for bid, cov in br.items() if cov == 0]
+
+ coverage = f"{branch_sum}/{branch_num}"
+ coverage_type = (
+ CoverageType.method if line_str in fn_lines else CoverageType.branch
+ )
+
+ _line = report_builder_session.create_coverage_line(
+ coverage,
+ coverage_type,
+ missing_branches=(missing_branches if missing_branches != [] else None),
+ )
+ # instead of using `.append`/merge, this rather overwrites the line:
+ _file[ln] = _line
+
+ return _file
+
+
+def parse_int(n: str) -> int:
+ if n.isnumeric():
+ return int(n)
+
+ # Huge ints may be expressed in scientific notation.
+ # int(float(hit)) may lose precision, but Decimal shouldn't.
+ return int(Decimal(n))
diff --git a/apps/worker/services/report/languages/lua.py b/apps/worker/services/report/languages/lua.py
new file mode 100644
index 0000000000..6a835780e7
--- /dev/null
+++ b/apps/worker/services/report/languages/lua.py
@@ -0,0 +1,48 @@
+import re
+
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class LuaProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: bytes, first_line: str, name: str) -> bool:
+ return content[:7] == b"======="
+
+ @sentry_sdk.trace
+ def process(
+ self, content: bytes, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_txt(content, report_builder_session)
+
+
+docs = re.compile(r"^=+\n", re.M).split
+
+
+def from_txt(input: bytes, report_builder_session: ReportBuilderSession) -> None:
+ _file = None
+ for line in docs(input.decode(errors="replace").replace("\t", " ")):
+ line = line.rstrip()
+ if line == "Summary":
+ _file = None
+
+ elif line.endswith((".lua", ".lisp")):
+ _file = report_builder_session.create_coverage_file(line)
+
+ elif _file is not None:
+ for ln, source in enumerate(line.splitlines(), start=1):
+ try:
+ cov = source.strip().split(" ")[0]
+ cov = 0 if cov[-2:] in ("*0", "0") else int(cov)
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ ),
+ )
+
+ except Exception:
+ pass
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/mono.py b/apps/worker/services/report/languages/mono.py
new file mode 100644
index 0000000000..cfdd25eb27
--- /dev/null
+++ b/apps/worker/services/report/languages/mono.py
@@ -0,0 +1,48 @@
+import sentry_sdk
+from lxml.etree import Element
+from shared.reports.resources import ReportFile
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class MonoProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "coverage" and content.find("assembly") is not None
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ files: dict[str, ReportFile | None] = {}
+
+ # loop through methods
+ for method in xml.iter("method"):
+ filename = method.attrib["filename"]
+ if filename not in files:
+ _file = report_builder_session.create_coverage_file(filename)
+ files[filename] = _file
+
+ _file = files[filename]
+ if _file is None:
+ continue
+
+ # loop through statements
+ for line in method.iter("statement"):
+ attr = line.attrib
+ coverage = int(attr["counter"])
+
+ _file.append(
+ int(attr["line"]),
+ report_builder_session.create_coverage_line(
+ coverage,
+ ),
+ )
+
+ for _file in files.values():
+ if _file is not None:
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/node.py b/apps/worker/services/report/languages/node.py
new file mode 100644
index 0000000000..323377a97b
--- /dev/null
+++ b/apps/worker/services/report/languages/node.py
@@ -0,0 +1,404 @@
+from collections import defaultdict
+from fractions import Fraction
+
+import sentry_sdk
+from shared.reports.resources import ReportFile
+from shared.utils.merge import partials_to_line
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+
+class NodeProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return isinstance(content, dict) and all(
+ isinstance(data, dict) for data in content.values()
+ )
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def get_line_coverage(location, cov, line_type):
+ if location.get("skip"):
+ return None, None, None
+
+ sl, sc, el, ec = get_location(location)
+
+ if not sl or (sc + 1 == ec and sl == el):
+ return None, None, None
+
+ if line_type != "m" and sl == el and sc != 0:
+ partial = [sc, ec, cov]
+ else:
+ partial = None
+
+ return sl, cov, partial
+
+
+def get_location(node):
+ try:
+ if "loc" in node:
+ return (
+ node["loc"]["start"]["line"],
+ node["loc"]["start"]["column"],
+ node["loc"]["end"]["line"],
+ node["loc"]["end"]["column"],
+ )
+ elif "start" in node:
+ return (
+ node["start"]["line"],
+ node["start"]["column"],
+ node["end"]["line"],
+ node["end"]["column"],
+ )
+ else:
+ return (
+ node["locations"][0]["start"]["line"],
+ node["locations"][0]["start"]["column"],
+ node["locations"][-1]["end"]["line"],
+ node["locations"][-1]["end"]["column"],
+ )
+ except Exception:
+ return (None, None, None, None)
+
+
+def must_be_dict(value):
+ if not isinstance(value, dict):
+ return {}
+ else:
+ return value
+
+
+def next_from_json(
+ report_dict: dict, report_builder_session: ReportBuilderSession
+) -> None:
+ path_fixer = report_builder_session.path_fixer
+
+ for filename, data in report_dict.items():
+ filename = path_fixer(filename) or path_fixer(
+ filename.replace("lib/", "src/", 1)
+ )
+ if filename is None:
+ continue
+ _file = report_builder_session.create_coverage_file(filename, do_fix_path=False)
+
+ if "lineData" in data:
+ jscoverage(_file, data, report_builder_session)
+ report_builder_session.append(_file)
+ continue
+
+ if data.get("data"):
+ # why. idk. node is like that.
+ data = data["data"]
+
+ ifs = {}
+ _ifends = {}
+ for bid, branch in must_be_dict(data.get("branchMap")).items():
+ if branch.get("skip") is True:
+ continue
+ if branch.get("type") == "if":
+ # first skip ifs
+ sl, sc, el, ec = get_location(branch)
+ ifs[sl] = (sc, el, ec)
+
+ else:
+ line_parts = defaultdict(list)
+ line_cov = defaultdict(list)
+ for lid, location in enumerate(branch["locations"]):
+ ln, cov, partials = get_line_coverage(
+ location, data["b"][bid][lid], "b"
+ )
+ if ln:
+ line_parts[ln].append(partials)
+ line_cov[ln].append(cov)
+ if ln == location["end"]["line"]:
+ _ifends[location["end"]["line"]] = location["end"]["column"]
+
+ for ln, partials in line_parts.items():
+ partials = list(filter(None, partials))
+ if len(partials) > 1:
+ branches = [
+ str(i)
+ for i, partial in enumerate(partials)
+ if partial and partial[2] == 0
+ ]
+ cov = "%d/%d" % (
+ len(partials) - len(branches),
+ len(partials),
+ )
+ partials = sorted(partials, key=lambda p: p[0])
+ else:
+ branches = None
+ cov = line_cov[ln][0]
+ partials = None
+
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ partials=partials,
+ missing_branches=branches,
+ ),
+ )
+
+ # statements
+ inlines = {}
+ line_parts = defaultdict(list)
+ line_cov = defaultdict(list)
+ for sid, statement in must_be_dict(data.get("statementMap")).items():
+ if statement.get("skip") is True:
+ continue
+
+ ln, cov, partials = get_line_coverage(statement, data["s"][sid], None)
+ if ln:
+ sl, sc, el, ec = get_location(statement)
+ if ifs.get(ln) == (sc, el, ec):
+ # we will chop it of later
+ if partials:
+ inlines[ln] = partials
+
+ else:
+ line_parts[ln].append(partials)
+ line_cov[ln].append(cov)
+
+ for ln, partials in line_parts.items():
+ partials = sorted(filter(None, partials), key=lambda p: p[0])
+ cov = line_cov[ln][0]
+ line = _file.get(ln)
+ if line and line.sessions[0].partials is not None:
+ continue
+ else:
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ partials=partials,
+ ),
+ )
+
+ # NOTE: All the modifications done here rely on *overwriting* the given line record using index notation.
+ # Using `.append` in this block of code will lead to wrong results as it would *merge* the records.
+ for bid, branch in must_be_dict(data.get("branchMap")).items():
+ # single stmt ifs only
+ if branch.get("skip") is True or branch.get("type") != "if":
+ continue
+
+ sl, sc, el, ec = get_location(branch)
+ if sl:
+ branches = data["b"][bid]
+ tb = len(branches)
+ cov = "%s/%s" % (tb - branches.count(0), tb)
+ mb = [str(i) for i, b in enumerate(branches) if b == 0]
+
+ line = _file.get(sl)
+ if line:
+ inline_part = inlines.pop(sl, None)
+ if inline_part:
+ cur_partials = line.sessions[-1].partials
+ if not cur_partials:
+ _, cov, partials = get_line_coverage(branch, cov, "b")
+ _file[sl] = report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ missing_branches=mb,
+ partials=partials,
+ )
+ continue
+
+ sc, ec = sc + 4, cur_partials[0][0] - 2
+ _isc, iec, icov = inline_part
+ if sc > ec:
+ cur_partials.append([cur_partials[-1][1] + 2, iec, icov])
+ _file[sl] = report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ missing_branches=mb,
+ partials=cur_partials,
+ )
+ else:
+ partials = [[sc, ec, cov]]
+ for p in cur_partials:
+ if (p[0], p[1]) != (ec + 2, iec):
+ # add these partials
+ partials.append(p)
+ elif p[2] == 0 or isinstance(p[2], str):
+ # dont add trimmed, this part was missed
+ partials.append(p)
+ else:
+ partials.append([ec + 2, iec, icov])
+ _file[sl] = report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ missing_branches=mb,
+ partials=sorted(partials, key=lambda p: p[0]),
+ )
+
+ else:
+ # if ( exp && expr )
+ # change to branch
+ _file[sl] = report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ missing_branches=mb,
+ partials=_file[sl].sessions[-1].partials,
+ )
+
+ else:
+ _file.append(
+ sl,
+ report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ missing_branches=mb,
+ ),
+ )
+
+ for fid, func in must_be_dict(data["fnMap"]).items():
+ if func.get("skip") is not True:
+ ln, cov, partials = get_line_coverage(func, data["f"][fid], "m")
+ if ln:
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.method,
+ ),
+ )
+
+ report_builder_session.append(_file)
+
+
+def _location_to_int(location: dict) -> int | None:
+ if "loc" in location:
+ location = location["loc"]
+
+ if location.get("skip"):
+ return None
+
+ elif location["start"].get("line", 0) == 0:
+ return None
+
+ return int(location["start"]["line"])
+
+
+def _jscoverage_eval_partial(partial):
+ return [
+ partial["position"],
+ partial["position"] + partial["nodeLength"],
+ Fraction(
+ "{0}/2".format(
+ (1 if partial["evalTrue"] else 0) + (1 if partial["evalFalse"] else 0)
+ )
+ ),
+ # It seems like the above line on Python2 would make something in `partials_to_line` always return True
+ ]
+
+
+def jscoverage(
+ _file: ReportFile, data: dict, report_builder_session: ReportBuilderSession
+):
+ branches = {
+ ln: map(_jscoverage_eval_partial, branchData[1:])
+ for ln, branchData in must_be_dict(data["branchData"]).items()
+ }
+
+ for ln, coverage in enumerate(data["lineData"]):
+ if coverage is not None:
+ partials = branches.get(str(ln))
+ if partials:
+ partials = list(partials)
+ coverage = partials_to_line(partials)
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ coverage,
+ CoverageType.branch if partials else CoverageType.line,
+ partials=partials,
+ ),
+ )
+
+
+def from_json(report_dict: dict, report_builder_session: ReportBuilderSession) -> None:
+ enable_partials = report_builder_session.yaml_field(
+ ("parsers", "javascript", "enable_partials"),
+ False,
+ )
+ path_fixer = report_builder_session.path_fixer
+
+ if enable_partials:
+ if next(iter(report_dict.items()))[0].endswith(".js"):
+ # only javascript is supported ATM
+ return next_from_json(report_dict, report_builder_session)
+
+ for filename, data in report_dict.items():
+ filename = path_fixer(filename) or path_fixer(
+ filename.replace("lib/", "src/", 1)
+ )
+ if filename is None:
+ continue
+ _file = report_builder_session.create_coverage_file(filename, do_fix_path=False)
+
+ if data.get("data"):
+ # why. idk. node is like that.
+ data = data["data"]
+
+ if "lineData" in data:
+ jscoverage(_file, data, report_builder_session)
+ report_builder_session.append(_file)
+ continue
+
+ if "linesCovered" in data:
+ for ln, coverage in data["linesCovered"].items():
+ _file.append(
+ int(ln),
+ report_builder_session.create_coverage_line(
+ coverage,
+ ),
+ )
+ report_builder_session.append(_file)
+ continue
+
+ # statements
+ for sid, statement in must_be_dict(data.get("statementMap")).items():
+ if statement.get("skip") is not True:
+ location_int = _location_to_int(statement)
+ if location_int:
+ _file.append(
+ location_int,
+ report_builder_session.create_coverage_line(
+ data["s"][sid],
+ ),
+ )
+
+ for bid, branch in must_be_dict(data.get("branchMap")).items():
+ if branch.get("skip") is not True:
+ # [FUTURE] we can record branch positions in the session
+ for lid, location in enumerate(branch["locations"]):
+ location_int = _location_to_int(location)
+ if location_int:
+ _file.append(
+ location_int,
+ report_builder_session.create_coverage_line(
+ data["b"][bid][lid],
+ CoverageType.branch,
+ ),
+ )
+
+ for fid, func in must_be_dict(data.get("fnMap")).items():
+ if func.get("skip") is not True:
+ location_int = _location_to_int(func["loc"])
+ if location_int:
+ _file.append(
+ location_int,
+ report_builder_session.create_coverage_line(
+ data["f"][fid],
+ CoverageType.method,
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/pycoverage.py b/apps/worker/services/report/languages/pycoverage.py
new file mode 100644
index 0000000000..2bab823ce5
--- /dev/null
+++ b/apps/worker/services/report/languages/pycoverage.py
@@ -0,0 +1,53 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession, SpecialLabelsEnum
+
+COVERAGE_HIT = 1
+COVERAGE_MISS = 0
+
+
+class PyCoverageProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ meta = "meta" in content and content["meta"]
+ return "files" in content and isinstance(meta, dict) and "show_contexts" in meta
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ labels_table = content.get("labels_table", {})
+
+ for filename, file_coverage in content["files"].items():
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is None:
+ continue
+
+ lines_and_coverage = [
+ (COVERAGE_HIT, ln) for ln in file_coverage["executed_lines"]
+ ] + [(COVERAGE_MISS, ln) for ln in file_coverage["missing_lines"]]
+ for cov, ln in lines_and_coverage:
+ if ln > 0:
+ label_list_of_lists = [
+ [_normalize_label(labels_table, testname)]
+ for testname in file_coverage.get("contexts", {}).get(
+ str(ln), []
+ )
+ ]
+ _line = report_builder_session.create_coverage_line(
+ cov,
+ labels_list_of_lists=label_list_of_lists,
+ )
+ _file.append(ln, _line)
+ report_builder_session.append(_file)
+
+
+def _normalize_label(labels_table: dict[str, str], testname: int | float | str) -> str:
+ if isinstance(testname, int) or isinstance(testname, float):
+ # This is from a compressed report.
+ # Pull label from the labels_table
+ # But the labels_table keys are strings, because of JSON format
+ testname = labels_table[str(testname)]
+ if testname == "":
+ return SpecialLabelsEnum.CODECOV_ALL_LABELS_PLACEHOLDER.corresponding_label
+ return testname.split("|", 1)[0]
diff --git a/apps/worker/services/report/languages/rlang.py b/apps/worker/services/report/languages/rlang.py
new file mode 100644
index 0000000000..dd95f2ee63
--- /dev/null
+++ b/apps/worker/services/report/languages/rlang.py
@@ -0,0 +1,42 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class RlangProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return isinstance(content, dict) and content.get("uploader") == "R"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(data_dict: dict, report_builder_session: ReportBuilderSession) -> None:
+ """
+ Report example
+
+ uploader: R
+ files: []
+ name:
+ coverage: [null]
+ """
+
+ for data in data_dict["files"]:
+ _file = report_builder_session.create_coverage_file(data["name"])
+ if _file is None:
+ continue
+
+ for ln, cov in enumerate(data["coverage"]):
+ if cov is not None:
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ int(cov),
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/salesforce.py b/apps/worker/services/report/languages/salesforce.py
new file mode 100644
index 0000000000..37db59bec9
--- /dev/null
+++ b/apps/worker/services/report/languages/salesforce.py
@@ -0,0 +1,32 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class SalesforceProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: list, first_line: str, name: str) -> bool:
+ return bool(content) and isinstance(content, list) and "name" in content[0]
+
+ @sentry_sdk.trace
+ def process(
+ self, content: list, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(json: list, report_builder_session: ReportBuilderSession) -> None:
+ for obj in json:
+ if obj and obj.get("name") and obj.get("lines"):
+ filename = obj["name"] + (".cls" if "." not in obj["name"] else "")
+ _file = report_builder_session.create_coverage_file(filename)
+ if _file is None:
+ continue
+
+ for ln, cov in obj["lines"].items():
+ _file.append(
+ int(ln),
+ report_builder_session.create_coverage_line(cov),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/scala.py b/apps/worker/services/report/languages/scala.py
new file mode 100644
index 0000000000..30c0d9756e
--- /dev/null
+++ b/apps/worker/services/report/languages/scala.py
@@ -0,0 +1,30 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class ScalaProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return "fileReports" in content
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(data_dict: dict, report_builder_session: ReportBuilderSession) -> None:
+ for f in data_dict["fileReports"]:
+ _file = report_builder_session.create_coverage_file(f["filename"])
+ if _file is None:
+ continue
+
+ for ln, cov in f["coverage"].items():
+ _file.append(
+ int(ln),
+ report_builder_session.create_coverage_line(cov),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/scoverage.py b/apps/worker/services/report/languages/scoverage.py
new file mode 100644
index 0000000000..a200abfac1
--- /dev/null
+++ b/apps/worker/services/report/languages/scoverage.py
@@ -0,0 +1,61 @@
+import sentry_sdk
+from lxml.etree import Element
+from shared.helpers.numeric import maxint
+from shared.reports.resources import ReportFile
+
+from services.report.report_builder import CoverageType, ReportBuilderSession
+
+from .base import BaseLanguageProcessor
+from .helpers import child_text
+
+
+class SCoverageProcessor(BaseLanguageProcessor):
+ def matches_content(self, content: Element, first_line: str, name: str) -> bool:
+ return content.tag == "statements"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: Element, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_xml(content, report_builder_session)
+
+
+def from_xml(xml: Element, report_builder_session: ReportBuilderSession) -> None:
+ files: dict[str, ReportFile | None] = {}
+ for statement in xml.iter("statement"):
+ filename = child_text(statement, "source")
+ if filename not in files:
+ files[filename] = report_builder_session.create_coverage_file(filename)
+
+ _file = files.get(filename)
+ if _file is None:
+ continue
+
+ # Add the line
+ ln = int(child_text(statement, "line"))
+ hits = child_text(statement, "count")
+
+ if child_text(statement, "ignored") == "true":
+ continue
+
+ if child_text(statement, "branch") == "true":
+ cov = "%s/2" % hits
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ CoverageType.branch,
+ ),
+ )
+ else:
+ cov = maxint(hits)
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ ),
+ )
+
+ for _file in files.values():
+ if _file is not None:
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/simplecov.py b/apps/worker/services/report/languages/simplecov.py
new file mode 100644
index 0000000000..fa19723f2f
--- /dev/null
+++ b/apps/worker/services/report/languages/simplecov.py
@@ -0,0 +1,53 @@
+import sentry_sdk
+
+from services.report.languages.base import BaseLanguageProcessor
+from services.report.report_builder import ReportBuilderSession
+
+
+class SimplecovProcessor(BaseLanguageProcessor):
+ """
+ Handles processing of coverage reports generated by Simplecov (https://github.com/simplecov-ruby/simplecov)
+ The JSON formatter this processor expects is simplecov-json (https://github.com/vicentllongo/simplecov-json)
+
+ """
+
+ def matches_content(self, content: dict, first_line: str, name: str) -> bool:
+ return isinstance(content, dict) and content.get("command_name") == "RSpec"
+
+ @sentry_sdk.trace
+ def process(
+ self, content: dict, report_builder_session: ReportBuilderSession
+ ) -> None:
+ return from_json(content, report_builder_session)
+
+
+def from_json(json: dict, report_builder_session: ReportBuilderSession) -> None:
+ for data in json["files"]:
+ _file = report_builder_session.create_coverage_file(data["filename"])
+ if _file is None:
+ continue
+
+ # Structure depends on which Simplecov version was used so we need to handle either structure
+ coverage = data["coverage"]
+ coverage_to_check = (
+ coverage["lines"]
+ if isinstance(coverage, dict)
+ and coverage.get("lines") # Simplecov version >= 0.18
+ else coverage # Simplecov version < 0.18
+ )
+
+ for ln, cov in enumerate(coverage_to_check, start=1):
+ if cov == "ignored":
+ # Lines that simplecov skipped are recorded as "ignored" by
+ # https://github.com/codeclimate-community/simplecov_json_formatter
+ # and we in turn record that as -1 which indicates a skipped line
+ # in our report
+ cov = -1
+ _file.append(
+ ln,
+ report_builder_session.create_coverage_line(
+ cov,
+ ),
+ )
+
+ report_builder_session.append(_file)
diff --git a/apps/worker/services/report/languages/tests/__init__.py b/apps/worker/services/report/languages/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/worker/services/report/languages/tests/unit/__init__.py b/apps/worker/services/report/languages/tests/unit/__init__.py
new file mode 100644
index 0000000000..d3b12bf7d7
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/__init__.py
@@ -0,0 +1,19 @@
+from services.path_fixer import PathFixer
+from services.report.report_builder import ReportBuilder, ReportBuilderSession
+
+
+def create_report_builder_session(
+ path_fixer: PathFixer | None = None,
+ filename: str = "filename",
+ current_yaml: dict | None = None,
+) -> ReportBuilderSession:
+ def fixes(filename, bases_to_try=None):
+ return filename
+
+ report_builder = ReportBuilder(
+ path_fixer=path_fixer or fixes,
+ ignored_lines={},
+ sessionid=0,
+ current_yaml=current_yaml,
+ )
+ return report_builder.create_report_builder_session(filename)
diff --git a/apps/worker/services/report/languages/tests/unit/node/ifbinary.json b/apps/worker/services/report/languages/tests/unit/node/ifbinary.json
new file mode 100644
index 0000000000..77184ceb58
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/ifbinary.json
@@ -0,0 +1,120 @@
+{
+ "report": {
+ "file.js": {
+ "s": {
+ "433": 8
+ },
+ "statementMap": {
+ "433": {
+ "start": {
+ "line": 731,
+ "column": 6
+ },
+ "end": {
+ "line": 737,
+ "column": 7
+ }
+ }
+ },
+ "b": {
+ "123": [
+ 3,
+ 5
+ ],
+ "124": [
+ 8,
+ 7,
+ 6
+ ]
+ },
+ "branchMap": {
+ "123": {
+ "loc": {
+ "start": {
+ "line": 731,
+ "column": 6
+ },
+ "end": {
+ "line": 737,
+ "column": 7
+ }
+ },
+ "type": "if",
+ "locations": [
+ {
+ "start": {
+ "line": 731,
+ "column": 6
+ },
+ "end": {
+ "line": 737,
+ "column": 7
+ }
+ },
+ {
+ "start": {
+ "line": 731,
+ "column": 6
+ },
+ "end": {
+ "line": 737,
+ "column": 7
+ }
+ }
+ ]
+ },
+ "124": {
+ "loc": {
+ "start": {
+ "line": 731,
+ "column": 10
+ },
+ "end": {
+ "line": 731,
+ "column": 80
+ }
+ },
+ "type": "binary-expr",
+ "locations": [
+ {
+ "start": {
+ "line": 731,
+ "column": 10
+ },
+ "end": {
+ "line": 731,
+ "column": 30
+ }
+ },
+ {
+ "start": {
+ "line": 731,
+ "column": 34
+ },
+ "end": {
+ "line": 731,
+ "column": 55
+ }
+ },
+ {
+ "start": {
+ "line": 731,
+ "column": 59
+ },
+ "end": {
+ "line": 731,
+ "column": 80
+ }
+ }
+ ]
+ }
+ },
+ "fnMap": {}
+ }
+ },
+ "result": {
+ "file.js": {
+ "731": ["2/2", "b", [[0, "2/2", [], [[10, 30, 8], [34, 55, 7], [59, 80, 6]], null]], null, null, null]
+ }
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/ifbinarymb.json b/apps/worker/services/report/languages/tests/unit/node/ifbinarymb.json
new file mode 100644
index 0000000000..f2e70acf2d
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/ifbinarymb.json
@@ -0,0 +1,125 @@
+{
+ "report": {
+ "file.js": {
+ "fnMap": {},
+ "s": {
+ "211": 7,
+ "212": 0
+ },
+ "statementMap": {
+ "211": {
+ "start": {
+ "line": 383,
+ "column": 6
+ },
+ "end": {
+ "line": 385,
+ "column": 7
+ }
+ },
+ "212": {
+ "start": {
+ "line": 384,
+ "column": 8
+ },
+ "end": {
+ "line": 384,
+ "column": 71
+ }
+ }
+ },
+ "b": {
+ "72": [ 0, 7],
+ "73": [ 7, 3, 0]
+ },
+ "branchMap": {
+ "72": {
+ "loc": {
+ "start": {
+ "line": 383,
+ "column": 6
+ },
+ "end": {
+ "line": 385,
+ "column": 7
+ }
+ },
+ "type": "if",
+ "locations": [
+ {
+ "start": {
+ "line": 383,
+ "column": 6
+ },
+ "end": {
+ "line": 385,
+ "column": 7
+ }
+ },
+ {
+ "start": {
+ "line": 383,
+ "column": 6
+ },
+ "end": {
+ "line": 385,
+ "column": 7
+ }
+ }
+ ]
+ },
+ "73": {
+ "loc": {
+ "start": {
+ "line": 383,
+ "column": 10
+ },
+ "end": {
+ "line": 383,
+ "column": 113
+ }
+ },
+ "type": "binary-expr",
+ "locations": [
+ {
+ "start": {
+ "line": 383,
+ "column": 10
+ },
+ "end": {
+ "line": 383,
+ "column": 31
+ }
+ },
+ {
+ "start": {
+ "line": 383,
+ "column": 35
+ },
+ "end": {
+ "line": 383,
+ "column": 72
+ }
+ },
+ {
+ "start": {
+ "line": 383,
+ "column": 76
+ },
+ "end": {
+ "line": 383,
+ "column": 113
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "result": {
+ "file.js": {
+ "383": ["1/2", "b", [[0, "1/2", ["0"], [[10, 31, 7], [35, 72, 3], [76, 113, 0]], null]], null, null, null],
+ "384": [0, null, [[0, 0, null, [[8, 71, 0]], null]], null, null, null]
+ }
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/inline.json b/apps/worker/services/report/languages/tests/unit/node/inline.json
new file mode 100644
index 0000000000..6f9067ebd5
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/inline.json
@@ -0,0 +1,78 @@
+{
+ "report": {
+ "file.js": {
+ "b": {
+ "122": [0, 8]
+ },
+ "branchMap": {
+ "122": {
+ "loc": {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ "type": "if",
+ "locations": [
+ {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ }
+ ]
+ }
+ },
+ "s": {
+ "430": 8,
+ "431": 0
+ },
+ "statementMap": {
+ "430": {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ "431": {
+ "start": {
+ "line": 728,
+ "column": 23
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ }
+ },
+ "fnMap": {}
+ }
+ },
+ "result": {
+ "file.js": {
+ "728": ["1/2", "b", [[0, "1/2", ["0"], [[10, 21, "1/2"], [23, 41, 0]], null]], null, null, null]
+ }
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/inlinehit.json b/apps/worker/services/report/languages/tests/unit/node/inlinehit.json
new file mode 100644
index 0000000000..b880092354
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/inlinehit.json
@@ -0,0 +1,78 @@
+{
+ "report": {
+ "file.js": {
+ "b": {
+ "122": [8, 8]
+ },
+ "branchMap": {
+ "122": {
+ "loc": {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ "type": "if",
+ "locations": [
+ {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ }
+ ]
+ }
+ },
+ "s": {
+ "430": 1,
+ "431": 1
+ },
+ "statementMap": {
+ "430": {
+ "start": {
+ "line": 728,
+ "column": 6
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ },
+ "431": {
+ "start": {
+ "line": 728,
+ "column": 23
+ },
+ "end": {
+ "line": 728,
+ "column": 41
+ }
+ }
+ },
+ "fnMap": {}
+ }
+ },
+ "result": {
+ "file.js": {
+ "728": ["2/2", "b", [[0, "2/2", ["0"], [[10, 21, "2/2"], [23, 41, 8]]]], null]
+ }
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node1-result.json b/apps/worker/services/report/languages/tests/unit/node/node1-result.json
new file mode 100644
index 0000000000..98d71b7ed2
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node1-result.json
@@ -0,0 +1,157 @@
+{
+ "archive": [
+ "{}\n<<<<< end_of_header >>>>>\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[2,44,2123]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[16,18,2123]],null]]]\n[2123,null,[[0,2123,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[19,23,14861],[27,38,14861],[41,50,631],[53,72,14230]],null]]]\n\n[2123,null,[[0,2123,null,[[2,17,2123]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[32050,\"m\",[[0,32050]]]\n[32050,null,[[0,32050,null,[[2,33,32050]],null]]]\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n[87,\"m\",[[0,87]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,19,\"2/2\"],[21,58,87]],null]]]\n[87,null,[[0,87,null,[[2,44,87]],null]]]\n[87,null,[[0,87,null,[[2,43,87]],null]]]\n\n\n[15001,\"m\",[[0,15001]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,29,15001],[33,53,1404],[55,62,15001]],null]]]\n\n[13627,null,[[0,13627,null,[[14,37,13627]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[32,null,[[0,32,null,[[6,53,32]],null]]]\n[32,null,[[0,32,null,[[6,39,32]],null]]]\n\n\n\n\n\n\n\n[34,null,[[0,34,null,[[6,45,34]],null]]]\n\n\n[13561,null,[[0,13561,null,[[22,33,13561]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,24,13561],[28,56,11677],[60,109,47]],null]]]\n[13,null,[[0,13,null,[[6,54,13]],null]]]\n[13,null,[[0,13,null,[[6,42,13]],null]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,25,24709],[29,60,18276]],null]]]\n[11082,null,[[0,11082,null,[[4,28,11082]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,28,79],[32,81,79]],null]]]\n[53,null,[[0,53,null,[[8,57,53]],null]]]\n[53,null,[[0,53,null,[[8,41,53]],null]]]\n\n\n\n\n[26,null,[[0,26,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[12,78,2]],null]]]\n[2,null,[[0,2,null,[[12,18,2]],null]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[29,null,[[0,29,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[12,52,2]],null]]]\n[2,null,[[0,2,null,[[12,16,2]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[47,null,[[0,47,null,[[8,58,47]],null]]]\n[47,null,[[0,47,null,[[8,40,47]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[33,null,[[0,33,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[32,null,[[0,32,null,[[10,16,32]],null]]]\n\n\n\n\n\n\n\n[33,null,[[0,33,null,[[6,68,33]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[30,null,[[0,30,null,[[8,36,30]],null]]]\n\n\n\n\n[33,null,[[0,33,null,[[6,61,33]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[8,32,1]],null]]]\n\n\n\n\n[13627,null,[[0,13627,null,[[2,40,13627]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,31,52],[35,74,52],[78,116,52]],null]]]\n[5,null,[[0,5,null,[[6,44,5]],null]]]\n\n[47,null,[[0,47,null,[[6,47,47]],null]]]\n\n\n\n[13627,null,[[0,13627,null,[[2,19,13627]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n\n\n\n\n\n[263,\"m\",[[0,263]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,19,\"2/2\"],[21,28,263]],null]]]\n\n[248,null,[[0,248,null,[[12,20,248]],null]]]\n\n[248,null,[[0,248,null,[],null]]]\n[235,\"b\",[[0,235]]]\n[235,null,[[0,235,null,[[6,22,235]],null]]]\n[235,null,[[0,235,null,[[6,12,235]],null]]]\n\n[9,\"b\",[[0,9]]]\n[13,\"b\",[[0,13]]]\n[13,null,[[0,13,null,[[6,31,13]],null]]]\n[13,null,[[0,13,null,[[6,12,13]],null]]]\n\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,26,248],[30,50,23]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[8,22,\"0/2\"],[24,84,0]],null]]]\n[0,null,[[0,0,null,[[4,26,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1793,\"m\",[[0,1793]]]\n[1793,null,[[0,1793,null,[[17,33,1793],[46,65,1793]],null]]]\n[1793,null,[[0,1793,null,[[13,64,1793]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8,null,[[0,8,null,[[15,51,8]],null]]]\n[8,null,[[0,8,null,[[4,30,8]],null]]]\n[8,null,[[0,8,null,[],null]]]\n[10,null,[[0,10,null,[[6,81,10]],null]]]\n\n[7,null,[[0,7,null,[[4,44,7]],null]]]\n[7,null,[[0,7,null,[[4,55,7]],null]]]\n\n[1479,null,[[0,1479,null,[[2,14,1479]],null]]]\n\n\n\n\n\n[3228,\"m\",[[0,3228]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,3228],[31,53,50]],null]]]\n[34,null,[[0,34,null,[[4,29,34]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[634,null,[[0,634,null,[[4,34,634]],null]]]\n\n[2560,null,[[0,2560,null,[[4,42,2560]],null]]]\n[2560,null,[[0,2560,null,[[4,33,2560]],null]]]\n\n\n[3194,null,[[0,3194,null,[[17,33,3194]],null]]]\n[3194,null,[[0,3194,null,[[17,36,3194]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,3194],[31,50,2820]],null]]]\n[1593,null,[[0,1593,null,[[4,51,1593]],null]]]\n\n\n[3194,null,[[0,3194,null,[[13,87,3194]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,20,\"2/2\"],[22,81,2780]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[209,null,[[0,209,null,[[15,51,209]],null]]]\n[209,null,[[0,209,null,[[4,37,209]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[36,59,185],[62,66,24]],null]]]\n[190,null,[[0,190,null,[[4,37,190]],null]]]\n\n[190,null,[[0,190,null,[[4,25,190]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,178],[22,46,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[8,49,1]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[1,null,[[0,1,null,[[8,49,1]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[2,null,[[0,2,null,[[8,116,2]],null]]]\n\n\n\n[176,null,[[0,176,null,[[4,16,176]],null]]]\n[176,null,[[0,176,null,[[4,45,176]],null]]]\n[169,null,[[0,169,null,[[4,57,169]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,34,2571],[38,66,2070]],null]]]\n[3,null,[[0,3,null,[[4,50,3]],null]]]\n\n\n[2568,null,[[0,2568,null,[[2,14,2568]],null]]]\n\n\n\n\n[3194,\"m\",[[0,3194]]]\n[3194,null,[[0,3194,null,[[17,33,3194],[46,65,3194]],null]]]\n[3194,null,[[0,3194,null,[[13,60,3194]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,2787],[32,60,2787],[62,74,2787]],null]]]\n\n[2734,null,[[0,2734,null,[[2,81,2734]],null]]]\n\n\n[2734,\"m\",[[0,2734]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[21,null,[[0,21,null,[[15,51,21]],null]]]\n[21,null,[[0,21,null,[[4,21,21]],null]]]\n[21,null,[[0,21,null,[[4,46,21]],null]]]\n[17,null,[[0,17,null,[[4,26,17]],null]]]\n[17,null,[[0,17,null,[[4,49,17]],null]]]\n[9,null,[[0,9,null,[[4,58,9]],null]]]\n\n[2713,null,[[0,2713,null,[[2,14,2713]],null]]]\n\n\n\n\n[3194,\"m\",[[0,3194]]]\n[3194,null,[[0,3194,null,[[17,33,3194],[46,65,3194]],null]]]\n[3194,null,[[0,3194,null,[[13,57,3194]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,2800],[32,60,2800]],null]]]\n[53,null,[[0,53,null,[[4,16,53]],null]]]\n\n[2747,null,[[0,2747,null,[[4,64,2747]],null]]]\n\n\n\n\n\n\n\n\n\n[3205,\"m\",[[0,3205]]]\n[3205,null,[[0,3205,null,[[13,34,3205]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,18,3205],[23,28,293],[32,51,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[242,null,[[0,242,null,[[17,61,242]],null]]]\n[242,null,[[0,242,null,[[6,23,242]],null]]]\n[242,null,[[0,242,null,[[6,39,242]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[242,\"b\",[[0,242]]]\n[13,\"b\",[[0,13]]]\n[4,\"b\",[[0,4]]]\n[4,\"b\",[[0,4]]]\n[4,\"b\",[[0,4]]]\n\n[3,null,[[0,3,null,[[8,124,3]],null]]]\n\n\n[239,null,[[0,239,null,[[15,30,239]],null]]]\n[239,null,[[0,239,null,[[6,18,239]],null]]]\n\n[238,null,[[0,238,null,[[21,37,238]],null]]]\n[238,null,[[0,238,null,[[21,40,238]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[102,110,10],[113,117,219]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[29,48,229],[52,72,218],[76,95,22],[98,116,207]],null]]]\n[229,null,[[0,229,null,[[6,79,229]],null]]]\n\n\n[2963,null,[[0,2963,null,[[2,14,2963]],null]]]\n\n\n\n\n[3503,\"m\",[[0,3503]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[61,null,[[0,61,null,[[15,31,61]],null]]]\n[61,null,[[0,61,null,[[17,38,61]],null]]]\n[61,null,[[0,61,null,[[4,37,61]],null]]]\n[61,null,[[0,61,null,[[4,23,61]],null]]]\n[61,null,[[0,61,null,[[4,16,61]],null]]]\n\n[61,null,[[0,61,null,[[18,33,61]],null]]]\n[61,null,[[0,61,null,[[4,43,61]],null]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",[],[[49,70,58],[75,95,2],[99,133,2]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,30,58],[34,62,56]],null]]]\n[0,null,[[0,0,null,[[6,52,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[28,null,[[0,28,null,[[6,36,28]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,32,30],[36,62,3],[66,101,2]],null]]]\n[2,null,[[0,2,null,[[6,71,2]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[42,60,16],[63,80,28]],null]]]\n\n\n[3442,null,[[0,3442,null,[[17,33,3442],[46,65,3442]],null]]]\n[3442,null,[[0,3442,null,[[13,61,3442]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,3065],[32,60,2770],[62,74,3065]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,32,3039],[36,62,43]],null]]]\n[39,null,[[0,39,null,[[15,51,39]],null]]]\n[39,null,[[0,39,null,[[4,37,39]],null]]]\n[39,null,[[0,39,null,[[4,24,39]],null]]]\n[39,null,[[0,39,null,[[4,25,39]],null]]]\n[39,null,[[0,39,null,[[4,25,39]],null]]]\n[27,null,[[0,27,null,[[4,16,27]],null]]]\n[27,null,[[0,27,null,[[4,53,27]],null]]]\n\n[3000,null,[[0,3000,null,[[2,14,3000]],null]]]\n\n\n\n\n[3466,\"m\",[[0,3466]]]\n[3466,null,[[0,3466,null,[[17,33,3466],[46,65,3466]],null]]]\n[3466,null,[[0,3466,null,[[25,52,3466]],null]]]\n[3466,null,[[0,3466,null,[[13,55,3466]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,45,3100],[49,80,113]],null]]]\n[101,null,[[0,101,null,[[4,16,101]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,2999],[32,60,2680]],null]]]\n[53,null,[[0,53,null,[[4,16,53]],null]]]\n\n\n[2946,null,[[0,2946,null,[[2,56,2946]],null]]]\n\n\n[2981,\"m\",[[0,2981]]]\n[2981,null,[[0,2981,null,[],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,16,3246],[20,44,3204]],null]]]\n[0,null,[[0,0,null,[[17,53,0]],null]]]\n[0,null,[[0,0,null,[[6,25,0]],null]]]\n[0,null,[[0,0,null,[[6,43,0]],null]]]\n[0,null,[[0,0,null,[[6,104,0]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[74,null,[[0,74,null,[[17,53,74]],null]]]\n[74,null,[[0,74,null,[[6,25,74]],null]]]\n[74,null,[[0,74,null,[[6,49,74]],null]]]\n[72,null,[[0,72,null,[[6,28,72]],null]]]\n[72,null,[[0,72,null,[[6,55,72]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[18,null,[[0,18,null,[[17,53,18]],null]]]\n[18,null,[[0,18,null,[[6,25,18]],null]]]\n[18,null,[[0,18,null,[[6,45,18]],null]]]\n[18,null,[[0,18,null,[[6,27,18]],null]]]\n[18,null,[[0,18,null,[[6,31,18]],null]]]\n[18,null,[[0,18,null,[[6,55,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,23,3154],[27,48,3117]],null]]]\n[\"4/4\",\"b\",[[0,\"4/4\",[],[[26,68,186],[72,98,167],[102,123,153],[127,153,15]],null]]]\n[186,null,[[0,186,null,[[6,18,186]],null]]]\n\n[186,null,[[0,186,null,[[17,53,186]],null]]]\n[186,null,[[0,186,null,[[6,25,186]],null]]]\n[186,null,[[0,186,null,[[6,83,186]],null]]]\n[177,null,[[0,177,null,[[6,53,177]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,23,177],[27,55,13]],null]]]\n[11,null,[[0,11,null,[[8,98,11]],null]]]\n\n[166,null,[[0,166,null,[[8,46,166]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[9,null,[[0,9,null,[[17,53,9]],null]]]\n[9,null,[[0,9,null,[[6,22,9]],null]]]\n[9,null,[[0,9,null,[[6,40,9]],null]]]\n[9,null,[[0,9,null,[[6,63,9]],null]]]\n\n[2959,null,[[0,2959,null,[[6,18,2959]],null]]]\n\n\n\n\n[186,\"m\",[[0,186]]]\n\n\n[186,null,[[0,186,null,[[13,15,186],[25,29,186]],null]]]\n[186,null,[[0,186,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[98,null,[[0,98,null,[[6,20,98]],null]]]\n\n[26,null,[[0,26,null,[[6,28,26]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,25,\"2/2\"],[27,33,26]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,29,123],[33,49,6]],null]]]\n[6,null,[[0,6,null,[[6,41,6]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[69,81,13],[84,93,110]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,24,178],[28,43,14],[47,75,1]],null]]]\n[1,null,[[0,1,null,[[4,22,1]],null]]]\n\n\n[177,null,[[0,177,null,[[2,14,177]],null]]]\n\n\n[9,\"m\",[[0,9]]]\n[9,null,[[0,9,null,[[2,30,9]],null]]]\n\n\n[11,\"m\",[[0,11]]]\n[11,null,[[0,11,null,[[2,24,11]],null]]]\n[11,null,[[0,11,null,[[2,63,11]],null]]]\n\n\n\n\n[35,\"m\",[[0,35]]]\n[35,null,[[0,35,null,[[17,33,35],[46,65,35]],null]]]\n[35,null,[[0,35,null,[[2,78,35]],null]]]\n\n\n\n\n\n\n\n[3533,\"m\",[[0,3533]]]\n[3533,null,[[0,3533,null,[[25,73,3533]],null]]]\n[3533,null,[[0,3533,null,[],null]]]\n[9,\"b\",[[0,9]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,30,9],[34,71,2]],null]]]\n[2,null,[[0,2,null,[[8,77,2]],null]]]\n\n\n[7,null,[[0,7,null,[[6,30,7]],null]]]\n[7,null,[[0,7,null,[[6,18,7]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,32,7],[36,60,4],[64,83,3]],null]]]\n[0,null,[[0,0,null,[[8,26,0]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,31,7],[35,72,3],[76,113,0]],null]]]\n[0,null,[[0,0,null,[[8,71,0]],null]]]\n\n[7,null,[[0,7,null,[[6,44,7]],null]]]\n\n[8,\"b\",[[0,8]]]\n[8,null,[[0,8,null,[[6,30,8]],null]]]\n[8,null,[[0,8,null,[[6,18,8]],null]]]\n[8,null,[[0,8,null,[[6,53,8]],null]]]\n\n[18,\"b\",[[0,18]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,32,\"1/2\"],[34,52,0]],null]]]\n\n[1501,\"b\",[[0,1501]]]\n[1501,null,[[0,1501,null,[[6,30,1501]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[23,51,1501],[55,73,14]],null]]]\n[1501,null,[[0,1501,null,[[23,56,1501]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[36,46,1501],[50,60,1490]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,30,14],[34,47,3]],null]]]\n[11,null,[[0,11,null,[[10,39,11]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[17,36,1484],[40,64,20],[68,94,3]],null]]]\n[2,null,[[0,2,null,[[8,20,2]],null]]]\n[2,null,[[0,2,null,[[8,60,2]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[17,27,1482],[31,50,1201],[54,73,18]],null]]]\n[1,null,[[0,1,null,[[21,45,1]],null]]]\n[1,null,[[0,1,null,[[8,30,1]],null]]]\n\n[1,null,[[0,1,null,[[8,61,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,20,1484],[24,50,1203],[54,72,1101]],null]]]\n[24,null,[[0,24,null,[[8,53,24]],null]]]\n\n\n[1460,null,[[0,1460,null,[[6,16,1460]],null]]]\n\n[0,\"b\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],null,null]]]\n[0,null,[[0,0,null,[[19,35,0]],null]]]\n[0,null,[[0,0,null,[[8,20,0]],null]]]\n[0,null,[[0,0,null,[[28,49,0]],null]]]\n[0,null,[[0,0,null,[[24,41,0]],null]]]\n[0,null,[[0,0,null,[[8,31,0]],null]]]\n[0,null,[[0,0,null,[[8,38,0]],null]]]\n[0,null,[[0,0,null,[[8,49,0]],null]]]\n[0,null,[[0,0,null,[[8,46,0]],null]]]\n[0,null,[[0,0,null,[[8,38,0]],null]]]\n[0,null,[[0,0,null,[[8,53,0]],null]]]\n\n\n[26,\"b\",[[0,26]]]\n[26,null,[[0,26,null,[[18,34,26]],null]]]\n[26,null,[[0,26,null,[[6,61,26]],null]]]\n[26,null,[[0,26,null,[[6,35,26]],null]]]\n[26,null,[[0,26,null,[[6,31,26]],null]]]\n[26,null,[[0,26,null,[[6,18,26]],null]]]\n\n[550,\"b\",[[0,550]]]\n[550,null,[[0,550,null,[[6,67,550]],null]]]\n\n[346,\"b\",[[0,346]]]\n[346,null,[[0,346,null,[[6,66,346]],null]]]\n\n[11,\"b\",[[0,11]]]\n[11,null,[[0,11,null,[[6,30,11]],null]]]\n[11,null,[[0,11,null,[[6,18,11]],null]]]\n[11,null,[[0,11,null,[[6,50,11]],null]]]\n\n[62,\"b\",[[0,62]]]\n[76,null,[[0,76,null,[[6,30,76]],null]]]\n[76,null,[[0,76,null,[[6,40,76]],null]]]\n[76,null,[[0,76,null,[[6,18,76]],null]]]\n[76,null,[[0,76,null,[[6,53,76]],null]]]\n\n[383,\"b\",[[0,383]]]\n[383,null,[[0,383,null,[[6,77,383]],null]]]\n\n[114,\"b\",[[0,114]]]\n[114,null,[[0,114,null,[[6,30,114]],null]]]\n[114,null,[[0,114,null,[[6,18,114]],null]]]\n[114,null,[[0,114,null,[[6,84,114]],null]]]\n[109,null,[[0,109,null,[[6,43,109]],null]]]\n[109,null,[[0,109,null,[[6,54,109]],null]]]\n\n[223,\"b\",[[0,223]]]\n[223,null,[[0,223,null,[[6,58,223]],null]]]\n\n[122,\"b\",[[0,122]]]\n[122,null,[[0,122,null,[[6,44,122]],null]]]\n\n[2,\"b\",[[0,2]]]\n[2,null,[[0,2,null,[[6,29,2]],null]]]\n\n[19,\"b\",[[0,19]]]\n[19,null,[[0,19,null,[[6,30,19]],null]]]\n[19,null,[[0,19,null,[[6,32,19]],null]]]\n[19,null,[[0,19,null,[[6,42,19]],null]]]\n\n[45,\"b\",[[0,45]]]\n[45,null,[[0,45,null,[[6,29,45]],null]]]\n\n[24,\"b\",[[0,24]]]\n[24,null,[[0,24,null,[[6,34,24]],null]]]\n\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[6,30,0]],null]]]\n[0,null,[[0,0,null,[[6,18,0]],null]]]\n[0,null,[[0,0,null,[[6,25,0]],null]]]\n[0,null,[[0,0,null,[[19,55,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],null,null]]]\n[0,null,[[0,0,null,[[8,55,0]],null]]]\n\n[0,null,[[0,0,null,[[8,84,0]],null]]]\n\n\n[76,\"b\",[[0,76]]]\n[76,null,[[0,76,null,[[6,24,76]],null]]]\n\n\n\n[122,\"m\",[[0,122]]]\n[122,null,[[0,122,null,[[13,29,122]],null]]]\n[122,null,[[0,122,null,[[13,39,122]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,122],[32,48,3],[52,82,1]],null]]]\n[1,null,[[0,1,null,[[4,54,1]],null]]]\n\n[121,null,[[0,121,null,[[4,43,121]],null]]]\n\n\n\n[11,\"m\",[[0,11]]]\n[11,null,[[0,11,null,[[2,19,11]],null]]]\n[11,null,[[0,11,null,[[2,45,11]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,108,2]],null]]]\n\n\n[8,null,[[0,8,null,[[2,47,8]],null]]]\n\n\n[944,\"m\",[[0,944]]]\n[944,null,[[0,944,null,[[13,29,944]],null]]]\n[944,null,[[0,944,null,[[2,41,944]],null]]]\n[944,null,[[0,944,null,[[2,81,944]],null]]]\n[944,null,[[0,944,null,[[2,21,944]],null]]]\n[944,null,[[0,944,null,[[2,14,944]],null]]]\n[944,null,[[0,944,null,[[2,37,944]],null]]]\n\n\n[129,\"m\",[[0,129]]]\n[129,null,[[0,129,null,[[2,25,129]],null]]]\n[125,null,[[0,125,null,[[12,34,125]],null]]]\n[124,null,[[0,124,null,[[2,25,124]],null]]]\n[124,null,[[0,124,null,[[2,13,124]],null]]]\n\n\n[383,\"m\",[[0,383]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,383],[25,41,383]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,383],[25,44,383]],null]]]\n\n\n[383,null,[[0,383,null,[[2,25,383]],null]]]\n\n[383,null,[[0,383,null,[[22,38,383],[56,75,383]],null]]]\n[383,null,[[0,383,null,[[17,19,383],[29,33,383]],null]]]\n[383,null,[[0,383,null,[[31,43,383]],null]]]\n[383,null,[[0,383,null,[[25,37,383]],null]]]\n[383,null,[[0,383,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[366,null,[[0,366,null,[[6,20,366]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[28,50,55],[54,58,53]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[8,46,3]],null]]]\n[3,null,[[0,3,null,[[8,14,3]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[22,null,[[0,22,null,[[31,47,22],[70,89,22]],null]]]\n[22,null,[[0,22,null,[[6,37,22]],null]]]\n[22,null,[[0,22,null,[[6,99,22]],null]]]\n[18,null,[[0,18,null,[[6,12,18]],null]]]\n\n[394,null,[[0,394,null,[[6,113,394]],null]]]\n\n\n\n[286,null,[[0,286,null,[[20,36,286]],null]]]\n[286,null,[[0,286,null,[[20,39,286]],null]]]\n[286,null,[[0,286,null,[[2,25,286]],null]]]\n\n[284,null,[[0,284,null,[[18,54,284]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,16,284],[20,46,277],[51,89,178]],null]]]\n[111,null,[[0,111,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,21,140],[25,50,13],[52,92,140]],null]]]\n\n\n[104,null,[[0,104,null,[[4,67,104]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n[7,null,[[0,7,null,[[6,47,7]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,24,\"2/2\"],[26,62,164]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,17,\"2/2\"],[19,48,163]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,34,\"1/2\"],[36,82,0]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,\"2/2\"],[30,70,160]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[4,57,4]],null]]]\n[4,null,[[0,4,null,[[4,31,4]],null]]]\n[4,null,[[0,4,null,[[4,43,4]],null]]]\n[4,null,[[0,4,null,[[4,75,4]],null]]]\n\n[155,null,[[0,155,null,[[4,22,155]],null]]]\n\n\n\n[159,null,[[0,159,null,[[2,44,159]],null]]]\n[159,null,[[0,159,null,[[2,45,159]],null]]]\n\n[159,null,[[0,159,null,[[2,13,159]],null]]]\n\n\n[178,\"m\",[[0,178]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[111,null,[[0,111,null,[[4,16,111]],null]]]\n\n\n\n[538,\"m\",[[0,538]]]\n[538,null,[[0,538,null,[[2,14,538]],null]]]\n\n\n\n\n\n\n[45,\"m\",[[0,45]]]\n[45,null,[[0,45,null,[[13,29,45]],null]]]\n[45,null,[[0,45,null,[[13,39,45]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[4,56,10]],null]]]\n\n\n[35,null,[[0,35,null,[[2,39,35]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[20,null,[[0,20,null,[[4,51,20]],null]]]\n[17,null,[[0,17,null,[[4,42,17]],null]]]\n\n[15,null,[[0,15,null,[[4,24,15]],null]]]\n\n\n[32,null,[[0,32,null,[[2,48,32]],null]]]\n\n\n\n\n[41,\"m\",[[0,41]]]\n[41,null,[[0,41,null,[[13,29,41]],null]]]\n[41,null,[[0,41,null,[],null]]]\n\n\n\n[41,null,[[0,41,null,[[2,14,41]],null]]]\n[41,null,[[0,41,null,[[2,39,41]],null]]]\n[41,null,[[0,41,null,[[2,50,41]],null]]]\n\n\n[33,\"m\",[[0,33]]]\n[33,null,[[0,33,null,[[13,29,33]],null]]]\n[33,null,[[0,33,null,[[2,14,33]],null]]]\n[29,null,[[0,29,null,[[2,24,29]],null]]]\n[29,null,[[0,29,null,[[15,42,29]],null]]]\n[29,null,[[0,29,null,[[2,25,29]],null]]]\n[29,null,[[0,29,null,[],null]]]\n[17,null,[[0,17,null,[[4,33,17]],null]]]\n[17,null,[[0,17,null,[[4,50,17]],null]]]\n[17,null,[[0,17,null,[[4,27,17]],null]]]\n[12,null,[[0,12,null,[[4,59,12]],null]]]\n\n[24,null,[[0,24,null,[[2,14,24]],null]]]\n[24,null,[[0,24,null,[[2,50,24]],null]]]\n\n\n\n\n[284,\"m\",[[0,284]]]\n[284,null,[[0,284,null,[[19,21,284]],null]]]\n[284,null,[[0,284,null,[[17,36,284]],null]]]\n[284,null,[[0,284,null,[[14,18,284]],null]]]\n[284,null,[[0,284,null,[[13,29,284]],null]]]\n\n[284,null,[[0,284,null,[[2,23,284]],null]]]\n[284,null,[[0,284,null,[[2,14,284]],null]]]\n\n[280,null,[[0,280,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[254,null,[[0,254,null,[[6,20,254]],null]]]\n\n[71,null,[[0,71,null,[[6,28,71]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,29,\"2/2\"],[31,37,61]],null]]]\n\n\n[312,null,[[0,312,null,[],null]]]\n[2,null,[[0,2,null,[[6,45,2]],null]]]\n\n\n[312,null,[[0,312,null,[[15,31,312],[47,52,312],[64,69,312]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,35,2]],null]]]\n[2,null,[[0,2,null,[[6,22,2]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,42,312],[46,69,15]],null]]]\n[8,null,[[0,8,null,[[6,32,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[30,44,3],[47,63,5]],null]]]\n[8,null,[[0,8,null,[[6,33,8]],null]]]\n[8,null,[[0,8,null,[[6,15,8]],null]]]\n\n\n[304,null,[[0,304,null,[[4,24,304]],null]]]\n[304,null,[[0,304,null,[[4,27,304]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,17,304],[21,43,233]],null]]]\n[295,null,[[0,295,null,[[6,34,295]],null]]]\n[295,null,[[0,295,null,[[6,37,295]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[233,null,[[0,233,null,[[6,38,233]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,304],[22,48,233]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,21,\"1/2\"],[23,41,0]],null]]]\n\n[8,null,[[0,8,null,[[20,42,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,30,8],[34,55,7],[59,80,6]],null]]]\n[3,null,[[0,3,null,[[8,27,3]],null]]]\n\n[5,null,[[0,5,null,[[8,23,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,45,\"2/2\"],[47,79,5]],null]]]\n[5,null,[[0,5,null,[[8,37,5]],null]]]\n\n\n[296,null,[[0,296,null,[[6,35,296]],null]]]\n\n\n[295,null,[[0,295,null,[[4,110,295]],null]]]\n[263,null,[[0,263,null,[[4,40,263]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[81,null,[[0,81,null,[[6,45,81]],null]]]\n\n\n[263,null,[[0,263,null,[[4,31,263]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,82,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[43,58,59],[61,79,170]],null]]]\n\n\n[295,\"m\",[[0,295]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,13,295],[17,28,290],[32,53,279]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,17,\"1/2\"],[19,37,0]],null]]]\n[46,null,[[0,46,null,[[4,25,46]],null]]]\n[46,null,[[0,46,null,[[4,23,46]],null]]]\n[46,null,[[0,46,null,[[4,49,46]],null]]]\n[37,null,[[0,37,null,[[4,49,37]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[29,90,27],[93,145,93]],null]]]\n[113,null,[[0,113,null,[[4,51,113]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,20,129],[24,54,128],[59,82,124],[86,109,101],[115,136,44],[140,162,44]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,19,43],[23,30,43],[34,43,43],[45,63,43]],null]]]\n[43,null,[[0,43,null,[[4,30,43]],null]]]\n[43,null,[[0,43,null,[[4,33,43]],null]]]\n[40,null,[[0,40,null,[[4,34,40]],null]]]\n[32,null,[[0,32,null,[[21,48,32]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[18,28,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],null,null]]]\n[0,null,[[0,0,null,[[8,58,0]],null]]]\n\n[0,null,[[0,0,null,[[8,66,0]],null]]]\n\n\n[32,null,[[0,32,null,[[4,49,32]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,20,86],[24,54,85]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[44,null,[[0,44,null,[[27,56,44]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,25,44],[29,46,44]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[25,64,8],[68,103,8]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[8,63,0]],null]]]\n\n[44,null,[[0,44,null,[[6,82,44]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,32,37],[36,58,11]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[11,null,[[0,11,null,[[8,56,11]],null]]]\n\n[11,null,[[0,11,null,[[6,82,11]],null]]]\n\n[26,null,[[0,26,null,[[6,38,26]],null]]]\n\n[81,null,[[0,81,null,[[4,26,81]],null]]]\n[81,null,[[0,81,null,[[4,51,81]],null]]]\n\n\n[5,null,[[0,5,null,[[2,20,5]],null]]]\n\n\n[544,\"m\",[[0,544]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[31,null,[[0,31,null,[[4,25,31]],null]]]\n[31,null,[[0,31,null,[[4,39,31]],null]]]\n[30,null,[[0,30,null,[[4,29,30]],null]]]\n[26,null,[[0,26,null,[[4,20,26]],null]]]\n\n[513,null,[[0,513,null,[[4,26,513]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[23,41,513],[45,66,507],[70,90,23],[93,119,490]],null]]]\n\n\n\n\n\n[762,\"m\",[[0,762]]]\n[762,null,[[0,762,null,[[2,17,762]],null]]]\n[762,null,[[0,762,null,[[2,25,762]],null]]]\n[762,null,[[0,762,null,[[2,26,762]],null]]]\n[762,null,[[0,762,null,[[2,25,762]],null]]]\n\n\n\n\n[190,\"m\",[[0,190]]]\n[190,null,[[0,190,null,[[20,39,190]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[24,33,190],[37,41,0]],null]]]\n[190,null,[[0,190,null,[[2,35,190]],null]]]\n[190,null,[[0,190,null,[[2,25,190]],null]]]\n[182,null,[[0,182,null,[[2,49,182]],null]]]\n[178,null,[[0,178,null,[[2,31,178]],null]]]\n[178,null,[[0,178,null,[[2,31,178]],null]]]\n[168,null,[[0,168,null,[[2,36,168]],null]]]\n[168,null,[[0,168,null,[[2,14,168]],null]]]\n\n\n\n\n[140,\"m\",[[0,140]]]\n[140,null,[[0,140,null,[[2,35,140]],null]]]\n[140,null,[[0,140,null,[[2,52,140]],null]]]\n[131,null,[[0,131,null,[[2,37,131]],null]]]\n[112,null,[[0,112,null,[[2,58,112]],null]]]\n\n\n\n\n[696,\"m\",[[0,696]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[21,36,696],[40,62,131]],null]]]\n\n[696,null,[[0,696,null,[[19,37,696]],null]]]\n[696,null,[[0,696,null,[[2,34,696]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[89,null,[[0,89,null,[[4,40,89]],null]]]\n[89,null,[[0,89,null,[[4,27,89]],null]]]\n\n\n\n[607,null,[[0,607,null,[[20,41,607],[54,76,607],[90,107,607]],null]]]\n[607,null,[[0,607,null,[[4,33,607],[34,74,607],[75,98,607]],null]]]\n[607,null,[[0,607,null,[[4,38,607]],null]]]\n[474,null,[[0,474,null,[[4,28,474]],null]]]\n[474,null,[[0,474,null,[[4,38,474],[39,73,474],[74,104,474]],null]]]\n\n[563,null,[[0,563,null,[[2,34,563]],null]]]\n\n\n\n\n[563,null,[[0,563,null,[[18,35,563]],null]]]\n[563,null,[[0,563,null,[[24,29,563]],null]]]\n[563,null,[[0,563,null,[[17,22,563]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,21,\"2/2\"],[23,40,563]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,19,563],[23,50,474]],null]]]\n[52,null,[[0,52,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[50,null,[[0,50,null,[[8,24,50]],null]]]\n[50,null,[[0,50,null,[[8,25,50]],null]]]\n[50,null,[[0,50,null,[[8,31,50]],null]]]\n[50,null,[[0,50,null,[[8,14,50]],null]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,14,563],[18,25,50],[29,58,44],[62,86,44]],null]]]\n[2,null,[[0,2,null,[[4,62,2]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[361,null,[[0,361,null,[[19,38,361]],null]]]\n[361,null,[[0,361,null,[[20,37,361]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,23,\"2/2\"],[25,50,361]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[105,null,[[0,105,null,[[6,36,105]],null]]]\n\n[337,null,[[0,337,null,[],null]]]\n[261,null,[[0,261,null,[[6,44,261]],null]]]\n\n[276,null,[[0,276,null,[[4,34,276]],null]]]\n\n\n\n\n\n\n\n\n\n[134,\"m\",[[0,134]]]\n[134,null,[[0,134,null,[[13,15,134],[25,29,134]],null]]]\n[134,null,[[0,134,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[106,null,[[0,106,null,[[6,20,106]],null]]]\n\n[52,null,[[0,52,null,[[6,28,52]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,25,\"2/2\"],[27,33,52]],null]]]\n\n\n[150,null,[[0,150,null,[[4,74,150]],null]]]\n\n[126,null,[[0,126,null,[[2,14,126]],null]]]\n\n\n[273,\"m\",[[0,273]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,16,273],[20,40,137]],null]]]\n[12,null,[[0,12,null,[[4,15,12]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[35,null,[[0,35,null,[[4,51,35]],null]]]\n\n[226,null,[[0,226,null,[[4,84,226]],null]]]\n\n[257,null,[[0,257,null,[[2,13,257]],null]]]\n\n\n\n\n\n\n[4037,\"m\",[[0,4037]]]\n[4037,null,[[0,4037,null,[[13,29,4037]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,16,3760],[20,37,2567],[41,79,905]],null]]]\n[3,null,[[0,3,null,[[6,89,3]],null]]]\n\n\n[3757,null,[[0,3757,null,[[4,33,3757]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,20,277],[24,47,257]],null]]]\n[243,null,[[0,243,null,[[4,40,243]],null]]]\n\n[34,null,[[0,34,null,[[4,22,34]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,14,4000],[18,39,2564],[43,61,3]],null]]]\n[0,null,[[0,0,null,[[4,79,0]],null]]]\n\n\n[4000,null,[[0,4000,null,[[2,38,4000]],null]]]\n\n[4000,null,[[0,4000,null,[[2,14,4000]],null]]]\n[3998,null,[[0,3998,null,[[2,45,3998]],null]]]\n\n\n\n\n[11,\"m\",[[0,11]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,116,1]],null]]]\n\n[10,null,[[0,10,null,[[2,41,10]],null]]]\n[10,null,[[0,10,null,[[2,50,10]],null]]]\n\n\n\n\n[34,\"m\",[[0,34]]]\n[34,null,[[0,34,null,[[13,29,34]],null]]]\n[34,null,[[0,34,null,[[2,14,34]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,25,34],[29,54,33],[59,79,29],[83,110,19]],null]]]\n[10,null,[[0,10,null,[[4,26,10]],null]]]\n[10,null,[[0,10,null,[[4,25,10]],null]]]\n\n[24,null,[[0,24,null,[[4,38,24]],null]]]\n[24,null,[[0,24,null,[[4,44,24]],null]]]\n\n[33,null,[[0,33,null,[[2,50,33]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n[1,null,[[0,1,null,[[23,25,1]],null]]]\n\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[4,34,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,26,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,27,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,57,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,43,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,23,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,58,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,43,2123]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,28,2123],[32,53,2123],[57,78,2]],null]]]\n[2,null,[[0,2,null,[[6,30,2]],null]]]\n\n\n\n[1088,\"m\",[[0,1088]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,31,1088],[35,53,1088]],null]]]\n\n\n[8596,\"m\",[[0,8596]]]\n[8596,null,[[0,8596,null,[[4,31,8596]],null]]]\n\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[20,22,2123]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n[506,\"m\",[[0,506,null,[[6,62,253],[43,60,506]],null]]]\n[253,null,[[0,253,null,[[6,27,253]],null]]]\n\n\n[2123,null,[[0,2123,null,[],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[557,null,[[0,557,null,[[8,31,557]],null]]]\n\n[557,null,[[0,557,null,[[21,42,557]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,18,\"2/2\"],[20,33,557]],null]]]\n\n\n\n[2123,null,[[0,2123,null,[[4,21,2123]],null]]]\n\n\n\n\n\n\n\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[15,31,2123]],null]]]\n[2123,null,[[0,2123,null,[[18,34,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,21,2123]],null]]]\n[2026,null,[[0,2026,null,[[4,45,2026]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n\n\n\n\n\n\n[738,\"m\",[[0,738]]]\n[738,null,[[0,738,null,[[12,40,738]],null]]]\n[738,null,[[0,738,null,[[2,44,738]],null]]]\n[738,null,[[0,738,null,[[12,36,738]],null]]]\n[738,null,[[0,738,null,[[2,16,738]],null]]]\n[738,null,[[0,738,null,[[2,16,738]],null]]]\n[738,null,[[0,738,null,[[2,12,738]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n\n\n\n[495,\"m\",[[0,495]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[495,null,[[0,495,null,[],null]]]\n[309,\"b\",[[0,309]]]\n[309,\"b\",[[0,309]]]\n[309,\"b\",[[0,309]]]\n[317,\"b\",[[0,317]]]\n[317,null,[[0,317,null,[[8,14,317]],null]]]\n\n[31,\"b\",[[0,31]]]\n[31,null,[[0,31,null,[[8,36,31]],null]]]\n[31,null,[[0,31,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[16,35,3],[39,58,0]],null]]]\n[3,null,[[0,3,null,[[14,90,3]],null]]]\n\n[0,null,[[0,0,null,[[14,81,0]],null]]]\n\n\n[36,null,[[0,36,null,[[12,47,36]],null]]]\n\n\n[25,null,[[0,25,null,[[8,14,25]],null]]]\n\n[35,\"b\",[[0,35]]]\n[35,null,[[0,35,null,[[8,49,35]],null]]]\n[32,null,[[0,32,null,[[8,14,32]],null]]]\n\n[1,\"b\",[[0,1]]]\n[1,null,[[0,1,null,[[8,35,1]],null]]]\n[1,null,[[0,1,null,[[8,14,1]],null]]]\n\n[55,\"b\",[[0,55]]]\n[55,null,[[0,55,null,[[8,35,55]],null]]]\n[55,null,[[0,55,null,[[8,56,55]],null]]]\n[50,null,[[0,50,null,[[8,14,50]],null]]]\n\n[17,\"b\",[[0,17]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[17,null,[[0,17,null,[[10,42,17]],null]]]\n[17,null,[[0,17,null,[[10,31,17]],null]]]\n\n[0,null,[[0,0,null,[[10,99,0]],null]]]\n\n[17,null,[[0,17,null,[[8,14,17]],null]]]\n\n[9,\"b\",[[0,9]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,22,\"2/2\"],[24,30,9]],null]]]\n\n[31,\"b\",[[0,31]]]\n[31,null,[[0,31,null,[[8,54,31]],null]]]\n\n\n[450,null,[[0,450,null,[[2,14,450]],null]]]\n\n\n\n\n[195,\"m\",[[0,195]]]\n[195,null,[[0,195,null,[[12,27,195]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[180,null,[[0,180,null,[[15,32,180]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,12,180],[16,43,177]],null]]]\n[12,null,[[0,12,null,[[6,12,12]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,19,168],[23,52,165]],null]]]\n[13,null,[[0,13,null,[[6,32,13]],null]]]\n[13,null,[[0,13,null,[[16,29,13]],null]]]\n[13,null,[[0,13,null,[[6,40,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,35,13],[39,70,4],[74,101,2]],null]]]\n[1,null,[[0,1,null,[[8,35,1]],null]]]\n\n[12,null,[[0,12,null,[[6,12,12]],null]]]\n\n\n[194,null,[[0,194,null,[],null]]]\n[216,null,[[0,216,null,[[14,25,216]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,11,\"2/2\"],[13,47,216]],null]]]\n\n[181,null,[[0,181,null,[[2,18,181]],null]]]\n\n\n\n\n[289,\"m\",[[0,289]]]\n[289,null,[[0,289,null,[[2,18,289]],null]]]\n\n\n\n\n[43,\"m\",[[0,43]]]\n[43,null,[[0,43,null,[[13,29,43]],null]]]\n[43,null,[[0,43,null,[[2,14,43]],null]]]\n[43,null,[[0,43,null,[[2,64,43]],null]]]\n[39,null,[[0,39,null,[[2,48,39]],null]]]\n\n\n[55,\"m\",[[0,55]]]\n[55,null,[[0,55,null,[[13,29,55]],null]]]\n[55,null,[[0,55,null,[[2,14,55]],null]]]\n[55,null,[[0,55,null,[[2,48,55]],null]]]\n[41,null,[[0,41,null,[[2,46,41]],null]]]\n\n\n[1885,\"m\",[[0,1885]]]\n[\"3/3\",\"b\",[[0,\"3/3\",[],[[9,30,1885],[34,52,28],[56,79,25]],null]]]\n\n\n[384,\"m\",[[0,384]]]\n[384,null,[[0,384,null,[[2,65,384]],null]]]\n\n\n\n\n[726,\"m\",[[0,726]]]\n[726,null,[[0,726,null,[],null]]]\n[16,\"b\",[[0,16]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,27,16],[31,53,9],[55,73,16]],null]]]\n\n[583,\"b\",[[0,583]]]\n[583,null,[[0,583,null,[[6,40,583]],null]]]\n\n[46,\"b\",[[0,46]]]\n[46,null,[[0,46,null,[[17,33,46]],null]]]\n[46,null,[[0,46,null,[[6,18,46]],null]]]\n[46,null,[[0,46,null,[[6,63,46]],null]]]\n[46,null,[[0,46,null,[[6,51,46]],null]]]\n\n[61,\"b\",[[0,61]]]\n[61,null,[[0,61,null,[[6,33,61]],null]]]\n\n[26,\"b\",[[0,26]]]\n[26,null,[[0,26,null,[[6,24,26]],null]]]\n\n\n\n[639,\"m\",[[0,639]]]\n[639,null,[[0,639,null,[[13,15,639]],null]]]\n[639,null,[[0,639,null,[[14,18,639]],null]]]\n[639,null,[[0,639,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[241,null,[[0,241,null,[[6,20,241]],null]]]\n\n[79,null,[[0,79,null,[[6,28,79]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,320],[22,42,59]],null]]]\n[2,null,[[0,2,null,[[6,22,2]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[6,12,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[33,null,[[0,33,null,[[6,69,33]],null]]]\n[23,null,[[0,23,null,[[6,25,23]],null]]]\n[18,null,[[0,18,null,[[6,12,18]],null]]]\n\n[280,null,[[0,280,null,[[23,25,280]],null]]]\n[280,null,[[0,280,null,[],null]]]\n[15,null,[[0,15,null,[[8,47,15]],null]]]\n\n[280,null,[[0,280,null,[[17,41,280]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[8,37,10]],null]]]\n\n[267,null,[[0,267,null,[[6,46,267]],null]]]\n[267,null,[[0,267,null,[[6,74,267]],null]]]\n\n\n[611,null,[[0,611,null,[[2,14,611]],null]]]\n\n\n[254,\"m\",[[0,254]]]\n[254,null,[[0,254,null,[[2,15,254]],null]]]\n\n\n\n\n[629,\"m\",[[0,629]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,629],[25,44,280]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,629],[25,41,280]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,13,629],[17,40,307]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,22,\"2/2\"],[24,36,615]],null]]]\n\n[39,null,[[0,39,null,[[13,49,39]],null]]]\n[39,null,[[0,39,null,[[2,19,39]],null]]]\n[39,null,[[0,39,null,[[2,39,39]],null]]]\n[39,null,[[0,39,null,[[2,52,39]],null]]]\n\n\n\n\n\n[1338,\"m\",[[0,1338]]]\n[1338,null,[[0,1338,null,[],null]]]\n[1094,\"b\",[[0,1094]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,27,1094],[32,67,491],[71,102,402]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[44,54,86],[57,72,27]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n\n\n\n\n\n\n\n\n\n\n[254,null,[[0,254,null,[[18,33,254]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[22,null,[[0,22,null,[[10,71,22]],null]]]\n\n[232,null,[[0,232,null,[[10,35,232]],null]]]\n\n\n[959,null,[[0,959,null,[[6,12,959]],null]]]\n\n[9,\"b\",[[0,9]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,55,\"1/2\"],[57,66,0],[69,83,0]],null]]]\n[9,null,[[0,9,null,[[6,12,9]],null]]]\n\n[66,\"b\",[[0,66]]]\n[66,null,[[0,66,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,42,\"2/2\"],[44,62,79]],null]]]\n[79,null,[[0,79,null,[[8,54,79]],null]]]\n\n[60,null,[[0,60,null,[[6,12,60]],null]]]\n\n[88,\"b\",[[0,88]]]\n[88,null,[[0,88,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,16,\"2/2\"],[18,64,117]],null]]]\n\n[80,null,[[0,80,null,[[6,12,80]],null]]]\n\n[36,\"b\",[[0,36]]]\n[36,null,[[0,36,null,[[6,57,36]],null]]]\n[35,null,[[0,35,null,[[6,12,35]],null]]]\n\n[3,\"b\",[[0,3]]]\n[36,\"b\",[[0,36]]]\n[36,null,[[0,36,null,[[6,61,36]],null]]]\n[31,null,[[0,31,null,[[6,12,31]],null]]]\n\n[9,\"b\",[[0,9]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[42,51,0],[54,68,9]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n[1,null,[[0,1,null,[[20,76,1]],null]]]\n\n\n[21359,\"m\",[[0,21359]]]\n[21359,null,[[0,21359,null,[[4,19,21359]],null]]]\n[21359,null,[[0,21359,null,[[4,21,21359]],null]]]\n[21359,null,[[0,21359,null,[[4,17,21359]],null]]]\n[21359,null,[[0,21359,null,[[4,39,21359]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,16,\"2/2\"],[18,47,21359]],null]]]\n\n\n\n\n\n\n\n[126,\"m\",[[0,126]]]\n[126,null,[[0,126,null,[[18,26,126]],null]]]\n[126,null,[[0,126,null,[],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[630,null,[[0,630,null,[[8,31,630]],null]]]\n\n\n\n[126,null,[[0,126,null,[[4,17,126]],null]]]\n\n\n\n[19360,\"m\",[[0,19360]]]\n[19360,null,[[0,19360,null,[[2,72,19360]],null]]]\n\n\n[1873,\"m\",[[0,1873]]]\n[1873,null,[[0,1873,null,[[2,43,1873]],null]]]\n\n\n[15001,\"m\",[[0,15001]]]\n[15001,null,[[0,15001,null,[[2,19,15001]],null]]]\n[15001,null,[[0,15001,null,[[2,17,15001]],null]]]\n[15001,null,[[0,15001,null,[[2,21,15001]],null]]]\n[15001,null,[[0,15001,null,[[2,28,15001]],null]]]\n[15001,null,[[0,15001,null,[[2,14,15001]],null]]]\n\n\n\n\n[14554,\"m\",[[0,14554]]]\n[14554,null,[[0,14554,null,[[2,94,14554]],null]]]\n\n\n\n\n[447,\"m\",[[0,447]]]\n[447,null,[[0,447,null,[[2,55,447]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n\n\n\n\n\n\n\n[2026,\"m\",[[0,2026]]]\n[2026,null,[[0,2026,null,[[2,47,2026]],null]]]\n\n[2026,null,[[0,2026,null,[[2,51,2026]],null]]]\n\n[1404,null,[[0,1404,null,[[2,54,1404]],null]]]\n[1404,null,[[0,1404,null,[[2,38,1404]],null]]]\n[1404,null,[[0,1404,null,[[2,36,1404]],null]]]\n\n[1404,null,[[0,1404,null,[[2,39,1404]],null]]]\n\n\n[1,null,[[0,1,null,[[18,32,1],[48,64,1]],null]]]\n\n\n\n[212,\"m\",[[0,212]]]\n[212,null,[[0,212,null,[[13,28,212]],null]]]\n\n[212,null,[[0,212,null,[[25,69,212]],null]]]\n[212,null,[[0,212,null,[[25,69,212]],null]]]\n\n[212,null,[[0,212,null,[[12,50,212]],null]]]\n[212,null,[[0,212,null,[[12,53,212]],null]]]\n\n[212,null,[[0,212,null,[[2,46,212]],null]]]\n[212,null,[[0,212,null,[[2,51,212]],null]]]\n\n[212,null,[[0,212,null,[[2,100,212]],null]]]\n\n[212,null,[[0,212,null,[[2,75,212]],null]]]\n\n\n\n\n\n\n\n\n\n[2906,\"m\",[[0,2906]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[4,31,5]],null]]]\n\n\n[2905,null,[[0,2905,null,[[18,33,2905],[42,58,2905]],null]]]\n\n\n\n\n\n[2905,null,[[0,2905,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[4,19,41],[20,104,72]],null]]]\n[3,\"b\",[[0,3,null,[[23,64,3]],null]]]\n[15,\"b\",[[0,15,null,[[17,52,15]],null]]]\n[80,\"b\",[[0,80,null,[[18,54,80]],null]]]\n[293,\"b\",[[0,293]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,22,\"2/2\"],[24,42,293]],null]]]\n[292,null,[[0,292,null,[[6,47,292]],null]]]\n\n[134,\"b\",[[0,134]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,22,\"2/2\"],[24,42,134]],null]]]\n[133,null,[[0,133,null,[[6,32,133]],null]]]\n[133,null,[[0,133,null,[[6,41,133]],null]]]\n\n[27,\"b\",[[0,27,null,[[17,52,27]],null]]]\n[32,\"b\",[[0,32,null,[[21,60,32]],null]]]\n[24,\"b\",[[0,24,null,[[21,60,24]],null]]]\n[19,\"b\",[[0,19,null,[[20,58,19]],null]]]\n[34,\"b\",[[0,34,null,[[18,54,34]],null]]]\n\n[49,\"b\",[[0,49]]]\n[78,\"b\",[[0,78]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,22,\"2/2\"],[24,42,78]],null]]]\n\n[315,\"b\",[[0,315]]]\n[315,null,[[0,315,null,[[6,53,315]],null]]]\n\n[55,\"b\",[[0,55,null,[[20,58,55]],null]]]\n[14,\"b\",[[0,14,null,[[19,56,14]],null]]]\n[109,\"b\",[[0,109,null,[[20,45,109]],null]]]\n[46,\"b\",[[0,46,null,[[18,56,46]],null]]]\n[78,\"b\",[[0,78]]]\n[127,\"b\",[[0,127]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[10,97,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[10,106,3]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[40,62,48],[65,87,75]],null]]]\n\n[590,\"b\",[[0,590]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n[13,null,[[0,13,null,[[20,38,13]],null]]]\n[13,null,[[0,13,null,[[8,20,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,36,13],[40,66,10]],null]]]\n[9,null,[[0,9,null,[[10,36,9]],null]]]\n[9,null,[[0,9,null,[[10,61,9]],null]]]\n\n[4,null,[[0,4,null,[[10,29,4]],null]]]\n\n\n\n\n\n\n\n\n\n[1493,null,[[0,1493,null,[[18,34,1493]],null]]]\n[1493,null,[[0,1493,null,[[13,35,1493]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,1194],[31,57,543],[61,79,145]],null]]]\n[39,null,[[0,39,null,[[4,61,39]],null]]]\n\n[1155,null,[[0,1155,null,[[4,53,1155]],null]]]\n\n\n\n[152,\"m\",[[0,152]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[4,44,6]],null]]]\n[6,null,[[0,6,null,[[4,31,6]],null]]]\n\n\n\n[7,\"m\",[[0,7]]]\n[7,null,[[0,7,null,[],null]]]\n[8,null,[[0,8,null,[[4,54,8]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,17,7],[21,43,5]],null]]]\n[0,null,[[0,0,null,[[4,11,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,95,1]],null]]]\n\n\n\n[33,\"m\",[[0,33]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n[33,null,[[0,33,null,[[13,29,33]],null]]]\n[33,null,[[0,33,null,[[2,14,33]],null]]]\n[33,null,[[0,33,null,[[2,44,33]],null]]]\n[33,null,[[0,33,null,[[2,44,33]],null]]]\n\n\n[72,\"m\",[[0,72]]]\n[72,null,[[0,72,null,[[16,35,72]],null]]]\n[72,null,[[0,72,null,[[2,14,72]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[43,null,[[0,43,null,[[4,22,43]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[4,22,4]],null]]]\n\n[25,null,[[0,25,null,[[4,40,25]],null]]]\n[25,null,[[0,25,null,[[4,21,25]],null]]]\n\n\n\n\n\n[68,null,[[0,68,null,[],null]]]\n[52,null,[[0,52,null,[[14,34,52]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,26,52],[30,58,21]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,26,48],[31,38,47],[42,61,19],[63,70,48]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,20,3],[24,31,1],[33,39,3]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,36,\"2/2\"],[38,87,68]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[41,57,29],[60,79,17]],null]]]\n\n\n[3,\"m\",[[0,3]]]\n[3,null,[[0,3,null,[[2,14,3]],null]]]\n[3,null,[[0,3,null,[[2,19,3]],null]]]\n[3,null,[[0,3,null,[[2,52,3]],null]]]\n\n\n[15,\"m\",[[0,15]]]\n[15,null,[[0,15,null,[[2,14,15]],null]]]\n[15,null,[[0,15,null,[[2,36,15]],null]]]\n[15,null,[[0,15,null,[[2,41,15]],null]]]\n[13,null,[[0,13,null,[[2,26,13]],null]]]\n[13,null,[[0,13,null,[[2,25,13]],null]]]\n[11,null,[[0,11,null,[[2,42,11]],null]]]\n[11,null,[[0,11,null,[[2,20,11]],null]]]\n[11,null,[[0,11,null,[[2,51,11]],null]]]\n\n\n\n\n\n\n\n\n\n\n[80,\"m\",[[0,80]]]\n[80,null,[[0,80,null,[[2,14,80]],null]]]\n[80,null,[[0,80,null,[[2,36,80]],null]]]\n\n[80,null,[[0,80,null,[[17,22,80]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,39,80],[43,61,8],[65,91,7]],null]]]\n[5,null,[[0,5,null,[[6,22,5]],null]]]\n[5,null,[[0,5,null,[[6,18,5]],null]]]\n\n[80,null,[[0,80,null,[[2,25,80]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[6,24,1]],null]]]\n\n[9,null,[[0,9,null,[[4,37,9]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,25,69],[29,48,56],[52,73,39]],null]]]\n[40,null,[[0,40,null,[[15,31,40],[43,58,40]],null]]]\n[40,null,[[0,40,null,[[4,16,40]],null]]]\n[40,null,[[0,40,null,[[4,39,40]],null]]]\n[39,null,[[0,39,null,[[4,49,39]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,26,39],[30,53,26]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,40,30],[44,70,28]],null]]]\n[20,null,[[0,20,null,[[8,53,20]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[6,24,1]],null]]]\n\n[18,null,[[0,18,null,[[4,37,18]],null]]]\n\n\n[29,null,[[0,29,null,[[31,41,29]],null]]]\n[29,null,[[0,29,null,[[13,63,29]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,24,29],[28,51,17]],null]]]\n[17,null,[[0,17,null,[[4,28,17]],null]]]\n[10,null,[[0,10,null,[[4,25,10]],null]]]\n[10,null,[[0,10,null,[[4,49,10]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,50,0]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,22,1]],null]]]\n\n[11,null,[[0,11,null,[[2,35,11]],null]]]\n\n\n[292,\"m\",[[0,292]]]\n[292,null,[[0,292,null,[[2,14,292]],null]]]\n[292,null,[[0,292,null,[[2,40,292]],null]]]\n\n\n[27,\"m\",[[0,27]]]\n[27,null,[[0,27,null,[[2,14,27]],null]]]\n[27,null,[[0,27,null,[[2,42,27]],null]]]\n[24,null,[[0,24,null,[[2,47,24]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[40,66,7],[69,73,11]],null]]]\n[15,null,[[0,15,null,[[2,46,15]],null]]]\n\n\n[32,\"m\",[[0,32]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,28,32],[32,72,4]],null]]]\n[2,null,[[0,2,null,[[4,65,2]],null]]]\n\n\n[30,null,[[0,30,null,[[2,14,30]],null]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[12,null,[[0,12,null,[[4,25,12]],null]]]\n\n[18,null,[[0,18,null,[[4,43,18]],null]]]\n[17,null,[[0,17,null,[[4,21,17]],null]]]\n\n\n[28,null,[[0,28,null,[[2,50,28]],null]]]\n\n\n[24,\"m\",[[0,24]]]\n[24,null,[[0,24,null,[[2,14,24]],null]]]\n[24,null,[[0,24,null,[[2,50,24]],null]]]\n[22,null,[[0,22,null,[[2,18,22]],null]]]\n[22,null,[[0,22,null,[[2,25,22]],null]]]\n[22,null,[[0,22,null,[[2,38,22]],null]]]\n\n\n\n\n\n\n[22,null,[[0,22,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,28,53],[32,55,35]],null]]]\n[26,null,[[0,26,null,[[19,39,26]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,13,\"2/2\"],[15,50,26]],null]]]\n[26,null,[[0,26,null,[[6,46,26]],null]]]\n[26,null,[[0,26,null,[[6,26,26]],null]]]\n[26,null,[[0,26,null,[[6,18,26]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[18,null,[[0,18,null,[[8,42,18]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,22,\"2/2\"],[24,88,8]],null]]]\n[6,null,[[0,6,null,[[8,26,6]],null]]]\n[6,null,[[0,6,null,[[8,24,6]],null]]]\n\n[24,null,[[0,24,null,[[6,28,24]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[27,null,[[0,27,null,[[8,55,27]],null]]]\n\n[0,null,[[0,0,null,[[8,26,0]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,9,\"2/2\"],[11,46,17]],null]]]\n[17,null,[[0,17,null,[[2,14,17]],null]]]\n[17,null,[[0,17,null,[[2,26,17]],null]]]\n[17,null,[[0,17,null,[[2,50,17]],null]]]\n\n\n[19,\"m\",[[0,19]]]\n[19,null,[[0,19,null,[[2,14,19]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,69,1]],null]]]\n[18,null,[[0,18,null,[[2,41,18]],null]]]\n[13,null,[[0,13,null,[[2,19,13]],null]]]\n[13,null,[[0,13,null,[[2,49,13]],null]]]\n\n\n\n\n[1,null,[[0,1,null,[[12,14,1]],null]]]\n\n[34,\"m\",[[0,34]]]\n[34,null,[[0,34,null,[[2,14,34]],null]]]\n\n[34,null,[[0,34,null,[[2,33,34]],null]]]\n[34,null,[[0,34,null,[[2,22,34]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[30,null,[[0,30,null,[[17,33,30]],null]]]\n[30,null,[[0,30,null,[[4,16,30]],null]]]\n\n[30,null,[[0,30,null,[[4,27,30]],null]]]\n[30,null,[[0,30,null,[[4,43,30]],null]]]\n[25,null,[[0,25,null,[[4,60,25]],null]]]\n[20,null,[[0,20,null,[[4,27,20]],null]]]\n\n[18,null,[[0,18,null,[[4,36,18]],null]]]\n[18,null,[[0,18,null,[[4,58,18]],null]]]\n\n\n[22,null,[[0,22,null,[[2,31,22]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[43,60,4],[63,67,18]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,19,22],[23,38,4]],null]]]\n[2,null,[[0,2,null,[[4,62,2]],null]]]\n\n\n[20,null,[[0,20,null,[[2,47,20]],null]]]\n\n\n[315,\"m\",[[0,315]]]\n[315,null,[[0,315,null,[[2,14,315]],null]]]\n[312,null,[[0,312,null,[[2,35,312]],null]]]\n[248,null,[[0,248,null,[[2,19,248]],null]]]\n[247,null,[[0,247,null,[[2,54,247]],null]]]\n\n\n[55,\"m\",[[0,55]]]\n[55,null,[[0,55,null,[[2,14,55]],null]]]\n[55,null,[[0,55,null,[[2,42,55]],null]]]\n[55,null,[[0,55,null,[[2,36,55]],null]]]\n[55,null,[[0,55,null,[[2,41,55]],null]]]\n[38,null,[[0,38,null,[[2,26,38]],null]]]\n[38,null,[[0,38,null,[[2,49,38]],null]]]\n\n\n[14,\"m\",[[0,14]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,23,\"2/2\"],[25,79,14]],null]]]\n[12,null,[[0,12,null,[[2,14,12]],null]]]\n[12,null,[[0,12,null,[[2,44,12]],null]]]\n[12,null,[[0,12,null,[[2,41,12]],null]]]\n[10,null,[[0,10,null,[[2,48,10]],null]]]\n\n\n[46,\"m\",[[0,46]]]\n[46,null,[[0,46,null,[[2,14,46]],null]]]\n[46,null,[[0,46,null,[[2,49,46]],null]]]\n\n\n[39,\"m\",[[0,39]]]\n[39,null,[[0,39,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[6,73,3]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[38,44,26],[47,87,10],[72,80,0],[83,87,10]],null]]]\n[36,null,[[0,36,null,[],null]]]\n[4,null,[[0,4,null,[[16,36,4]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[4,null,[[0,4,null,[[6,46,4]],null]]]\n[4,null,[[0,4,null,[[6,24,4]],null]]]\n\n[0,null,[[0,0,null,[[6,12,0]],null]]]\n\n\n\n[36,null,[[0,36,null,[[2,90,36]],null]]]\n[36,null,[[0,36,null,[[2,40,36]],null]]]\n[25,null,[[0,25,null,[[2,26,25]],null]]]\n[25,null,[[0,25,null,[[2,20,25]],null]]]\n[25,null,[[0,25,null,[[2,51,25]],null]]]\n\n\n[1109,\"m\",[[0,1109]]]\n[1109,null,[[0,1109,null,[[2,25,1109]],null]]]\n[1109,null,[[0,1109,null,[[2,19,1109]],null]]]\n[1077,null,[[0,1077,null,[[2,54,1077]],null]]]\n\n\n\n\n\n\n[772,\"m\",[[0,772]]]\n[772,null,[[0,772,null,[[13,29,772]],null]]]\n[772,null,[[0,772,null,[[2,25,772]],null]]]\n[766,null,[[0,766,null,[[2,63,766]],null]]]\n[615,null,[[0,615,null,[[2,49,615]],null]]]\n\n\n\n\n[2792,\"m\",[[0,2792]]]\n[2792,null,[[0,2792,null,[[2,17,2792]],null]]]\n[2792,null,[[0,2792,null,[[2,23,2792]],null]]]\n\n[2792,null,[[0,2792,null,[[27,32,2792]],null]]]\n\n\n\n[2792,null,[[0,2792,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,27,2670],[31,55,2559],[59,73,12]],null]]]\n[11,null,[[0,11,null,[[6,47,11]],null]]]\n\n\n[2670,null,[[0,2670,null,[[15,50,2670]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,23,1904],[27,46,1788]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,43,1716],[47,87,919]],null]]]\n[213,\"b\",[[0,213]]]\n[212,null,[[0,212,null,[[22,48,212]],null]]]\n[212,null,[[0,212,null,[[6,38,212]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,33,212],[37,75,210]],null]]]\n[182,null,[[0,182,null,[[8,38,182]],null]]]\n[182,null,[[0,182,null,[[8,29,182]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[10,68,3]],null]]]\n\n\n\n[205,null,[[0,205,null,[[6,15,205]],null]]]\n\n\n[1692,null,[[0,1692,null,[[4,30,1692]],null]]]\n[1692,null,[[0,1692,null,[[4,25,1692]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[53,null,[[0,53,null,[[4,26,53]],null]]]\n\n\n\n\n\n\n\n[38,\"m\",[[0,38]]]\n[38,null,[[0,38,null,[[2,19,38]],null]]]\n[38,null,[[0,38,null,[[2,23,38]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[36,40,20],[43,65,6]],null]]]\n[26,null,[[0,26,null,[[2,23,26]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[40,44,22],[47,69,4]],null]]]\n[26,null,[[0,26,null,[[2,25,26]],null]]]\n[26,null,[[0,26,null,[[2,41,26]],null]]]\n[23,null,[[0,23,null,[[2,26,23]],null]]]\n[23,null,[[0,23,null,[[2,47,23]],null]]]\n\n\n\n\n\n[30,\"m\",[[0,30]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,29,2]],null]]]\n[2,null,[[0,2,null,[[4,31,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[32,48,13],[51,67,15]],null]]]\n[28,null,[[0,28,null,[[4,16,28]],null]]]\n\n[30,null,[[0,30,null,[[2,19,30]],null]]]\n[30,null,[[0,30,null,[[2,38,30]],null]]]\n[29,null,[[0,29,null,[[2,25,29]],null]]]\n[29,null,[[0,29,null,[[2,41,29]],null]]]\n[29,null,[[0,29,null,[[2,26,29]],null]]]\n[29,null,[[0,29,null,[[2,37,29]],null]]]\n\n\n\n\n[352,\"m\",[[0,352]]]\n[352,null,[[0,352,null,[[2,25,352]],null]]]\n[352,null,[[0,352,null,[[2,27,352]],null]]]\n[352,null,[[0,352,null,[],null]]]\n[389,null,[[0,389,null,[[15,31,389]],null]]]\n[389,null,[[0,389,null,[[4,28,389]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[208,null,[[0,208,null,[[6,47,208]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,33,136],[37,85,11],[39,57,11],[61,84,10]],null]]]\n[5,null,[[0,5,null,[[6,24,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,44,131],[48,107,8],[50,55,8],[60,78,4],[82,105,2]],null]]]\n[4,null,[[0,4,null,[[6,100,4]],null]]]\n\n[127,null,[[0,127,null,[[6,23,127]],null]]]\n\n[324,null,[[0,324,null,[[4,72,324]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,27,\"2/2\"],[29,35,324]],null]]]\n\n[287,null,[[0,287,null,[[2,14,287]],null]]]\n\n\n[389,\"m\",[[0,389]]]\n[389,null,[[0,389,null,[[2,36,389]],null]]]\n[369,null,[[0,369,null,[[2,32,369]],null]]]\n\n\n\n\n\n[432,\"m\",[[0,432]]]\n[432,null,[[0,432,null,[[20,39,432]],null]]]\n[432,null,[[0,432,null,[[2,30,432]],null]]]\n\n[432,null,[[0,432,null,[[2,35,432]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,18,50],[22,56,0]],null]]]\n[0,null,[[0,0,null,[[6,24,0]],null]]]\n\n[50,null,[[0,50,null,[[6,28,50]],null]]]\n[50,null,[[0,50,null,[[6,18,50]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,17,432],[21,32,309],[36,56,301],[60,82,20]],null]]]\n[15,null,[[0,15,null,[[4,22,15]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,25,417],[29,50,96]],null]]]\n[329,null,[[0,329,null,[[4,44,329]],null]]]\n\n\n[412,null,[[0,412,null,[[2,33,412]],null]]]\n[387,null,[[0,387,null,[[2,52,387]],null]]]\n\n[196,null,[[0,196,null,[[2,36,196]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[45,66,125],[69,89,71]],null]]]\n\n\n[412,\"m\",[[0,412]]]\n[412,null,[[0,412,null,[[2,25,412]],null]]]\n[411,null,[[0,411,null,[[2,49,411]],null]]]\n\n\n\n\n\n[156,\"m\",[[0,156]]]\n[156,null,[[0,156,null,[[2,14,156]],null]]]\n[156,null,[[0,156,null,[[2,51,156]],null]]]\n[152,null,[[0,152,null,[[2,29,152]],null]]]\n[152,null,[[0,152,null,[[2,28,152]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[45,63,108],[66,83,19]],null]]]\n\n\n[102,\"m\",[[0,102]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,26,102],[30,53,96]],null]]]\n\n\n[91,\"m\",[[0,91]]]\n[91,null,[[0,91,null,[[2,15,91]],null]]]\n\n\n[152,\"m\",[[0,152]]]\n\n[152,null,[[0,152,null,[[18,35,152]],null]]]\n[152,null,[[0,152,null,[[2,27,152]],null]]]\n\n[152,null,[[0,152,null,[[27,32,152]],null]]]\n[152,null,[[0,152,null,[[23,28,152]],null]]]\n[152,null,[[0,152,null,[[19,21,152]],null]]]\n[152,null,[[0,152,null,[[18,34,152]],null]]]\n\n[152,null,[[0,152,null,[[2,22,152]],null]]]\n\n[152,null,[[0,152,null,[[2,25,152]],null]]]\n\n[148,null,[[0,148,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[19,null,[[0,19,null,[[6,15,19]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8,null,[[0,8,null,[[6,45,8]],null]]]\n[8,null,[[0,8,null,[[6,15,8]],null]]]\n\n\n[131,null,[[0,131,null,[[17,33,131]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[6,37,6]],null]]]\n[6,null,[[0,6,null,[[6,22,6]],null]]]\n\n\n[131,null,[[0,131,null,[[28,33,131]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[24,43,131],[47,76,116]],null]]]\n[131,null,[[0,131,null,[[22,39,131]],null]]]\n[131,null,[[0,131,null,[[19,24,131]],null]]]\n[131,null,[[0,131,null,[[18,23,131]],null]]]\n\n[131,null,[[0,131,null,[[4,35,131]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[20,33,128],[37,59,37]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,21,\"1/2\"],[23,41,0]],null]]]\n[35,null,[[0,35,null,[[6,38,35]],null]]]\n[35,null,[[0,35,null,[[6,37,35]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,20,127],[24,56,120],[60,76,112]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[15,null,[[0,15,null,[[8,61,15]],null]]]\n[13,null,[[0,13,null,[[8,17,13]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,48,91],[52,78,2],[82,101,2],[105,139,2]],null]]]\n[1,null,[[0,1,null,[[8,33,1]],null]]]\n[1,null,[[0,1,null,[[8,39,1]],null]]]\n\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",[],[[24,46,112],[50,66,42],[70,102,42],[106,133,41]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,43,2],[47,64,1],[66,85,2]],null]]]\n[2,null,[[0,2,null,[[6,21,2]],null]]]\n[2,null,[[0,2,null,[[6,37,2]],null]]]\n\n\n[112,null,[[0,112,null,[[4,27,112]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[103,null,[[0,103,null,[[20,26,103]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,18,103],[22,34,101],[38,67,95],[71,96,91],[100,122,86],[127,145,35],[149,167,17]],null]]]\n[31,null,[[0,31,null,[[8,24,31]],null]]]\n[31,null,[[0,31,null,[[8,31,31]],null]]]\n[31,null,[[0,31,null,[[8,45,31]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[26,44,103],[48,62,102]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,34,78],[38,64,73]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,37,67],[41,68,5]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,26,\"2/2\"],[28,93,15]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,20,\"2/2\"],[22,87,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,23,\"2/2\"],[25,83,10]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[12,19,\"1/2\"],[21,85,0]],null]]]\n[9,null,[[0,9,null,[[8,36,9]],null]]]\n[9,null,[[0,9,null,[[8,30,9]],null]]]\n\n\n\n[97,\"b\",[[0,97]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,34,24],[38,62,23]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,37,23],[41,66,1]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[8,86,2]],null]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,28,\"1/2\"],[30,103,0]],null]]]\n[1,null,[[0,1,null,[[6,38,1]],null]]]\n[1,null,[[0,1,null,[[6,32,1]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[9,38,104],[42,75,95],[80,97,10]],null]]]\n[0,null,[[0,0,null,[[6,85,0]],null]]]\n\n\n[104,null,[[0,104,null,[[4,67,104]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[28,null,[[0,28,null,[[23,52,28]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[20,32,1]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[10,60,0]],null]]]\n\n[1,null,[[0,1,null,[[10,68,1]],null]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,80,1]],null]]]\n\n\n[127,null,[[0,127,null,[[2,54,127]],null]]]\n\n[127,null,[[0,127,null,[[2,32,127]],null]]]\n\n\n[15,\"m\",[[0,15]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,42,\"1/2\"],[44,62,0]],null]]]\n[6,null,[[0,6,null,[[4,16,6]],null]]]\n[6,null,[[0,6,null,[[4,41,6]],null]]]\n\n[9,null,[[0,9,null,[[4,22,9]],null]]]\n\n[15,null,[[0,15,null,[[2,19,15]],null]]]\n[13,null,[[0,13,null,[[2,48,13]],null]]]\n\n\n[93,\"m\",[[0,93]]]\n[93,null,[[0,93,null,[[2,49,93]],null]]]\n[88,null,[[0,88,null,[[2,62,88]],null]]]\n\n\n[156,\"m\",[[0,156]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[145,null,[[0,145,null,[[4,37,145]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,11],[22,34,8]],null]]]\n[7,null,[[0,7,null,[[6,21,7]],null]]]\n\n[4,null,[[0,4,null,[[6,24,4]],null]]]\n\n\n\n\n[152,\"m\",[[0,152]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[44,70,24],[73,77,128]],null]]]\n\n\n\n\n[75,\"m\",[[0,75]]]\n[75,null,[[0,75,null,[[2,14,75]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[7,null,[[0,7,null,[[20,36,7]],null]]]\n[7,null,[[0,7,null,[[4,16,7]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,42,7],[46,70,2]],null]]]\n[2,null,[[0,2,null,[[6,50,2]],null]]]\n[2,null,[[0,2,null,[[6,81,2]],null]]]\n[2,null,[[0,2,null,[[6,44,2]],null]]]\n[2,null,[[0,2,null,[[6,39,2]],null]]]\n\n[5,null,[[0,5,null,[[6,39,5]],null]]]\n[2,null,[[0,2,null,[[6,59,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,47,68],[51,82,3]],null]]]\n[3,null,[[0,3,null,[[20,36,3]],null]]]\n[3,null,[[0,3,null,[[4,52,3]],null]]]\n[3,null,[[0,3,null,[[4,77,3]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,28,3],[32,65,1]],null]]]\n[0,null,[[0,0,null,[[6,28,0]],null]]]\n[0,null,[[0,0,null,[[22,38,0]],null]]]\n[0,null,[[0,0,null,[[6,27,0]],null]]]\n[0,null,[[0,0,null,[[6,34,0]],null]]]\n[0,null,[[0,0,null,[[6,50,0]],null]]]\n[0,null,[[0,0,null,[[6,83,0]],null]]]\n\n[3,null,[[0,3,null,[[6,44,3]],null]]]\n\n[3,null,[[0,3,null,[[4,37,3]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[23,null,[[0,23,null,[[15,31,23]],null]]]\n[23,null,[[0,23,null,[[20,25,23]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8,null,[[0,8,null,[[6,64,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[6,47,4]],null]]]\n\n[11,null,[[0,11,null,[[6,23,11]],null]]]\n[11,null,[[0,11,null,[[6,37,11]],null]]]\n\n[22,null,[[0,22,null,[[4,28,22]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,17,\"2/2\"],[19,36,22]],null]]]\n[21,null,[[0,21,null,[[4,27,21]],null]]]\n[21,null,[[0,21,null,[[4,61,21]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,36,42],[40,75,28]],null]]]\n[21,null,[[0,21,null,[[4,25,21]],null]]]\n[21,null,[[0,21,null,[[4,23,21]],null]]]\n[21,null,[[0,21,null,[[4,57,21]],null]]]\n\n[21,null,[[0,21,null,[[4,28,21]],null]]]\n[21,null,[[0,21,null,[[4,51,21]],null]]]\n[19,null,[[0,19,null,[[4,31,19]],null]]]\n\n[44,null,[[0,44,null,[[2,25,44]],null]]]\n[44,null,[[0,44,null,[[2,57,44]],null]]]\n\n\n[15,\"m\",[[0,15]]]\n[15,null,[[0,15,null,[[2,35,15]],null]]]\n\n\n[3,\"m\",[[0,3]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,\"b\",[[0,2]]]\n[2,\"b\",[[0,2]]]\n[2,\"b\",[[0,2]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,17,0]],null]]]\n\n\n[1,null,[[0,1,null,[[18,34,1]],null]]]\n[\"3/3\",\"b\",[[0,\"3/3\",[],[[9,36,1],[41,67,1],[71,97,1]],null]]]\n\n\n[5,\"m\",[[0,5]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,75,2]],null]]]\n\n\n\n[31,\"m\",[[0,31]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[42,62,15],[65,82,1]],null]]]\n[15,null,[[0,15,null,[[4,27,15]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[6,24,3]],null]]]\n\n[12,null,[[0,12,null,[[6,25,12]],null]]]\n\n\n\n[27,null,[[0,27,null,[[2,19,27]],null]]]\n\n\n[22,\"m\",[[0,22]]]\n[22,null,[[0,22,null,[[2,36,22]],null]]]\n\n\n[80,\"m\",[[0,80]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",[\"0\",\"1\",\"2\"],[[18,34,0],[39,83,0],[87,130,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[8,25,0],[29,37,0]],null]]]\n[0,null,[[0,0,null,[[6,96,0]],null]]]\n\n[0,null,[[0,0,null,[[4,42,0]],null]]]\n\n\n\n\n\n[25,\"m\",[[0,25]]]\n[25,null,[[0,25,null,[[14,16,25]],null]]]\n[25,null,[[0,25,null,[[14,18,25]],null]]]\n\n\n\n[25,null,[[0,25,null,[[2,25,25]],null]]]\n\n[25,null,[[0,25,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[24,null,[[0,24,null,[[6,20,24]],null]]]\n\n[8,null,[[0,8,null,[[6,28,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,29,\"2/2\"],[31,37,8]],null]]]\n\n\n[31,null,[[0,31,null,[[20,43,31]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,17,31],[21,31,4],[33,50,31]],null]]]\n\n[31,null,[[0,31,null,[[15,31,31]],null]]]\n[31,null,[[0,31,null,[[4,49,31]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[47,73,8],[76,96,23]],null]]]\n[31,null,[[0,31,null,[[4,57,31]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,25],[19,45,4]],null]]]\n[2,null,[[0,2,null,[[4,22,2]],null]]]\n\n\n[23,null,[[0,23,null,[[2,15,23]],null]]]\n\n\n\n\n[48,\"m\",[[0,48]]]\n[48,null,[[0,48,null,[[2,14,48]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,25,2]],null]]]\n[2,null,[[0,2,null,[[4,39,2]],null]]]\n\n[46,null,[[0,46,null,[[4,25,46]],null]]]\n[46,null,[[0,46,null,[[4,37,46]],null]]]\n[42,null,[[0,42,null,[[4,34,42]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[42,62,29],[65,82,2]],null]]]\n\n[31,null,[[0,31,null,[[2,19,31]],null]]]\n[31,null,[[0,31,null,[[2,52,31]],null]]]\n\n\n\n\n[46,\"m\",[[0,46]]]\n[46,null,[[0,46,null,[[14,18,46]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n[18,null,[[0,18,null,[[19,35,18],[48,67,18]],null]]]\n[18,null,[[0,18,null,[[4,103,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,27,\"2/2\"],[29,36,18]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8,null,[[0,8,null,[[20,36,8]],null]]]\n[8,null,[[0,8,null,[[4,16,8]],null]]]\n[8,null,[[0,8,null,[[4,32,8]],null]]]\n[7,null,[[0,7,null,[[4,45,7]],null]]]\n[7,null,[[0,7,null,[[4,42,7]],null]]]\n[7,null,[[0,7,null,[[4,81,7]],null]]]\n[7,null,[[0,7,null,[[4,11,7]],null]]]\n\n\n[27,null,[[0,27,null,[[2,25,27]],null]]]\n[24,null,[[0,24,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[23,null,[[0,23,null,[[6,20,23]],null]]]\n\n[9,null,[[0,9,null,[[6,28,9]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,29,\"2/2\"],[31,37,9]],null]]]\n\n\n[31,null,[[0,31,null,[[20,36,31]],null]]]\n[31,null,[[0,31,null,[[4,52,31]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[49,71,9],[74,102,22]],null]]]\n[31,null,[[0,31,null,[[4,42,31]],null]]]\n[31,null,[[0,31,null,[[4,72,31]],null]]]\n\n\n\n[18,\"m\",[[0,18]]]\n[18,null,[[0,18,null,[[13,49,18]],null]]]\n[18,null,[[0,18,null,[[2,18,18]],null]]]\n[18,null,[[0,18,null,[[2,35,18]],null]]]\n[18,null,[[0,18,null,[[2,57,18]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n[1,null,[[0,1,null,[[11,27,1]],null]]]\n\n\n\n\n\n[2801,\"m\",[[0,2801]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,11,\"1/2\"],[13,20,0]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[27,37,2801],[41,43,1460]],null]]]\n[2801,null,[[0,2801,null,[[2,19,2801]],null]]]\n\n\n\n\n[1089,\"m\",[[0,1089]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,34,1089],[38,61,494]],null]]]\n\n\n\n\n[126,\"m\",[[0,126]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[126,null,[[0,126,null,[[4,16,126]],null]]]\n\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n\n\n\n\n[432,\"m\",[[0,432]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,28,432],[32,57,288]],null]]]\n\n\n\n\n[157,\"m\",[[0,157]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,34,157],[38,55,82]],null]]]\n\n\n\n\n[60,\"m\",[[0,60]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,31,\"2/2\"],[33,64,60]],null]]]\n\n\n\n\n[2706,\"m\",[[0,2706]]]\n[2706,\"b\",[[0,2706]]]\n[1781,\"b\",[[0,1781]]]\n[1627,\"b\",[[0,1627]]]\n\n\n\n\n[1721,\"m\",[[0,1721]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,26,1721],[30,55,1122]],null]]]\n\n\n\n\n\n[1523,\"m\",[[0,1523]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,30,\"2/2\"],[32,50,1523]],null]]]\n\n\n\n\n\n[3953,\"m\",[[0,3953]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,23,3953],[27,47,72]],null]]]\n\n\n\n\n[349,\"b\",[[0,349]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[27,30,24],[33,49,325]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[9,25,1]],null]]]\n\n[287,\"m\",[[0,287]]]\n[287,null,[[0,287,null,[[18,35,287]],null]]]\n[287,null,[[0,287,null,[[2,27,287]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,17,287],[21,29,270]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,33,17],[37,61,16]],null]]]\n[3,null,[[0,3,null,[[6,18,3]],null]]]\n\n\n[287,null,[[0,287,null,[[13,33,287]],null]]]\n[284,null,[[0,284,null,[[2,32,284]],null]]]\n[284,null,[[0,284,null,[[2,14,284]],null]]]\n\n\n[13,\"m\",[[0,13]]]\n[13,null,[[0,13,null,[[2,14,13]],null]]]\n[13,null,[[0,13,null,[[2,41,13]],null]]]\n[13,null,[[0,13,null,[[2,47,13]],null]]]\n\n\n[5,\"m\",[[0,5]]]\n[5,null,[[0,5,null,[[2,14,5]],null]]]\n\n[5,null,[[0,5,null,[[11,43,5]],null]]]\n\n[5,null,[[0,5,null,[[17,33,5]],null]]]\n[5,null,[[0,5,null,[[22,38,5]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,71,1]],null]]]\n\n[4,null,[[0,4,null,[[4,35,4]],null]]]\n\n\n[5,null,[[0,5,null,[[2,25,5]],null]]]\n[5,null,[[0,5,null,[[12,46,5]],null]]]\n[5,null,[[0,5,null,[[2,31,5]],null]]]\n[5,null,[[0,5,null,[[2,27,5]],null]]]\n[5,null,[[0,5,null,[[2,25,5]],null]]]\n[5,null,[[0,5,null,[[2,56,5]],null]]]\n\n[5,null,[[0,5,null,[[2,85,5]],null]]]\n[5,null,[[0,5,null,[[2,71,5]],null]]]\n\n[5,null,[[0,5,null,[[2,31,5]],null]]]\n\n[5,null,[[0,5,null,[[2,19,5]],null]]]\n\n[5,null,[[0,5,null,[[2,50,5]],null]]]\n\n\n[36,\"m\",[[0,36]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[13,null,[[0,13,null,[[4,44,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[4,47,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[4,47,3]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,54,2]],null]]]\n\n[8,null,[[0,8,null,[[6,47,8]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,48,2]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,48,2]],null]]]\n\n[1,null,[[0,1,null,[[4,22,1]],null]]]\n\n\n\n[3,\"m\",[[0,3]]]\n[3,null,[[0,3,null,[[2,14,3]],null]]]\n[3,null,[[0,3,null,[[2,54,3]],null]]]\n[3,null,[[0,3,null,[[2,19,3]],null]]]\n[3,null,[[0,3,null,[[2,50,3]],null]]]\n\n\n[8,\"m\",[[0,8]]]\n[8,null,[[0,8,null,[[2,14,8]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,35,1]],null]]]\n\n[7,null,[[0,7,null,[[4,37,7]],null]]]\n\n\n[8,null,[[0,8,null,[[17,45,8]],null]]]\n[8,null,[[0,8,null,[[13,31,8]],null]]]\n[8,null,[[0,8,null,[[2,25,8]],null]]]\n[8,null,[[0,8,null,[],null]]]\n[6,null,[[0,6,null,[[16,32,6]],null]]]\n\n[6,null,[[0,6,null,[[4,106,6]],null]]]\n\n[5,null,[[0,5,null,[[4,44,5]],null]]]\n\n[6,null,[[0,6,null,[[2,25,6]],null]]]\n\n[6,null,[[0,6,null,[[2,46,6]],null]]]\n[6,null,[[0,6,null,[[2,48,6]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[2,34,2]],null]]]\n[2,null,[[0,2,null,[[2,22,2]],null]]]\n[2,null,[[0,2,null,[[2,35,2]],null]]]\n[1,null,[[0,1,null,[[2,55,1]],null]]]\n[1,null,[[0,1,null,[[2,55,1]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[2,14,2]],null]]]\n[2,null,[[0,2,null,[[2,32,2]],null]]]\n[2,null,[[0,2,null,[[2,51,2]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[2,14,2]],null]]]\n[2,null,[[0,2,null,[[2,35,2]],null]]]\n[2,null,[[0,2,null,[[2,51,2]],null]]]\n\n\n\n\n[27,\"m\",[[0,27]]]\n[27,null,[[0,27,null,[[2,35,27]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[12,null,[[0,12,null,[[4,67,12]],null]]]\n\n[15,null,[[0,15,null,[[4,31,15]],null]]]\n\n\n[27,null,[[0,27,null,[[2,20,27]],null]]]\n[27,null,[[0,27,null,[[2,19,27]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[],null]]]\n[5,null,[[0,5,null,[[6,58,5]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,16,1]],null]]]\n[1,null,[[0,1,null,[],null]]]\n[2,null,[[0,2,null,[[6,57,2]],null]]]\n\n\n\n[27,null,[[0,27,null,[[2,52,27]],null]]]\n\n\n[7,\"m\",[[0,7]]]\n[7,null,[[0,7,null,[[13,29,7]],null]]]\n\n[7,null,[[0,7,null,[[2,52,7]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[4,69,4]],null]]]\n\n[3,null,[[0,3,null,[[4,31,3]],null]]]\n\n\n[7,null,[[0,7,null,[[2,51,7]],null]]]\n\n\n[12,\"m\",[[0,12]]]\n[12,null,[[0,12,null,[[2,42,12]],null]]]\n[12,null,[[0,12,null,[[2,55,12]],null]]]\n\n\n\n\n[19,\"m\",[[0,19]]]\n[19,null,[[0,19,null,[[2,35,19]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[12,null,[[0,12,null,[[4,67,12]],null]]]\n\n[7,null,[[0,7,null,[[4,31,7]],null]]]\n\n\n[17,null,[[0,17,null,[],null]]]\n\n\n\n[17,null,[[0,17,null,[[2,19,17]],null]]]\n\n[17,null,[[0,17,null,[[2,44,17]],null]]]\n\n\n\n\n[91,\"m\",[[0,91]]]\n[91,null,[[0,91,null,[[13,29,91]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[6,24,3]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[3,null,[[0,3,null,[[6,25,3]],null]]]\n\n[6,null,[[0,6,null,[[4,25,6]],null]]]\n\n\n[91,null,[[0,91,null,[[14,67,91]],null]]]\n[89,null,[[0,89,null,[[2,25,89]],null]]]\n[89,null,[[0,89,null,[[2,27,89]],null]]]\n[89,null,[[0,89,null,[[2,36,89]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[27,null,[[0,27,null,[[4,20,27]],null]]]\n[27,null,[[0,27,null,[[4,41,27]],null]]]\n\n\n[89,null,[[0,89,null,[[2,48,89]],null]]]\n\n\n[76,\"m\",[[0,76]]]\n[76,null,[[0,76,null,[[20,37,76]],null]]]\n[76,null,[[0,76,null,[[13,29,76]],null]]]\n[76,null,[[0,76,null,[[2,19,76]],null]]]\n\n[76,null,[[0,76,null,[[2,27,76]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[6,28,76],[32,58,11]],null]]]\n[76,null,[[0,76,null,[[4,16,76]],null]]]\n\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n\n[76,null,[[0,76,null,[],null]]]\n[91,null,[[0,91,null,[[4,52,91]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[19,null,[[0,19,null,[[6,28,19]],null]]]\n\n\n[70,null,[[0,70,null,[[2,29,70]],null]]]\n\n[70,null,[[0,70,null,[[2,32,70]],null]]]\n\n[70,null,[[0,70,null,[[2,59,70]],null]]]\n\n\n[28,\"m\",[[0,28]]]\n[28,null,[[0,28,null,[[13,29,28],[43,60,28]],null]]]\n[28,null,[[0,28,null,[[2,19,28]],null]]]\n\n[28,null,[[0,28,null,[[2,27,28]],null]]]\n\n[28,null,[[0,28,null,[[2,29,28]],null]]]\n[28,null,[[0,28,null,[],null]]]\n[31,null,[[0,31,null,[[4,43,31]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[6,28,3]],null]]]\n\n\n[28,null,[[0,28,null,[[2,29,28]],null]]]\n\n[28,null,[[0,28,null,[[2,32,28]],null]]]\n\n[28,null,[[0,28,null,[[2,61,28]],null]]]\n\n\n[45,\"m\",[[0,45]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,28,45],[32,53,45],[57,77,0],[80,106,45]],null]]]\n\n\n[6,\"m\",[[0,6]]]\n[6,null,[[0,6,null,[[2,25,6]],null]]]\n\n[6,null,[[0,6,null,[[2,27,6]],null]]]\n[6,null,[[0,6,null,[[2,46,6]],null]]]\n[6,null,[[0,6,null,[[2,45,6]],null]]]\n[6,null,[[0,6,null,[[2,27,6]],null]]]\n[6,null,[[0,6,null,[[2,47,6]],null]]]\n\n[6,null,[[0,6,null,[[2,33,6]],null]]]\n[6,null,[[0,6,null,[[2,52,6]],null]]]\n\n\n[13,\"m\",[[0,13]]]\n[13,null,[[0,13,null,[[2,19,13]],null]]]\n[13,null,[[0,13,null,[[2,19,13]],null]]]\n[13,null,[[0,13,null,[[2,29,13]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,67,2]],null]]]\n\n\n[13,null,[[0,13,null,[[2,25,13]],null]]]\n[13,null,[[0,13,null,[],null]]]\n[6,null,[[0,6,null,[[4,56,6]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,28,2]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,50,1]],null]]]\n\n[13,null,[[0,13,null,[[2,25,13]],null]]]\n[13,null,[[0,13,null,[[2,52,13]],null]]]\n\n[13,null,[[0,13,null,[[2,57,13]],null]]]\n\n\n[6,\"m\",[[0,6]]]\n[6,null,[[0,6,null,[[13,49,6]],null]]]\n[6,null,[[0,6,null,[[2,87,6]],null]]]\n[6,null,[[0,6,null,[[2,25,6]],null]]]\n[6,null,[[0,6,null,[[2,17,6]],null]]]\n[6,null,[[0,6,null,[[2,24,6]],null]]]\n[6,null,[[0,6,null,[[2,33,6]],null]]]\n[6,null,[[0,6,null,[[2,53,6]],null]]]\n\n\n[7,\"m\",[[0,7]]]\n[7,null,[[0,7,null,[[18,34,7]],null]]]\n[7,null,[[0,7,null,[[2,25,7]],null]]]\n[7,null,[[0,7,null,[[2,60,7]],null]]]\n[7,null,[[0,7,null,[[2,33,7]],null]]]\n[7,null,[[0,7,null,[[2,57,7]],null]]]\n\n\n[56,\"m\",[[0,56]]]\n[56,null,[[0,56,null,[[18,34,56]],null]]]\n\n\n\n\n[56,null,[[0,56,null,[[2,32,56]],null]]]\n[56,null,[[0,56,null,[[2,28,56]],null]]]\n[56,null,[[0,56,null,[[2,26,56]],null]]]\n\n[56,null,[[0,56,null,[[2,25,56]],null]]]\n\n[56,null,[[0,56,null,[],null]]]\n[52,null,[[0,52,null,[[19,24,52]],null]]]\n[52,null,[[0,52,null,[[19,35,52],[48,67,52]],null]]]\n[52,null,[[0,52,null,[[4,28,52]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,52],[23,50,7]],null]]]\n[4,null,[[0,4,null,[[6,18,4]],null]]]\n[4,null,[[0,4,null,[[6,22,4]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[6,79,6]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,36,46],[40,62,40]],null]]]\n[7,null,[[0,7,null,[[6,93,7]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,18,39],[22,42,2]],null]]]\n[0,null,[[0,0,null,[[8,45,0]],null]]]\n\n[39,null,[[0,39,null,[[8,56,39]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,32,39],[36,57,38]],null]]]\n\n[6,null,[[0,6,null,[[8,109,6]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[10,26,2]],null]]]\n\n[33,null,[[0,33,null,[[8,31,33]],null]]]\n[33,null,[[0,33,null,[[8,53,33]],null]]]\n[33,null,[[0,33,null,[[8,33,33]],null]]]\n[33,null,[[0,33,null,[[8,31,33]],null]]]\n[33,null,[[0,33,null,[[8,39,33]],null]]]\n[33,null,[[0,33,null,[[8,79,33]],null]]]\n\n\n\n\n[56,null,[[0,56,null,[[2,25,56]],null]]]\n\n[56,null,[[0,56,null,[[2,60,56]],null]]]\n\n\n[52,\"m\",[[0,52]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,24,52],[28,47,26],[51,73,26]],null]]]\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n\n\n[84,\"m\",[[0,84]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,84],[25,41,7]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,21,84],[25,44,7]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,15,84],[19,41,7]],null]]]\n\n[84,null,[[0,84,null,[],null]]]\n[7,null,[[0,7,null,[[16,52,7]],null]]]\n[7,null,[[0,7,null,[[4,31,7]],null]]]\n[7,null,[[0,7,null,[[4,38,7]],null]]]\n[7,null,[[0,7,null,[[4,61,7]],null]]]\n\n\n[84,null,[[0,84,null,[[2,14,84]],null]]]\n\n\n[77,\"m\",[[0,77]]]\n[77,null,[[0,77,null,[[13,49,77]],null]]]\n\n[77,null,[[0,77,null,[[2,29,77]],null]]]\n[77,null,[[0,77,null,[[2,74,77]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[21,null,[[0,21,null,[[4,69,21]],null]]]\n\n\n[77,null,[[0,77,null,[[2,56,77]],null]]]\n\n\n[5,\"m\",[[0,5]]]\n[5,null,[[0,5,null,[[13,29,5]],null]]]\n[5,null,[[0,5,null,[[2,26,5]],null]]]\n[5,null,[[0,5,null,[[2,46,5]],null]]]\n[5,null,[[0,5,null,[[2,55,5]],null]]]\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[[13,29,4]],null]]]\n[4,null,[[0,4,null,[[2,18,4]],null]]]\n[4,null,[[0,4,null,[[2,27,4]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,43,6],[47,71,6]],null]]]\n[4,null,[[0,4,null,[[4,42,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,31,\"2/2\"],[33,39,4]],null]]]\n[2,null,[[0,2,null,[[4,26,2]],null]]]\n\n[4,null,[[0,4,null,[[2,27,4]],null]]]\n[4,null,[[0,4,null,[[2,54,4]],null]]]\n\n\n[23,\"m\",[[0,23]]]\n[23,null,[[0,23,null,[[17,22,23]],null]]]\n[23,null,[[0,23,null,[[13,29,23]],null]]]\n[23,null,[[0,23,null,[[2,37,23]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,20,1]],null]]]\n\n[23,null,[[0,23,null,[[2,27,23]],null]]]\n[23,null,[[0,23,null,[[2,56,23]],null]]]\n[23,null,[[0,23,null,[[2,52,23]],null]]]\n\n\n[26,\"m\",[[0,26]]]\n[26,null,[[0,26,null,[[12,38,26]],null]]]\n[26,null,[[0,26,null,[],null]]]\n[13,null,[[0,13,null,[[4,55,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[6,28,4]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[4,49,3]],null]]]\n\n[26,null,[[0,26,null,[[2,13,26]],null]]]\n\n\n[299,\"m\",[[0,299]]]\n[299,null,[[0,299,null,[],null]]]\n[2,\"b\",[[0,2]]]\n[2,null,[[0,2,null,[[6,56,2]],null]]]\n\n[11,\"b\",[[0,11]]]\n[11,null,[[0,11,null,[[6,57,11]],null]]]\n\n[7,\"b\",[[0,7]]]\n[7,\"b\",[[0,7]]]\n[7,null,[[0,7,null,[[6,60,7]],null]]]\n\n[1,\"b\",[[0,1]]]\n[1,null,[[0,1,null,[[6,58,1]],null]]]\n\n[113,\"b\",[[0,113]]]\n[113,null,[[0,113,null,[[6,59,113]],null]]]\n\n[88,\"b\",[[0,88]]]\n[88,null,[[0,88,null,[[6,59,88]],null]]]\n\n[77,\"b\",[[0,77]]]\n[77,null,[[0,77,null,[[6,63,77]],null]]]\n\n\n\n\n\n\n[396,\"m\",[[0,396]]]\n[396,null,[[0,396,null,[[17,33,396],[46,65,396]],null]]]\n[396,null,[[0,396,null,[[13,29,396]],null]]]\n\n\n[396,null,[[0,396,null,[[22,27,396]],null]]]\n\n[396,null,[[0,396,null,[],null]]]\n[299,\"b\",[[0,299]]]\n[299,null,[[0,299,null,[[6,94,299]],null]]]\n\n[29,\"b\",[[0,29]]]\n[29,null,[[0,29,null,[[6,40,29]],null]]]\n\n[4,\"b\",[[0,4]]]\n[4,null,[[0,4,null,[[6,39,4]],null]]]\n\n[4,\"b\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[8,71,3]],null]]]\n[2,null,[[0,2,null,[[8,31,2]],null]]]\n[2,null,[[0,2,null,[[8,49,2]],null]]]\n[2,null,[[0,2,null,[[8,33,2]],null]]]\n[2,null,[[0,2,null,[[8,29,2]],null]]]\n[2,null,[[0,2,null,[[8,31,2]],null]]]\n\n[2,null,[[0,2,null,[[8,30,2]],null]]]\n\n[2,null,[[0,2,null,[[8,47,2]],null]]]\n\n[2,null,[[0,2,null,[[8,63,2]],null]]]\n\n[1,null,[[0,1,null,[[6,12,1]],null]]]\n\n[28,\"b\",[[0,28]]]\n[28,null,[[0,28,null,[[6,18,28]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,32,28],[36,60,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[22,43,10]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[26,47,10],[51,69,9]],null]]]\n\n[7,null,[[0,7,null,[[10,31,7]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[9,null,[[0,9,null,[[8,36,9]],null]]]\n[9,null,[[0,9,null,[[8,31,9]],null]]]\n[9,null,[[0,9,null,[[8,20,9]],null]]]\n\n\n[19,null,[[0,19,null,[[6,47,19]],null]]]\n[19,null,[[0,19,null,[[6,31,19]],null]]]\n[19,null,[[0,19,null,[[6,27,19]],null]]]\n\n[19,null,[[0,19,null,[[6,29,19]],null]]]\n\n[19,null,[[0,19,null,[[6,28,19]],null]]]\n\n[19,null,[[0,19,null,[[6,45,19]],null]]]\n[19,null,[[0,19,null,[[6,33,19]],null]]]\n\n[19,null,[[0,19,null,[[6,61,19]],null]]]\n\n[4,\"b\",[[0,4]]]\n[4,null,[[0,4,null,[[6,36,4]],null]]]\n[4,null,[[0,4,null,[[6,50,4]],null]]]\n[4,null,[[0,4,null,[[6,85,4]],null]]]\n[4,null,[[0,4,null,[[6,18,4]],null]]]\n[4,null,[[0,4,null,[[6,66,4]],null]]]\n\n[1,\"b\",[[0,1]]]\n[2,null,[[0,2,null,[[6,40,2]],null]]]\n[2,null,[[0,2,null,[[6,18,2]],null]]]\n[2,null,[[0,2,null,[[6,67,2]],null]]]\n\n[4,\"b\",[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[4,null,[[0,4,null,[[8,20,4]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[12,31,\"1/2\"],[33,51,0]],null]]]\n\n[4,null,[[0,4,null,[[8,39,4]],null]]]\n[4,null,[[0,4,null,[[8,52,4]],null]]]\n[4,null,[[0,4,null,[[8,87,4]],null]]]\n[4,null,[[0,4,null,[[8,20,4]],null]]]\n[4,null,[[0,4,null,[[8,69,4]],null]]]\n\n\n[8,\"b\",[[0,8]]]\n[8,null,[[0,8,null,[[6,36,8]],null]]]\n[8,null,[[0,8,null,[[6,50,8]],null]]]\n[8,null,[[0,8,null,[[6,85,8]],null]]]\n[8,null,[[0,8,null,[[6,18,8]],null]]]\n[8,null,[[0,8,null,[[6,67,8]],null]]]\n\n[2,\"b\",[[0,2]]]\n[2,null,[[0,2,null,[[6,40,2]],null]]]\n[2,null,[[0,2,null,[[6,18,2]],null]]]\n[2,null,[[0,2,null,[[6,64,2]],null]]]\n\n[1,\"b\",[[0,1]]]\n[1,null,[[0,1,null,[[6,40,1]],null]]]\n[1,null,[[0,1,null,[[6,18,1]],null]]]\n[1,null,[[0,1,null,[[6,57,1]],null]]]\n\n[5,\"b\",[[0,5]]]\n[5,null,[[0,5,null,[[6,18,5]],null]]]\n[5,null,[[0,5,null,[[6,59,5]],null]]]\n\n[6,\"b\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[8,42,5]],null]]]\n\n\n\n[2,null,[[0,2,null,[[2,20,2]],null]]]\n\n\n[391,\"m\",[[0,391]]]\n[391,null,[[0,391,null,[[13,29,391]],null]]]\n[391,null,[[0,391,null,[[13,59,391]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[4,29,10]],null]]]\n[10,null,[[0,10,null,[[4,29,10]],null]]]\n[10,null,[[0,10,null,[[4,56,10]],null]]]\n\n[378,null,[[0,378,null,[[4,16,378]],null]]]\n\n\n\n[414,\"m\",[[0,414]]]\n[414,null,[[0,414,null,[[13,29,414]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[23,null,[[0,23,null,[[4,53,23]],null]]]\n[23,null,[[0,23,null,[[4,59,23]],null]]]\n\n[391,null,[[0,391,null,[[4,39,391]],null]]]\n\n\n\n[389,\"m\",[[0,389]]]\n[389,null,[[0,389,null,[[13,29,389]],null]]]\n[389,null,[[0,389,null,[[13,39,389]],null]]]\n[386,null,[[0,386,null,[[2,22,386]],null]]]\n[386,null,[[0,386,null,[],null]]]\n[2,null,[[0,2,null,[[4,48,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[35,39,384],[42,93,2]],null]]]\n\n\n[379,\"m\",[[0,379]]]\n[379,null,[[0,379,null,[[13,29,379]],null]]]\n[379,null,[[0,379,null,[[13,45,379]],null]]]\n[376,null,[[0,376,null,[[2,22,376]],null]]]\n[376,null,[[0,376,null,[],null]]]\n[10,null,[[0,10,null,[[4,54,10]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[35,39,367],[42,86,9]],null]]]\n\n\n[379,\"m\",[[0,379]]]\n[379,null,[[0,379,null,[[18,35,379]],null]]]\n[379,null,[[0,379,null,[[2,27,379]],null]]]\n[379,null,[[0,379,null,[[13,38,379]],null]]]\n[376,null,[[0,376,null,[[2,32,376]],null]]]\n[376,null,[[0,376,null,[[2,14,376]],null]]]\n\n\n[184,\"m\",[[0,184]]]\n[184,null,[[0,184,null,[[13,29,184]],null]]]\n[184,null,[[0,184,null,[[2,56,184]],null]]]\n[181,null,[[0,181,null,[[2,49,181]],null]]]\n\n\n[94,\"m\",[[0,94]]]\n\n[94,null,[[0,94,null,[[14,36,94]],null]]]\n[92,null,[[0,92,null,[[24,29,92]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,24,92],[28,49,0]],null]]]\n[0,null,[[0,0,null,[[4,29,0]],null]]]\n[0,null,[[0,0,null,[[4,27,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,92],[31,51,92]],null]]]\n[18,null,[[0,18,null,[[4,58,18]],null]]]\n[18,null,[[0,18,null,[[4,39,18]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[4,26,0]],null]]]\n[0,null,[[0,0,null,[[4,39,0]],null]]]\n\n\n[92,null,[[0,92,null,[[2,15,92]],null]]]\n\n\n[16,\"m\",[[0,16]]]\n[16,null,[[0,16,null,[[2,55,16]],null]]]\n\n[16,null,[[0,16,null,[],null]]]\n\n\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,30,84],[34,50,17]],null]]]\n\n\n[17,null,[[0,17,null,[[8,57,17]],null]]]\n\n\n[84,null,[[0,84,null,[[6,53,84]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[300,\"m\",[[0,300]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,27,300],[31,50,256],[54,86,70]],null]]]\n[10,null,[[0,10,null,[[19,35,10]],null]]]\n[10,null,[[0,10,null,[[8,20,10]],null]]]\n[10,null,[[0,10,null,[[8,45,10]],null]]]\n\n[290,null,[[0,290,null,[[8,55,290]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[14,35,31],[39,58,19],[62,86,6],[90,109,2]],null]]]\n[31,null,[[0,31,null,[[12,47,31]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[12,49,0]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[15,null,[[0,15,null,[[12,49,15]],null]]]\n\n\n\n\n[69,null,[[0,69,null,[[6,42,69]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[6,\"b\",[[0,6]]]\n[2,\"b\",[[0,2]]]\n[0,\"b\",[[0,0]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[316,\"m\",[[0,316]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,26,316],[30,53,49]],null]]]\n[5,null,[[0,5,null,[[22,40,5]],null]]]\n[5,null,[[0,5,null,[],null]]]\n[5,null,[[0,5,null,[[10,66,5]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[5,null,[[0,5,null,[[12,31,5]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[37,44,5],[48,64,0]],null]]]\n[5,null,[[0,5,null,[[12,24,5]],null]]]\n\n[0,null,[[0,0,null,[[12,22,0]],null]]]\n\n\n\n\n[311,null,[[0,311,null,[[6,62,311]],null]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[74,null,[[0,74,null,[[6,56,74]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[8,29,6]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[23,null,[[0,23,null,[[27,63,23]],null]]]\n[23,null,[[0,23,null,[[8,39,23]],null]]]\n[23,null,[[0,23,null,[[8,69,23]],null]]]\n\n[23,null,[[0,23,null,[[8,67,23]],null]]]\n\n\n[51,null,[[0,51,null,[[6,18,51]],null]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[7,null,[[0,7,null,[[6,36,7]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[26,41,6],[45,52,0]],null]]]\n\n[7,null,[[0,7,null,[[6,18,7]],null]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[8,33,4]],null]]]\n\n[4,null,[[0,4,null,[[30,46,4]],null]]]\n[4,null,[[0,4,null,[[8,20,4]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n[2,null,[[0,2,null,[[10,57,2]],null]]]\n[2,null,[[0,2,null,[[10,37,2]],null]]]\n[2,null,[[0,2,null,[[10,22,2]],null]]]\n\n\n[2,null,[[0,2,null,[[10,58,2]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[2,null,[[0,2,null,[[8,33,2]],null]]]\n[2,null,[[0,2,null,[[30,46,2]],null]]]\n[2,null,[[0,2,null,[[8,20,2]],null]]]\n[2,null,[[0,2,null,[[8,56,2]],null]]]\n\n[0,null,[[0,0,null,[[8,38,0]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[33,null,[[0,33,null,[[6,35,33]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[16,null,[[0,16,null,[[8,71,16]],null]]]\n\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[1260,\"m\",[[0,1260]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,27,1260],[31,46,469]],null]]]\n[11,null,[[0,11,null,[[8,21,11]],null]]]\n\n[1249,null,[[0,1249,null,[[8,38,1249]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[3518,\"m\",[[0,3518]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,27,3518],[32,43,1245],[47,58,1145]],null]]]\n[127,null,[[0,127,null,[[8,47,127]],null]]]\n\n[3391,null,[[0,3391,null,[[8,38,3391]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[10,28,\"0/2\"],[30,54,0]],null]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[8,75,4]],null]]]\n\n[39,null,[[0,39,null,[[8,49,39]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[34,null,[[0,34,null,[],null]]]\n[32,null,[[0,32,null,[[19,30,32]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,16,32],[20,54,32]],null]]]\n[12,null,[[0,12,null,[[10,55,12]],null]]]\n\n\n[34,null,[[0,34,null,[[6,51,34]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[14,null,[[0,14,null,[],null]]]\n[16,null,[[0,16,null,[[19,30,16]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[12,16,16],[20,38,16],[42,76,0]],null]]]\n[0,null,[[0,0,null,[[10,57,0]],null]]]\n\n\n\n[14,null,[[0,14,null,[[6,22,14]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[20,null,[[0,20,null,[[22,38,20]],null]]]\n[20,null,[[0,20,null,[[17,69,20]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[8,39,0]],null]]]\n[0,null,[[0,0,null,[[8,36,0]],null]]]\n[0,null,[[0,0,null,[[8,66,0]],null]]]\n[0,null,[[0,0,null,[[8,64,0]],null]]]\n\n[20,null,[[0,20,null,[[8,20,20]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[223,null,[[0,223,null,[[8,44,223]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[4,null,[[0,4,null,[[8,61,4]],null]]]\n\n[4,null,[[0,4,null,[[6,36,4]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,33,14],[37,53,10]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[8,73,4]],null]]]\n\n[11,null,[[0,11,null,[[6,53,11]],null]]]\n[11,null,[[0,11,null,[[6,66,11]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[33,null,[[0,33,null,[[6,42,33]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,25,33],[29,51,6]],null]]]\n[2,null,[[0,2,null,[[8,78,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[8,20,5]],null]]]\n[5,null,[[0,5,null,[[26,46,5]],null]]]\n[5,null,[[0,5,null,[],null]]]\n[6,null,[[0,6,null,[[21,37,6]],null]]]\n[6,null,[[0,6,null,[[10,43,6]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[14,79,1]],null]]]\n\n[5,null,[[0,5,null,[[14,41,5]],null]]]\n\n[6,null,[[0,6,null,[[10,69,6]],null]]]\n\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[8,66,4]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[12,34,\"1/2\"],[36,54,0]],null]]]\n\n\n[17,null,[[0,17,null,[[6,35,17]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,19,4],[23,27,4]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[8,30,1]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[30,null,[[0,30,null,[[8,62,30]],null]]]\n\n[36,null,[[0,36,null,[[6,41,36]],null]]]\n[36,null,[[0,36,null,[[6,19,36]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[8,null,[[0,8,null,[[6,32,8]],null]]]\n\n[8,null,[[0,8,null,[[17,21,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[8,24,3]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[5,null,[[0,5,null,[[8,22,5]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[8,null,[[0,8,null,[[17,33,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,32,8],[36,55,3],[60,81,6],[85,104,4]],null]]]\n[6,null,[[0,6,null,[[10,22,6]],null]]]\n[6,null,[[0,6,null,[[10,33,6]],null]]]\n\n\n\n[8,null,[[0,8,null,[[6,29,8]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[8,71,6]],null]]]\n\n[32,null,[[0,32,null,[[6,29,32]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[91,null,[[0,91,null,[[6,29,91]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[68,null,[[0,68,null,[[8,64,68]],null]]]\n[67,null,[[0,67,null,[[8,47,67]],null]]]\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[8,57,5]],null]]]\n\n\n[7,null,[[0,7,null,[[6,42,7]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,33,7],[37,53,2]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[253,\"m\",[[0,253]]]\n[330,\"m\",[[0,330]]]\n[330,null,[[0,330,null,[[21,25,330]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,24,330],[28,54,330]],null]]]\n[50,null,[[0,50,null,[[22,40,50]],null]]]\n[50,null,[[0,50,null,[],null]]]\n[50,null,[[0,50,null,[[10,41,50]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[11,null,[[0,11,null,[[12,31,11]],null]]]\n[11,null,[[0,11,null,[[12,27,11]],null]]]\n\n[0,null,[[0,0,null,[[12,22,0]],null]]]\n\n\n\n\n\n\n[291,null,[[0,291,null,[[6,50,291]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,26,291],[30,52,280]],null]]]\n\n\n[16,null,[[0,16,null,[],null]]]\n[16,null,[[0,16,null,[[10,68,16]],null]]]\n\n[13,null,[[0,13,null,[[10,52,13]],null]]]\n[11,null,[[0,11,null,[[10,58,11]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[[16,24,5],[28,31,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8,null,[[0,8,null,[[10,33,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[10,25,2]],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n[275,null,[[0,275,null,[[6,31,275]],null]]]\n\n[275,null,[[0,275,null,[[6,37,275]],null]]]\n\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[18,null,[[0,18,null,[[20,38,18]],null]]]\n[18,null,[[0,18,null,[],null]]]\n[18,null,[[0,18,null,[[27,57,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,35,\"2/2\"],[37,55,16]],null]]]\n\n[13,null,[[0,13,null,[[10,39,13]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[5,null,[[0,5,null,[[12,31,5]],null]]]\n\n[0,null,[[0,0,null,[[12,22,0]],null]]]\n\n\n\n\n[44,null,[[0,44,null,[[6,36,44]],null]]]\n\n\n\n[253,\"m\",[[0,253]]]\n[253,\"m\",[[0,253]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[8,20,4]],null]]]\n\n[7,null,[[0,7,null,[[8,32,7]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[19,34,1]],null]]]\n[1,null,[[0,1,null,[[23,30,1]],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[91,\"m\",[[0,91]]]\n[91,null,[[0,91,null,[[2,37,91]],null]]]\n[91,null,[[0,91,null,[[2,37,91]],null]]]\n[91,null,[[0,91,null,[[2,33,91]],null]]]\n\n\n[87,\"m\",[[0,87]]]\n[87,null,[[0,87,null,[[12,36,87]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,23,87],[27,48,60],[52,69,66]],null]]]\n[48,null,[[0,48,null,[[4,29,48]],null]]]\n[48,null,[[0,48,null,[[4,61,48]],null]]]\n\n[39,null,[[0,39,null,[[4,34,39]],null]]]\n\n\n\n[1,null,[[0,1,null,[[9,25,1]],null]]]\n\n\n\n[81,\"m\",[[0,81]]]\n[81,null,[[0,81,null,[[12,14,81]],null]]]\n[81,null,[[0,81,null,[[19,33,81]],null]]]\n[81,null,[[0,81,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[6,64,6]],null]]]\n\n\n[352,null,[[0,352,null,[[13,50,352]],null]]]\n\n[352,null,[[0,352,null,[],null]]]\n[54,\"b\",[[0,54]]]\n[75,\"b\",[[0,75]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,23,53],[27,49,38]],null]]]\n[38,null,[[0,38,null,[[12,29,38]],null]]]\n[38,null,[[0,38,null,[[12,52,38]],null]]]\n\n[15,null,[[0,15,null,[[10,43,15]],null]]]\n\n[22,null,[[0,22,null,[[8,60,22]],null]]]\n[22,null,[[0,22,null,[[8,49,22]],null]]]\n\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[8,60,0]],null]]]\n[0,null,[[0,0,null,[[8,36,0]],null]]]\n[0,null,[[0,0,null,[[8,36,0]],null]]]\n[0,null,[[0,0,null,[[8,14,0]],null]]]\n\n[277,\"b\",[[0,277]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[9,null,[[0,9,null,[[10,62,9]],null]]]\n[9,null,[[0,9,null,[[10,43,9]],null]]]\n[9,null,[[0,9,null,[[10,38,9]],null]]]\n\n[268,null,[[0,268,null,[[10,27,268]],null]]]\n\n\n\n\n\n[11,\"m\",[[0,11]]]\n[11,null,[[0,11,null,[[11,48,11]],null]]]\n\n[11,null,[[0,11,null,[[2,19,11]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[6,15,11],[19,63,0]],null]]]\n[0,null,[[0,0,null,[[4,21,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[26,30,0],[33,39,0]],null]]]\n\n[11,null,[[0,11,null,[[4,34,11]],null]]]\n\n[11,null,[[0,11,null,[[2,23,11]],null]]]\n[11,null,[[0,11,null,[[2,40,11]],null]]]\n\n[11,null,[[0,11,null,[[2,13,11]],null]]]\n\n\n[13,\"m\",[[0,13]]]\n[13,null,[[0,13,null,[[12,14,13]],null]]]\n[13,null,[[0,13,null,[[19,35,13]],null]]]\n[13,null,[[0,13,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[6,67,1]],null]]]\n\n\n[139,null,[[0,139,null,[[13,50,139]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,20,\"2/2\"],[22,28,139]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[6,58,4]],null]]]\n[4,null,[[0,4,null,[[6,34,4]],null]]]\n[4,null,[[0,4,null,[[6,34,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,58,2]],null]]]\n[2,null,[[0,2,null,[[6,40,2]],null]]]\n[2,null,[[0,2,null,[[6,34,2]],null]]]\n\n[121,null,[[0,121,null,[[6,23,121]],null]]]\n\n\n[12,null,[[0,12,null,[[2,56,12]],null]]]\n[12,null,[[0,12,null,[[2,42,12]],null]]]\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[[12,14,4]],null]]]\n\n\n[4,null,[[0,4,null,[[11,37,4]],null]]]\n\n[4,null,[[0,4,null,[[17,33,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,43,20],[47,59,20]],null]]]\n[20,null,[[0,20,null,[[4,38,20]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[10,30,1]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[1,null,[[0,1,null,[[12,60,1]],null]]]\n\n[1,null,[[0,1,null,[[10,30,1]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[1,null,[[0,1,null,[[12,60,1]],null]]]\n\n\n[2,null,[[0,2,null,[[8,36,2]],null]]]\n\n[4,null,[[0,4,null,[[6,12,4]],null]]]\n\n[16,null,[[0,16,null,[[4,14,16]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[4,30,1]],null]]]\n[1,null,[[0,1,null,[[4,15,1]],null]]]\n\n[3,null,[[0,3,null,[[2,16,3]],null]]]\n\n\n\n\n\n\n\n\n\n\n[125,\"m\",[[0,125]]]\n\n[125,null,[[0,125,null,[[14,28,125]],null]]]\n[125,null,[[0,125,null,[],null]]]\n[282,null,[[0,282,null,[[4,49,282]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[11,31,282],[35,44,127]],null]]]\n[125,null,[[0,125,null,[[2,79,125]],null]]]\n\n\n\n\n[67,\"m\",[[0,67]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[59,null,[[0,59,null,[[4,23,59]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[4,58,2]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[6,null,[[0,6,null,[[4,91,6]],null]]]\n\n\n\n\n\n[124,\"m\",[[0,124]]]\n[124,null,[[0,124,null,[[13,29,124]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[124,null,[[0,124,null,[[4,33,124]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],null,null]]]\n[0,null,[[0,0,null,[[4,40,0]],null]]]\n\n[0,null,[[0,0,null,[[4,22,0]],null]]]\n\n[124,null,[[0,124,null,[[2,14,124]],null]]]\n[124,null,[[0,124,null,[[2,48,124]],null]]]\n\n\n\n\n[112,\"m\",[[0,112]]]\n[112,null,[[0,112,null,[[17,33,112],[46,65,112]],null]]]\n[112,null,[[0,112,null,[[13,38,112]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,25,\"2/2\"],[27,39,112]],null]]]\n\n[6,null,[[0,6,null,[[13,49,6]],null]]]\n[6,null,[[0,6,null,[[2,24,6]],null]]]\n[6,null,[[0,6,null,[[2,40,6]],null]]]\n[6,null,[[0,6,null,[[2,52,6]],null]]]\n\n\n\n\n\n[90,\"m\",[[0,90]]]\n[90,null,[[0,90,null,[[17,33,90],[46,65,90]],null]]]\n[90,null,[[0,90,null,[[13,42,90]],null]]]\n[90,null,[[0,90,null,[],null]]]\n[6,null,[[0,6,null,[[18,54,6]],null]]]\n[6,null,[[0,6,null,[[4,26,6]],null]]]\n[6,null,[[0,6,null,[[4,49,6]],null]]]\n[6,null,[[0,6,null,[[4,59,6]],null]]]\n\n[90,null,[[0,90,null,[[2,14,90]],null]]]\n\n\n\n\n[20,\"m\",[[0,20]]]\n\n[20,null,[[0,20,null,[],null]]]\n[5,\"b\",[[0,5]]]\n[5,null,[[0,5,null,[[6,48,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[[8,94,1]],null]]]\n\n[4,null,[[0,4,null,[[8,20,4]],null]]]\n\n\n[2,\"b\",[[0,2]]]\n[14,\"b\",[[0,14]]]\n[14,null,[[0,14,null,[[6,34,14]],null]]]\n[14,null,[[0,14,null,[[6,24,14]],null]]]\n[14,null,[[0,14,null,[[6,18,14]],null]]]\n\n[1,\"b\",[[0,1]]]\n[1,null,[[0,1,null,[[6,100,1]],null]]]\n\n\n\n\n\n\n\n[3,\"m\",[[0,3]]]\n[3,null,[[0,3,null,[[13,66,3]],null]]]\n[3,null,[[0,3,null,[[2,82,3]],null]]]\n\n\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[13,29,2]],null]]]\n[2,null,[[0,2,null,[[2,25,2]],null]]]\n[2,null,[[0,2,null,[[2,27,2]],null]]]\n[2,null,[[0,2,null,[[2,43,2]],null]]]\n[2,null,[[0,2,null,[[2,25,2]],null]]]\n\n[2,null,[[0,2,null,[[2,49,2]],null]]]\n\n\n\n\n\n[18,\"m\",[[0,18]]]\n[18,null,[[0,18,null,[[13,29,18]],null]]]\n[18,null,[[0,18,null,[[2,14,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[4,53,3]],null]]]\n\n[15,null,[[0,15,null,[[4,45,15]],null]]]\n\n[18,null,[[0,18,null,[[2,25,18]],null]]]\n[14,null,[[0,14,null,[[2,57,14]],null]]]\n\n\n\n\n[28,\"m\",[[0,28]]]\n[28,null,[[0,28,null,[[13,29,28]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[4,29,6]],null]]]\n[6,null,[[0,6,null,[[4,44,6]],null]]]\n[6,null,[[0,6,null,[[4,27,6]],null]]]\n[6,null,[[0,6,null,[[4,55,6]],null]]]\n\n[22,null,[[0,22,null,[[2,44,22]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[33,62,20],[65,69,1]],null]]]\n[19,null,[[0,19,null,[[2,47,19]],null]]]\n\n\n\n\n[63,\"m\",[[0,63]]]\n[63,null,[[0,63,null,[[13,49,63]],null]]]\n[63,null,[[0,63,null,[[2,23,63]],null]]]\n[63,null,[[0,63,null,[[2,41,63]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,30,88],[34,59,67]],null]]]\n[28,null,[[0,28,null,[[4,51,28]],null]]]\n\n[60,null,[[0,60,null,[[2,40,60]],null]]]\n[60,null,[[0,60,null,[[2,28,60]],null]]]\n[58,null,[[0,58,null,[[2,52,58]],null]]]\n\n\n\n\n[27,\"m\",[[0,27]]]\n[27,null,[[0,27,null,[[13,49,27]],null]]]\n[27,null,[[0,27,null,[[2,41,27]],null]]]\n[27,null,[[0,27,null,[[2,28,27]],null]]]\n[27,null,[[0,27,null,[[2,52,27]],null]]]\n\n\n\n\n\n[63,\"m\",[[0,63]]]\n[63,null,[[0,63,null,[[13,49,63]],null]]]\n[63,null,[[0,63,null,[[17,19,63]],null]]]\n[63,null,[[0,63,null,[[23,72,63]],null]]]\n[58,null,[[0,58,null,[[23,27,58]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[37,null,[[0,37,null,[],null]]]\n[75,null,[[0,75,null,[],null]]]\n[38,\"b\",[[0,38]]]\n[38,null,[[0,38,null,[[10,38,38],[39,70,38]],null]]]\n[38,null,[[0,38,null,[[10,22,38]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[27,null,[[0,27,null,[[12,79,27]],null]]]\n[27,null,[[0,27,null,[[12,27,27]],null]]]\n\n[11,null,[[0,11,null,[[10,68,11]],null]]]\n[5,null,[[0,5,null,[[10,16,5]],null]]]\n\n[22,\"b\",[[0,22]]]\n[22,null,[[0,22,null,[[10,46,22]],null]]]\n[22,null,[[0,22,null,[[10,16,22]],null]]]\n\n[15,\"b\",[[0,15]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[12,54,2]],null]]]\n\n[13,null,[[0,13,null,[[12,62,13]],null]]]\n\n\n[11,null,[[0,11,null,[[10,16,11]],null]]]\n\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[10,28,0]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n[47,null,[[0,47,null,[[2,39,47]],null]]]\n[47,null,[[0,47,null,[[2,39,47]],null]]]\n[47,null,[[0,47,null,[[2,27,47]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,31,47],[35,59,1]],null]]]\n[1,null,[[0,1,null,[[4,94,1]],null]]]\n\n[46,null,[[0,46,null,[[2,45,46]],null]]]\n\n\n\n\n[52,\"m\",[[0,52]]]\n[52,null,[[0,52,null,[[17,33,52],[46,65,52]],null]]]\n[52,null,[[0,52,null,[[2,14,52]],null]]]\n[52,null,[[0,52,null,[[2,52,52]],null]]]\n\n\n[251,\"m\",[[0,251]]]\n[251,\"m\",[[0,251]]]\n[385,\"m\",[[0,385]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[22,null,[[0,22,null,[[19,65,22]],null]]]\n\n[22,null,[[0,22,null,[[8,26,22]],null]]]\n[22,null,[[0,22,null,[[8,20,22]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[52,null,[[0,52,null,[[8,38,52]],null]]]\n\n[311,null,[[0,311,null,[[8,56,311]],null]]]\n\n\n\n\n[251,\"m\",[[0,251]]]\n[3351,\"m\",[[0,3351]]]\n[3351,null,[[0,3351,null,[[20,37,3351]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[81,null,[[0,81,null,[[8,35,81]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,31,3270],[35,56,3012]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[125,null,[[0,125,null,[[10,36,125]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[87,null,[[0,87,null,[[10,27,87]],null]]]\n[87,null,[[0,87,null,[[10,48,87]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,24,108],[28,39,95],[44,65,13]],null]]]\n[13,null,[[0,13,null,[[10,42,13]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,21,3045],[25,47,119]],null]]]\n[53,null,[[0,53,null,[[8,25,53]],null]]]\n[53,null,[[0,53,null,[[8,48,53]],null]]]\n\n\n[2992,null,[[0,2992,null,[[6,36,2992]],null]]]\n\n\n\n[251,\"m\",[[0,251]]]\n[3939,\"m\",[[0,3939]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[212,null,[[0,212,null,[[25,42,212]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[11,null,[[0,11,null,[[10,54,11]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[15,null,[[0,15,null,[[10,52,15]],null]]]\n\n[186,null,[[0,186,null,[[10,37,186]],null]]]\n\n[212,null,[[0,212,null,[[8,38,212]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[17,37,3727],[41,68,50]],null]]]\n[27,null,[[0,27,null,[[8,39,27]],null]]]\n[27,null,[[0,27,null,[[8,43,27]],null]]]\n[27,null,[[0,27,null,[[8,39,27]],null]]]\n\n[3700,null,[[0,3700,null,[[8,42,3700]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n[10,\"m\",[[0,10]]]\n[10,null,[[0,10,null,[[4,23,10]],null]]]\n[10,null,[[0,10,null,[[4,27,10]],null]]]\n[10,null,[[0,10,null,[[4,41,10]],null]]]\n[10,null,[[0,10,null,[[4,29,10]],null]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n[87,\"m\",[[0,87,null,[[51,68,87]],null]]]\n\n\n\n\n\n[2509,\"m\",[[0,2509]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[4,34,4]],null]]]\n[4,null,[[0,4,null,[[4,11,4]],null]]]\n\n\n[2505,null,[[0,2505,null,[[12,36,2505]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,34,2505],[38,84,831]],null]]]\n[83,null,[[0,83,null,[[4,29,83]],null]]]\n[83,null,[[0,83,null,[[4,35,83]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[30,null,[[0,30,null,[[4,34,30]],null]]]\n\n[2392,null,[[0,2392,null,[[4,41,2392]],null]]]\n\n\n\n[3962,\"m\",[[0,3962]]]\n[3962,null,[[0,3962,null,[[2,33,3962]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,26,3962],[30,52,3925],[56,76,3895]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[20,null,[[0,20,null,[[6,36,20]],null]]]\n\n\n\n\n[1353,\"m\",[[0,1353]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[56,76,996],[79,100,357]],null]]]\n[1353,null,[[0,1353,null,[[2,32,1353]],null]]]\n\n\n[17,\"m\",[[0,17]]]\n[17,null,[[0,17,null,[[2,47,17]],null]]]\n[17,null,[[0,17,null,[[2,32,17]],null]]]\n\n\n[1488,\"m\",[[0,1488]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[24,43,1488],[47,67,1461]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[24,45,1387],[49,71,1375]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[44,64,179],[67,88,1309]],null]]]\n[1488,null,[[0,1488,null,[[2,32,1488]],null]]]\n\n\n[67,\"m\",[[0,67]]]\n\n\n\n[441,\"m\",[[0,441]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[98,null,[[0,98,null,[[4,54,98]],null]]]\n\n\n[441,null,[[0,441,null,[[2,33,441]],null]]]\n\n\n[59,\"m\",[[0,59]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[24,null,[[0,24,null,[[4,29,24]],null]]]\n\n[35,null,[[0,35,null,[[4,44,35]],null]]]\n\n[59,null,[[0,59,null,[[2,33,59]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[17531,\"m\",[[0,17531]]]\n[17531,null,[[0,17531,null,[[4,27,17531]],null]]]\n[17531,null,[[0,17531,null,[[4,29,17531]],null]]]\n[17531,null,[[0,17531,null,[[4,29,17531]],null]]]\n[17531,null,[[0,17531,null,[[4,25,17531]],null]]]\n[17531,null,[[0,17531,null,[[4,64,17531]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[31,\"m\",[[0,31]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[21,null,[[0,21,null,[[4,37,21]],null]]]\n\n[10,null,[[0,10,null,[[4,102,10]],null]]]\n\n\n\n\n[2123,\"m\",[[0,2123]]]\n[2123,null,[[0,2123,null,[[4,27,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,36,2123]],null]]]\n\n\n\n\n[17576,\"m\",[[0,17576]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[17531,null,[[0,17531,null,[[6,52,17531]],null]]]\n\n\n[17576,null,[[0,17576,null,[[4,43,17576]],null]]]\n[17576,null,[[0,17576,null,[[4,47,17576]],null]]]\n[17576,null,[[0,17576,null,[[4,49,17576]],null]]]\n[17576,null,[[0,17576,null,[[4,53,17576]],null]]]\n[17576,null,[[0,17576,null,[[4,21,17576]],null]]]\n\n\n\n\n[32202,\"m\",[[0,32202]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[8994,null,[[0,8994,null,[[6,18,8994]],null]]]\n[8976,null,[[0,8976,null,[[6,18,8976]],null]]]\n\n[23208,null,[[0,23208,null,[[6,19,23208]],null]]]\n\n\n\n\n\n[83412,\"m\",[[0,83412]]]\n[83412,null,[[0,83412,null,[[4,36,83412]],null]]]\n\n\n\n\n[5955,\"m\",[[0,5955]]]\n[5955,null,[[0,5955,null,[[4,27,5955]],null]]]\n\n\n\n\n[45,\"m\",[[0,45]]]\n[45,null,[[0,45,null,[[14,24,45]],null]]]\n[45,null,[[0,45,null,[[4,33,45]],null]]]\n\n[45,null,[[0,45,null,[[4,28,45]],null]]]\n[45,null,[[0,45,null,[[4,16,45]],null]]]\n[45,null,[[0,45,null,[[4,29,45]],null]]]\n\n[45,null,[[0,45,null,[[15,37,45]],null]]]\n[45,null,[[0,45,null,[[4,21,45]],null]]]\n[45,null,[[0,45,null,[[4,16,45]],null]]]\n\n\n\n\n\n[235,\"m\",[[0,235]]]\n[235,null,[[0,235,null,[[4,31,235]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,27,235],[31,53,227],[55,62,235]],null]]]\n[12,null,[[0,12,null,[[4,38,12]],null]]]\n[12,null,[[0,12,null,[],null]]]\n[0,null,[[0,0,null,[[6,88,0]],null]]]\n[0,null,[[0,0,null,[[6,27,0]],null]]]\n\n[12,null,[[0,12,null,[[4,21,12]],null]]]\n\n\n[24694,\"m\",[[0,24694]]]\n[24694,null,[[0,24694,null,[[4,61,24694]],null]]]\n\n\n\n\n\n[19714,\"m\",[[0,19714]]]\n[19714,null,[[0,19714,null,[[21,38,19714]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,19714],[23,48,19714],[50,67,19714]],null]]]\n\n[19702,null,[[0,19702,null,[[4,37,19702]],null]]]\n[19702,null,[[0,19702,null,[[4,36,19702]],null]]]\n[19702,null,[[0,19702,null,[[4,38,19702]],null]]]\n[19702,null,[[0,19702,null,[[4,51,19702]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,43,\"2/2\"],[45,77,19702]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[87,null,[[0,87,null,[[6,39,87]],null]]]\n\n[16681,null,[[0,16681,null,[[6,54,16681]],null]]]\n\n\n\n[16195,\"m\",[[0,16195]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,31,16195],[35,46,10264]],null]]]\n[5944,null,[[0,5944,null,[[6,29,5944]],null]]]\n\n[10251,null,[[0,10251,null,[[6,41,10251]],null]]]\n\n\n\n[45377,\"m\",[[0,45377]]]\n[45377,null,[[0,45377,null,[[15,52,45377]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,22,45377],[26,40,212],[42,54,45377]],null]]]\n\n[212,null,[[0,212,null,[[15,56,212]],null]]]\n[212,null,[[0,212,null,[[4,43,212]],null]]]\n\n\n[88,\"m\",[[0,88]]]\n[88,null,[[0,88,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[20,34,47],[37,50,41]],null]]]\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[87,null,[[0,87,null,[[6,38,87]],null]]]\n[87,null,[[0,87,null,[[6,40,87]],null]]]\n[87,null,[[0,87,null,[[6,31,87]],null]]]\n\n\n\n[59,\"m\",[[0,59]]]\n[59,null,[[0,59,null,[[19,43,59]],null]]]\n[59,null,[[0,59,null,[[16,30,59],[38,83,59]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,\"2/2\"],[20,75,59]],null]]]\n\n[47,null,[[0,47,null,[[4,29,47]],null]]]\n[47,null,[[0,47,null,[[4,33,47]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,47,81],[52,80,48]],null]]]\n[34,null,[[0,34,null,[[6,27,34]],null]]]\n[34,null,[[0,34,null,[[6,59,34]],null]]]\n\n\n[47,null,[[0,47,null,[[4,120,47]],null]]]\n\n\n[41,\"m\",[[0,41]]]\n[41,null,[[0,41,null,[[16,30,41]],null]]]\n[41,null,[[0,41,null,[[19,43,41]],null]]]\n[41,null,[[0,41,null,[[13,63,41]],null]]]\n[\"5/5\",\"b\",[[0,\"5/5\",[],[[11,45,509],[49,58,503],[62,71,470],[75,86,468],[90,101,468]],null]]]\n[468,null,[[0,468,null,[[6,23,468]],null]]]\n[468,null,[[0,468,null,[[6,49,468]],null]]]\n\n\n[41,null,[[0,41,null,[[4,140,41]],null]]]\n\n\n\n\n\n[19549,\"m\",[[0,19549]]]\n[19549,null,[[0,19549,null,[],null]]]\n[24559,null,[[0,24559,null,[[15,52,24559]],null]]]\n[24559,null,[[0,24559,null,[],null]]]\n[7518,\"b\",[[0,7518]]]\n[7518,null,[[0,7518,null,[[10,27,7518]],null]]]\n[7518,null,[[0,7518,null,[[10,16,7518]],null]]]\n\n[6,\"b\",[[0,6]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],null,null]]]\n[0,null,[[0,0,null,[[12,29,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,16,341],[17,27,342]],null]]]\n[342,null,[[0,342,null,[[10,27,342]],null]]]\n[342,null,[[0,342,null,[[10,31,342]],null]]]\n[342,null,[[0,342,null,[[10,48,342]],null]]]\n[342,null,[[0,342,null,[[10,16,342]],null]]]\n\n[200,\"b\",[[0,200]]]\n[200,null,[[0,200,null,[],null]]]\n[59,\"b\",[[0,59]]]\n[59,null,[[0,59,null,[[14,38,59]],null]]]\n[47,null,[[0,47,null,[[14,20,47]],null]]]\n\n[36,\"b\",[[0,36]]]\n[36,null,[[0,36,null,[[14,38,36]],null]]]\n[36,null,[[0,36,null,[[14,20,36]],null]]]\n\n[105,\"b\",[[0,105]]]\n[105,null,[[0,105,null,[[14,25,105]],null]]]\n\n[83,null,[[0,83,null,[[10,16,83]],null]]]\n\n[16499,\"b\",[[0,16499]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,20,16499],[24,31,16499],[35,45,16499],[49,97,21]],null]]]\n[2,null,[[0,2,null,[[12,29,2]],null]]]\n\n[16497,null,[[0,16497,null,[[12,23,16497]],null]]]\n\n\n\n\n\n\n\n\n\n\n[19578,\"m\",[[0,19578]]]\n[19578,null,[[0,19578,null,[[4,36,19578]],null]]]\n[19578,null,[[0,19578,null,[[4,49,19578]],null]]]\n[19578,null,[[0,19578,null,[[19,34,19578]],null]]]\n[19578,null,[[0,19578,null,[[4,27,19578]],null]]]\n[19578,null,[[0,19578,null,[[4,27,19578]],null]]]\n\n[19578,null,[[0,19578,null,[[4,33,19578]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[232,\"m\",[[0,232]]]\n[232,null,[[0,232,null,[[15,56,232]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,232],[22,32,109]],null]]]\n[3,null,[[0,3,null,[[6,35,3]],null]]]\n\n\n[229,null,[[0,229,null,[[16,57,229]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,229],[23,35,119]],null]]]\n[116,null,[[0,116,null,[[6,26,116]],null]]]\n[116,null,[[0,116,null,[[6,43,116]],null]]]\n\n[113,null,[[0,113,null,[[6,23,113]],null]]]\n[113,null,[[0,113,null,[[6,38,113]],null]]]\n\n\n\n[105,\"m\",[[0,105]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[37,null,[[0,37,null,[[6,23,37]],null]]]\n[37,null,[[0,37,null,[[6,31,37]],null]]]\n\n\n[68,null,[[0,68,null,[[15,56,68]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,41,2]],null]]]\n\n[66,null,[[0,66,null,[[6,40,66]],null]]]\n\n\n\n[166,\"m\",[[0,166]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[29,36,156],[39,48,10]],null]]]\n\n[166,null,[[0,166,null,[[15,56,166]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[15,null,[[0,15,null,[[6,14,15]],null]]]\n[15,null,[[0,15,null,[[6,55,15]],null]]]\n[15,null,[[0,15,null,[[6,25,15]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[6,14,5]],null]]]\n[5,null,[[0,5,null,[[6,23,5]],null]]]\n\n\n[166,null,[[0,166,null,[[4,38,166]],null]]]\n\n\n[68,\"m\",[[0,68]]]\n[68,null,[[0,68,null,[[15,56,68]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,57,\"2/2\"],[59,71,11],[74,87,11]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,\"2/2\"],[21,56,46]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[40,52,28],[55,68,14]],null]]]\n\n\n[13,\"m\",[[0,13]]]\n[13,null,[[0,13,null,[[15,56,13]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,41,2]],null]]]\n\n[11,null,[[0,11,null,[[6,45,11]],null]]]\n\n\n\n[170,\"m\",[[0,170]]]\n[170,null,[[0,170,null,[[15,56,170]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,21,68],[25,73,32],[77,148,2]],null]]]\n\n[1,null,[[0,1,null,[[8,32,1]],null]]]\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n[1,null,[[0,1,null,[[8,32,1]],null]]]\n\n[67,null,[[0,67,null,[[6,41,67]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[6,41,5]],null]]]\n\n[97,null,[[0,97,null,[[6,42,97]],null]]]\n\n\n\n[118,\"m\",[[0,118]]]\n[118,null,[[0,118,null,[[15,56,118]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[13,24,19],[28,76,9]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,61,\"2/2\"],[63,105,19]],null]]]\n[13,null,[[0,13,null,[[6,46,13]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,99],[23,34,2],[38,86,2],[90,138,2]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[10,23,\"1/2\"],[25,43,0]],null]]]\n\n[2,null,[[0,2,null,[[6,30,2]],null]]]\n[2,null,[[0,2,null,[[6,23,2]],null]]]\n[2,null,[[0,2,null,[[6,30,2]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n\n[7,null,[[0,7,null,[[6,15,7]],null]]]\n\n\n[97,null,[[0,97,null,[[4,46,97]],null]]]\n\n\n[707,\"m\",[[0,707]]]\n[707,null,[[0,707,null,[[15,56,707]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,\"2/2\"],[21,113,707]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,698],[23,34,695]],null]]]\n[175,null,[[0,175,null,[[6,26,175]],null]]]\n[175,null,[[0,175,null,[[6,40,175]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[39,44,520],[47,56,3]],null]]]\n\n\n[10266,\"m\",[[0,10266]]]\n[10266,null,[[0,10266,null,[],null]]]\n\n\n[232,\"b\",[[0,232]]]\n[232,null,[[0,232,null,[[8,36,232]],null]]]\n\n\n[1488,\"b\",[[0,1488,null,[[15,32,1488],[33,68,1488]],null]]]\n[1321,\"b\",[[0,1321,null,[[15,32,1321],[33,68,1321]],null]]]\n[788,\"b\",[[0,788,null,[[15,32,788],[33,66,788]],null]]]\n[392,\"b\",[[0,392,null,[[15,32,392],[33,67,392]],null]]]\n[239,\"b\",[[0,239,null,[[15,32,239],[33,70,239]],null]]]\n[239,\"b\",[[0,239,null,[[15,32,239],[33,70,239]],null]]]\n[1379,\"b\",[[0,1379,null,[[16,33,1379],[34,69,1379]],null]]]\n[1188,\"b\",[[0,1188,null,[[16,33,1188],[34,69,1188]],null]]]\n\n[483,\"b\",[[0,483]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[12,42,483],[46,94,0]],null]]]\n[0,null,[[0,0,null,[[10,50,0]],null]]]\n\n[483,null,[[0,483,null,[[10,27,483]],null]]]\n[483,null,[[0,483,null,[[10,44,483]],null]]]\n\n\n[51,\"b\",[[0,51,null,[[15,32,51],[33,70,51]],null]]]\n[33,\"b\",[[0,33,null,[[15,32,33],[33,64,33]],null]]]\n\n[35,\"b\",[[0,35]]]\n[35,null,[[0,35,null,[[8,25,35]],null]]]\n[35,null,[[0,35,null,[[8,46,35]],null]]]\n\n[189,\"b\",[[0,189]]]\n[189,null,[[0,189,null,[[19,60,189]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,24,189],[28,39,178],[41,73,189]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,24,176],[28,39,156],[41,72,176]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,23,140],[27,38,122],[40,71,140]],null]]]\n\n\n[\"8/8\",\"b\",[[0,\"8/8\",[],[[6,14,301],[15,23,359],[24,32,407],[33,41,564],[42,50,574],[51,59,578],[60,68,580],[69,77,588]],null]]]\n[588,null,[[0,588,null,[[8,38,588]],null]]]\n\n\n[250,\"b\",[[0,250]]]\n[371,null,[[0,371,null,[[8,37,371]],null]]]\n\n\n\n\n\n\n[105,\"b\",[[0,105]]]\n[105,null,[[0,105,null,[[8,38,105]],null]]]\n\n[10,\"b\",[[0,10]]]\n[166,null,[[0,166,null,[[8,48,166]],null]]]\n\n[41,\"b\",[[0,41]]]\n[68,null,[[0,68,null,[[8,45,68]],null]]]\n\n[13,\"b\",[[0,13]]]\n[13,null,[[0,13,null,[[8,38,13]],null]]]\n\n[105,\"b\",[[0,105]]]\n[170,null,[[0,170,null,[[8,45,170]],null]]]\n\n[104,\"b\",[[0,104]]]\n[118,null,[[0,118,null,[[8,42,118]],null]]]\n\n[700,\"b\",[[0,700]]]\n[707,null,[[0,707,null,[[8,44,707]],null]]]\n\n[2,\"b\",[[0,2]]]\n[2,null,[[0,2,null,[[8,43,2]],null]]]\n\n\n[7,null,[[0,7,null,[[4,84,7]],null]]]\n\n\n[1261,\"m\",[[0,1261]]]\n[1261,null,[[0,1261,null,[[14,69,1261]],null]]]\n[1261,null,[[0,1261,null,[[4,27,1261]],null]]]\n[1261,null,[[0,1261,null,[[4,39,1261]],null]]]\n\n\n[37,\"m\",[[0,37]]]\n[37,null,[[0,37,null,[[34,48,37]],null]]]\n[37,null,[[0,37,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,45,\"2/2\"],[47,100,206]],null]]]\n[202,null,[[0,202,null,[[15,48,202]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[5,null,[[0,5,null,[[8,61,5]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[7,null,[[0,7,null,[[8,24,7]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[10,25,10]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[19,29,180],[33,40,10]],null]]]\n[10,null,[[0,10,null,[[10,26,10]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[19,29,170],[33,41,28]],null]]]\n[28,null,[[0,28,null,[[10,16,28]],null]]]\n\n[162,null,[[0,162,null,[[8,30,162]],null]]]\n\n[169,null,[[0,169,null,[[6,23,169]],null]]]\n\n[28,null,[[0,28,null,[[18,57,28]],null]]]\n[28,null,[[0,28,null,[[4,21,28]],null]]]\n\n\n[28,null,[[0,28,null,[[15,31,28]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[23,36,10]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,32,\"2/2\"],[34,87,10]],null]]]\n\n[26,null,[[0,26,null,[],null]]]\n\n\n\n\n\n\n\n\n\n[740,\"m\",[[0,740]]]\n[740,null,[[0,740,null,[[16,30,740]],null]]]\n[740,null,[[0,740,null,[],null]]]\n[1906,null,[[0,1906,null,[[17,54,1906]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[67,null,[[0,67,null,[[8,29,67]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[47,null,[[0,47,null,[[8,29,47]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[17,27,1792],[31,41,1313]],null]]]\n[1187,null,[[0,1187,null,[[8,24,1187]],null]]]\n\n[605,null,[[0,605,null,[[8,23,605]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,22,\"2/2\"],[24,30,1906]],null]]]\n[1203,null,[[0,1203,null,[[6,23,1203]],null]]]\n[1203,null,[[0,1203,null,[[6,34,1203]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,32,740],[36,47,704],[51,81,40],[83,95,740]],null]]]\n\n[699,null,[[0,699,null,[[4,17,699]],null]]]\n\n\n[83,\"m\",[[0,83]]]\n[83,null,[[0,83,null,[[4,24,83]],null]]]\n[83,null,[[0,83,null,[[14,33,83]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,\"2/2\"],[21,91,83]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,51,\"2/2\"],[53,116,65]],null]]]\n[55,null,[[0,55,null,[[4,41,55]],null]]]\n\n\n\n\n[591,\"m\",[[0,591]]]\n[591,null,[[0,591,null,[[16,30,591],[42,47,591],[57,101,591]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[8,22,591],[26,51,588],[53,89,591]],null]]]\n[591,null,[[0,591,null,[[15,52,591]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10,null,[[0,10,null,[[6,23,10]],null]]]\n[10,null,[[0,10,null,[[6,23,10]],null]]]\n[10,null,[[0,10,null,[[6,21,10]],null]]]\n[10,null,[[0,10,null,[[6,51,10]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,591],[23,35,591]],null]]]\n[11,null,[[0,11,null,[[6,53,11]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,21,11],[25,36,7],[38,55,11]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,35,\"2/2\"],[37,73,11]],null]]]\n[3,null,[[0,3,null,[[6,21,3]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,51,\"2/2\"],[53,116,583]],null]]]\n\n[573,null,[[0,573,null,[[14,53,573]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[11,null,[[0,11,null,[[6,28,11]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,21,562],[25,41,103]],null]]]\n[539,null,[[0,539,null,[[6,30,539]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,31,23],[35,52,21]],null]]]\n[9,null,[[0,9,null,[[6,42,9]],null]]]\n\n[14,null,[[0,14,null,[[6,29,14]],null]]]\n\n[564,null,[[0,564,null,[[4,41,564]],null]]]\n\n\n\n\n[40,\"m\",[[0,40]]]\n[40,null,[[0,40,null,[[13,50,40]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[21,null,[[0,21,null,[[20,36,21]],null]]]\n[21,null,[[0,21,null,[[6,88,21]],null]]]\n[18,null,[[0,18,null,[[6,23,18]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,25,\"2/2\"],[27,75,18]],null]]]\n\n[19,null,[[0,19,null,[[6,33,19]],null]]]\n\n[29,null,[[0,29,null,[[4,16,29]],null]]]\n\n\n[371,\"m\",[[0,371]]]\n[371,null,[[0,371,null,[[14,16,371],[31,47,371]],null]]]\n[371,null,[[0,371,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,45,\"2/2\"],[47,108,3274]],null]]]\n[3270,null,[[0,3270,null,[[15,52,3270]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,22,\"2/2\"],[24,30,3270]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[92,null,[[0,92,null,[[8,60,92]],null]]]\n[92,null,[[0,92,null,[[8,43,92]],null]]]\n[73,null,[[0,73,null,[[8,36,73]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,25,\"2/2\"],[27,88,2832]],null]]]\n[2830,null,[[0,2830,null,[[8,25,2830]],null]]]\n\n\n[346,null,[[0,346,null,[[4,58,346]],null]]]\n[346,null,[[0,346,null,[[4,44,346]],null]]]\n\n\n\n\n[87,\"m\",[[0,87]]]\n[87,null,[[0,87,null,[[14,16,87],[31,45,87]],null]]]\n[87,null,[[0,87,null,[],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,45,\"2/2\"],[47,101,227]],null]]]\n[225,null,[[0,225,null,[[15,52,225]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,19,225],[23,32,177],[36,85,36]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,47,82],[51,74,49]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[17,null,[[0,17,null,[[12,32,17]],null]]]\n[17,null,[[0,17,null,[[12,53,17]],null]]]\n\n[24,null,[[0,24,null,[[12,29,24]],null]]]\n[24,null,[[0,24,null,[[12,50,24]],null]]]\n\n\n[41,null,[[0,41,null,[[8,60,41]],null]]]\n[41,null,[[0,41,null,[[8,50,41]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[15,null,[[0,15,null,[[8,60,15]],null]]]\n[15,null,[[0,15,null,[[8,42,15]],null]]]\n[12,null,[[0,12,null,[[8,36,12]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[3,null,[[0,3,null,[[8,60,3]],null]]]\n[3,null,[[0,3,null,[[8,25,3]],null]]]\n[3,null,[[0,3,null,[],null]]]\n[1,\"b\",[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[16,60,\"1/2\"],[62,79,0]],null]]]\n[3,\"b\",[[0,3]]]\n[3,null,[[0,3,null,[[12,24,3]],null]]]\n[3,null,[[0,3,null,[[12,18,3]],null]]]\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[12,43,0]],null]]]\n[0,null,[[0,0,null,[[12,18,0]],null]]]\n\n[3,null,[[0,3,null,[[8,29,3]],null]]]\n[3,null,[[0,3,null,[[8,46,3]],null]]]\n[3,null,[[0,3,null,[[8,36,3]],null]]]\n\n[125,null,[[0,125,null,[[8,25,125]],null]]]\n\n\n\n\n\n\n[107,\"m\",[[0,107]]]\n[107,null,[[0,107,null,[[13,52,107]],null]]]\n[107,null,[[0,107,null,[[4,21,107]],null]]]\n[107,null,[[0,107,null,[],null]]]\n[8,\"b\",[[0,8,null,[[16,28,8]],null]]]\n[4,\"b\",[[0,4,null,[[16,28,4]],null]]]\n[8,\"b\",[[0,8,null,[[16,64,8]],null]]]\n[19,\"b\",[[0,19,null,[[16,63,19]],null]]]\n[2,\"b\",[[0,2,null,[[16,28,2]],null]]]\n[2,\"b\",[[0,2,null,[[15,27,2]],null]]]\n[2,\"b\",[[0,2,null,[[16,32,2]],null]]]\n[2,\"b\",[[0,2,null,[[16,28,2]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[19,63,\"0/2\"],[65,82,0]],null]]]\n[4,\"b\",[[0,4]]]\n[4,null,[[0,4,null,[[8,46,4]],null]]]\n[4,null,[[0,4,null,[[8,29,4]],null]]]\n[4,null,[[0,4,null,[[8,18,4]],null]]]\n[56,\"b\",[[0,56]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,20,56],[24,32,52]],null]]]\n[25,null,[[0,25,null,[[25,85,25]],null]]]\n[25,null,[[0,25,null,[[22,43,25]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[12,45,2]],null]]]\n[2,null,[[0,2,null,[[12,42,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],null,null]]]\n[23,null,[[0,23,null,[[14,46,23]],null]]]\n[23,null,[[0,23,null,[[14,60,23]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[16,33,23],[37,47,16]],null]]]\n[9,null,[[0,9,null,[[14,77,9]],null]]]\n\n\n[16,null,[[0,16,null,[[10,48,16]],null]]]\n[16,null,[[0,16,null,[[10,44,16]],null]]]\n\n[31,null,[[0,31,null,[[8,39,31]],null]]]\n\n\n\n\n\n[48,\"m\",[[0,48]]]\n[48,null,[[0,48,null,[[18,32,48]],null]]]\n[48,null,[[0,48,null,[[12,33,48]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,\"2/2\"],[20,73,48]],null]]]\n[35,null,[[0,35,null,[[4,13,35]],null]]]\n\n\n\n\n\n\n\n\n[5972,\"m\",[[0,5972]]]\n[5972,null,[[0,5972,null,[[4,35,5972]],null]]]\n[5972,null,[[0,5972,null,[[15,17,5972],[27,31,5972],[46,60,5972]],null]]]\n[5972,null,[[0,5972,null,[],null]]]\n[28048,null,[[0,28048,null,[[15,39,28048]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[22348,null,[[0,22348,null,[[8,47,22348]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[27,null,[[0,27,null,[[8,38,27]],null]]]\n\n[27,null,[[0,27,null,[[8,61,27]],null]]]\n[27,null,[[0,27,null,[[23,37,27]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[6,null,[[0,6,null,[[10,82,6]],null]]]\n\n\n[21,null,[[0,21,null,[[8,25,21]],null]]]\n[21,null,[[0,21,null,[[18,38,21]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[22,39,10],[42,58,10]],null]]]\n[5,null,[[0,5,null,[[10,57,5]],null]]]\n\n\n[15,null,[[0,15,null,[[8,39,15]],null]]]\n[15,null,[[0,15,null,[[8,36,15]],null]]]\n\n[5673,null,[[0,5673,null,[[8,14,5673]],null]]]\n\n[22363,null,[[0,22363,null,[[6,20,22363]],null]]]\n\n[5960,null,[[0,5960,null,[[4,63,5960]],null]]]\n\n\n\n\n\n[5944,\"m\",[[0,5944]]]\n[5944,null,[[0,5944,null,[[15,31,5944]],null]]]\n[5933,null,[[0,5933,null,[[15,22,5933]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,31,5933],[35,55,5922]],null]]]\n[1971,null,[[0,1971,null,[[6,32,1971]],null]]]\n\n[5933,null,[[0,5933,null,[[4,40,5933]],null]]]\n\n\n[1353,\"m\",[[0,1353]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[34,null,[[0,34,null,[[19,36,34]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[10,38,34],[42,71,11]],null]]]\n[32,null,[[0,32,null,[[8,30,32]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[2,null,[[0,2,null,[[6,87,2]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,29,1319],[33,53,1319],[57,76,1319],[80,102,1280]],null]]]\n[689,null,[[0,689,null,[[6,18,689]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[4,null,[[0,4,null,[[6,53,4]],null]]]\n\n\n[626,null,[[0,626,null,[[4,35,626]],null]]]\n\n\n[19525,\"m\",[[0,19525]]]\n[19525,null,[[0,19525,null,[[23,38,19525]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,20,19525],[24,43,1971]],null]]]\n[14,null,[[0,14,null,[[6,37,14]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],null,null]]]\n[10074,null,[[0,10074,null,[[6,34,10074]],null]]]\n\n[9437,null,[[0,9437,null,[[6,47,9437]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n[2123,\"m\",[[0,2123]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[[49,54,0],[57,88,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,23,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,31,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,78,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,21,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,25,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,21,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,23,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,31,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,31,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,31,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,34,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,21,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,23,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,22,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,37,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,53,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,53,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,51,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,39,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,28,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,50,2123]],null]]]\n[2123,null,[[0,2123,null,[[4,30,2123]],null]]]\n\n[2123,null,[[0,2123,null,[[4,16,2123]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[41591,\"m\",[[0,41591]]]\n[41591,null,[[0,41591,null,[[4,65,41591]],null]]]\n\n\n[176,\"m\",[[0,176]]]\n[176,null,[[0,176,null,[[16,25,176]],null]]]\n[176,null,[[0,176,null,[],null]]]\n[5777,null,[[0,5777,null,[[16,25,5777]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[11,22,5777],[26,43,2978],[48,66,2889]],null]]]\n[778,null,[[0,778,null,[[8,26,778]],null]]]\n\n\n[5777,null,[[0,5777,null,[[6,23,5777]],null]]]\n\n[176,null,[[0,176,null,[[4,17,176]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[81,\"b\",[[0,81]]]\n[81,null,[[0,81,null,[[4,23,81]],null]]]\n[81,null,[[0,81,null,[[4,32,81]],null]]]\n[81,null,[[0,81,null,[[4,40,81]],null]]]\n[81,null,[[0,81,null,[[4,40,81]],null]]]\n[81,null,[[0,81,null,[[4,52,81]],null]]]\n[81,null,[[0,81,null,[[4,32,81]],null]]]\n[81,null,[[0,81,null,[[4,36,81]],null]]]\n[81,null,[[0,81,null,[[4,32,81]],null]]]\n[81,null,[[0,81,null,[[4,34,81]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[17,27,81],[31,35,66]],null]]]\n[81,null,[[0,81,null,[[4,30,81]],null]]]\n\n\n\n[11,\"m\",[[0,11]]]\n[11,null,[[0,11,null,[[2,62,11]],null]]]\n\n[1,null,[[0,1,null,[[19,37,1],[52,70,1]],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[24,26,1]],null]]]\n\n\n[37,\"b\",[[0,37]]]\n[37,null,[[0,37,null,[[2,25,37]],null]]]\n[37,null,[[0,37,null,[[2,68,37]],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[[2,27,4]],null]]]\n[7769,\"m\",[[0,7769]]]\n[7769,null,[[0,7769,null,[[4,35,7769]],null]]]\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[25,263,1]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1,null,[[35,4312,1]],null]]]\n[1,null,[[0,1,null,[[30,2605,1]],null]]]\n\n[1,null,[[0,1,null,[[32,84,1]],null]]]\n[1,null,[[0,1,null,[[27,105,1]],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n[1,null,[[0,1,null,[[35,1052,1]],null]]]\n[1,null,[[0,1,null,[[30,485,1]],null]]]\n\n\n\n\n[221,\"m\",[[0,221]]]\n[221,null,[[0,221,null,[[12,19,221]],null]]]\n[221,null,[[0,221,null,[],null]]]\n[40931,null,[[0,40931,null,[[4,18,40931]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,18,\"2/2\"],[20,33,40931]],null]]]\n\n[40924,null,[[0,40924,null,[[4,22,40924]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,19,\"2/2\"],[21,33,40924]],null]]]\n\n\n\n\n\n[17173,\"m\",[[0,17173]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,36,17173]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,29,9474]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,36,9031]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,16,\"2/2\"],[18,30,8430]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,\"2/2\"],[29,41,2642],[45,100,14]],null]]]\n[208,null,[[0,208,null,[[2,57,208]],null]]]\n\n\n\n\n[28340,\"m\",[[0,28340]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,36,28340]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,29,23622]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,30,23593]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,29,22750]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,15,\"2/2\"],[17,36,22199]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,16,\"2/2\"],[18,30,21907]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[6,27,\"2/2\"],[29,41,153],[45,95,33]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[9,56,10],[60,102,3]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n[42329,\"m\",[[0,42329]]]\n[42329,null,[[0,42329,null,[[4,21,42329]],null]]]\n[42329,null,[[0,42329,null,[[4,22,42329]],null]]]\n\n\n\n\n[38978,\"m\",[[0,38978]]]\n[38978,null,[[0,38978,null,[[4,23,38978]],null]]]\n[38978,null,[[0,38978,null,[[4,19,38978]],null]]]\n\n\n\n\n\n\n\n\n\n[738,\"m\",[[0,738]]]\n[738,null,[[0,738,null,[],null]]]\n[816,null,[[0,816,null,[[4,31,816]],null]]]\n[816,null,[[0,816,null,[[16,38,816]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[8,13,816],[17,37,109]],null]]]\n[78,null,[[0,78,null,[[6,13,78]],null]]]\n[78,null,[[0,78,null,[[6,42,78]],null]]]\n\n[738,null,[[0,738,null,[[6,46,738]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n[1,null,[[0,1,null,[[25,49,1]],null]]]\n[1,null,[[0,1,null,[[26,59,1]],null]]]\n\n[3360,\"m\",[[0,3360]]]\n[\"4/4\",\"b\",[[0,\"4/4\",[],[[9,20,3360],[24,35,3345],[39,54,3344],[58,73,3344]],null]]]\n\n\n[1,null,[[0,1,null,[[34,87,1]],null]]]"
+ ],
+ "totals": {
+ "f": 19,
+ "n": 3127,
+ "h": 2945,
+ "m": 101,
+ "p": 81,
+ "c": "94.17972",
+ "b": 797,
+ "d": 320,
+ "M": 0,
+ "s": 0,
+ "C": 0,
+ "N": 0,
+ "diff": null
+ },
+ "report": {
+ "files": {
+ "/home/travis/build/babel/babylon/src/index.js": [
+ 0,
+ [0, 4, 4, 0, 0, "100", 0, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/options.js": [
+ 1,
+ [0, 6, 6, 0, 0, "100", 1, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/comments.js": [
+ 2,
+ [0, 55, 55, 0, 0, "100", 20, 3, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/expression.js": [
+ 3,
+ [0, 596, 536, 40, 20, "89.93289", 159, 37, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/index.js": [
+ 4,
+ [0, 32, 31, 0, 1, "96.87500", 5, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/location.js": [
+ 5,
+ [0, 8, 8, 0, 0, "100", 0, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/lval.js": [
+ 6,
+ [0, 154, 147, 2, 5, "95.45455", 53, 12, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/node.js": [
+ 7,
+ [0, 28, 28, 0, 0, "100", 2, 7, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/statement.js": [
+ 8,
+ [0, 598, 563, 19, 16, "94.14716", 166, 49, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/parser/util.js": [
+ 9,
+ [0, 29, 26, 1, 2, "89.65517", 15, 10, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/plugins/flow.js": [
+ 10,
+ [0, 686, 642, 19, 25, "93.58601", 140, 102, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/plugins/jsx/index.js": [
+ 11,
+ [0, 274, 257, 12, 5, "93.79562", 59, 27, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/tokenizer/context.js": [
+ 12,
+ [0, 44, 44, 0, 0, "100", 11, 10, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/tokenizer/index.js": [
+ 13,
+ [0, 466, 452, 8, 6, "96.99571", 144, 41, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/tokenizer/state.js": [
+ 14,
+ [0, 35, 34, 0, 1, "97.14286", 2, 3, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/tokenizer/types.js": [
+ 15,
+ [0, 57, 57, 0, 0, "100", 3, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/util/identifier.js": [
+ 16,
+ [0, 36, 36, 0, 0, "100", 15, 5, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/util/location.js": [
+ 17,
+ [0, 14, 14, 0, 0, "100", 1, 3, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/home/travis/build/babel/babylon/src/util/whitespace.js": [
+ 18,
+ [0, 5, 5, 0, 0, "100", 1, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ]
+ },
+ "sessions": {}
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node1.json b/apps/worker/services/report/languages/tests/unit/node/node1.json
new file mode 100644
index 0000000000..0d0e858655
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node1.json
@@ -0,0 +1,21 @@
+{"/home/travis/build/babel/babylon/src/index.js": {"path":"/home/travis/build/babel/babylon/src/index.js","statementMap":{"1":{"start":{"line":16,"column":0},"end":{"line":16,"column":26}},"2":{"start":{"line":17,"column":0},"end":{"line":17,"column":24}},"3":{"start":{"line":20,"column":2},"end":{"line":20,"column":44}}},"fnMap":{"1":{"name":"parse","decl":{"start":{"line":19,"column":16},"end":{"line":19,"column":21}},"loc":{"start":{"line":19,"column":38},"end":{"line":21,"column":1}}}},"branchMap":{},"s":{"1":1,"2":1,"3":2123},"f":{"1":2123},"b":{},"hash":"5e4c020863adf1e27d36d1981c8a0819a8671d5a"}
+,"/home/travis/build/babel/babylon/src/options.js": {"path":"/home/travis/build/babel/babylon/src/options.js","statementMap":{"1":{"start":{"line":12,"column":4},"end":{"line":29,"column":1}},"2":{"start":{"line":34,"column":16},"end":{"line":34,"column":18}},"3":{"start":{"line":35,"column":2},"end":{"line":37,"column":3}},"4":{"start":{"line":36,"column":4},"end":{"line":36,"column":73}},"5":{"start":{"line":38,"column":2},"end":{"line":38,"column":17}}},"fnMap":{"1":{"name":"getOptions","decl":{"start":{"line":33,"column":16},"end":{"line":33,"column":26}},"loc":{"start":{"line":33,"column":50},"end":{"line":39,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":36,"column":19},"end":{"line":36,"column":72}},"type":"cond-expr","locations":[{"start":{"line":36,"column":41},"end":{"line":36,"column":50}},{"start":{"line":36,"column":53},"end":{"line":36,"column":72}}]},"2":{"loc":{"start":{"line":36,"column":19},"end":{"line":36,"column":38}},"type":"binary-expr","locations":[{"start":{"line":36,"column":19},"end":{"line":36,"column":23}},{"start":{"line":36,"column":27},"end":{"line":36,"column":38}}]}},"s":{"1":1,"2":2123,"3":2123,"4":14861,"5":2123},"f":{"1":2123},"b":{"1":[631,14230],"2":[14861,14861]},"hash":"f2aa6292315d39d5f086c3e6689ad577b261d6b8"}
+,"/home/travis/build/babel/babylon/src/parser/comments.js": {"path":"/home/travis/build/babel/babylon/src/parser/comments.js","statementMap":{"1":{"start":{"line":30,"column":2},"end":{"line":30,"column":33}},"2":{"start":{"line":33,"column":11},"end":{"line":33,"column":27}},"3":{"start":{"line":35,"column":0},"end":{"line":39,"column":2}},"4":{"start":{"line":36,"column":2},"end":{"line":36,"column":58}},"5":{"start":{"line":36,"column":21},"end":{"line":36,"column":58}},"6":{"start":{"line":37,"column":2},"end":{"line":37,"column":44}},"7":{"start":{"line":38,"column":2},"end":{"line":38,"column":43}},"8":{"start":{"line":41,"column":0},"end":{"line":156,"column":2}},"9":{"start":{"line":42,"column":2},"end":{"line":42,"column":62}},"10":{"start":{"line":42,"column":55},"end":{"line":42,"column":62}},"11":{"start":{"line":44,"column":14},"end":{"line":44,"column":37}},"12":{"start":{"line":48,"column":2},"end":{"line":71,"column":3}},"13":{"start":{"line":53,"column":4},"end":{"line":64,"column":5}},"14":{"start":{"line":54,"column":6},"end":{"line":54,"column":53}},"15":{"start":{"line":55,"column":6},"end":{"line":55,"column":39}},"16":{"start":{"line":63,"column":6},"end":{"line":63,"column":45}},"17":{"start":{"line":66,"column":22},"end":{"line":66,"column":33}},"18":{"start":{"line":67,"column":4},"end":{"line":70,"column":5}},"19":{"start":{"line":68,"column":6},"end":{"line":68,"column":54}},"20":{"start":{"line":69,"column":6},"end":{"line":69,"column":42}},"21":{"start":{"line":74,"column":2},"end":{"line":76,"column":3}},"22":{"start":{"line":75,"column":4},"end":{"line":75,"column":28}},"23":{"start":{"line":78,"column":2},"end":{"line":143,"column":3}},"24":{"start":{"line":79,"column":4},"end":{"line":94,"column":5}},"25":{"start":{"line":80,"column":6},"end":{"line":93,"column":7}},"26":{"start":{"line":81,"column":8},"end":{"line":81,"column":57}},"27":{"start":{"line":82,"column":8},"end":{"line":82,"column":41}},"28":{"start":{"line":87,"column":8},"end":{"line":92,"column":9}},"29":{"start":{"line":88,"column":10},"end":{"line":91,"column":11}},"30":{"start":{"line":89,"column":12},"end":{"line":89,"column":78}},"31":{"start":{"line":90,"column":12},"end":{"line":90,"column":18}},"32":{"start":{"line":95,"column":9},"end":{"line":143,"column":3}},"33":{"start":{"line":96,"column":4},"end":{"line":142,"column":5}},"34":{"start":{"line":97,"column":6},"end":{"line":104,"column":7}},"35":{"start":{"line":98,"column":8},"end":{"line":103,"column":9}},"36":{"start":{"line":99,"column":10},"end":{"line":102,"column":11}},"37":{"start":{"line":100,"column":12},"end":{"line":100,"column":52}},"38":{"start":{"line":101,"column":12},"end":{"line":101,"column":16}},"39":{"start":{"line":105,"column":6},"end":{"line":108,"column":7}},"40":{"start":{"line":106,"column":8},"end":{"line":106,"column":58}},"41":{"start":{"line":107,"column":8},"end":{"line":107,"column":40}},"42":{"start":{"line":121,"column":6},"end":{"line":125,"column":7}},"43":{"start":{"line":122,"column":8},"end":{"line":124,"column":9}},"44":{"start":{"line":123,"column":10},"end":{"line":123,"column":16}},"45":{"start":{"line":131,"column":6},"end":{"line":131,"column":68}},"46":{"start":{"line":132,"column":6},"end":{"line":134,"column":7}},"47":{"start":{"line":133,"column":8},"end":{"line":133,"column":36}},"48":{"start":{"line":138,"column":6},"end":{"line":138,"column":61}},"49":{"start":{"line":139,"column":6},"end":{"line":141,"column":7}},"50":{"start":{"line":140,"column":8},"end":{"line":140,"column":32}},"51":{"start":{"line":145,"column":2},"end":{"line":145,"column":40}},"52":{"start":{"line":147,"column":2},"end":{"line":153,"column":3}},"53":{"start":{"line":148,"column":4},"end":{"line":152,"column":5}},"54":{"start":{"line":149,"column":6},"end":{"line":149,"column":44}},"55":{"start":{"line":151,"column":6},"end":{"line":151,"column":47}},"56":{"start":{"line":155,"column":2},"end":{"line":155,"column":19}}},"fnMap":{"1":{"name":"last","decl":{"start":{"line":29,"column":9},"end":{"line":29,"column":13}},"loc":{"start":{"line":29,"column":21},"end":{"line":31,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":35,"column":16},"end":{"line":35,"column":17}},"loc":{"start":{"line":35,"column":35},"end":{"line":39,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":41,"column":20},"end":{"line":41,"column":21}},"loc":{"start":{"line":41,"column":36},"end":{"line":156,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":36,"column":2},"end":{"line":36,"column":58}},"type":"if","locations":[{"start":{"line":36,"column":2},"end":{"line":36,"column":58}},{"start":{"line":36,"column":2},"end":{"line":36,"column":58}}]},"2":{"loc":{"start":{"line":42,"column":2},"end":{"line":42,"column":62}},"type":"if","locations":[{"start":{"line":42,"column":2},"end":{"line":42,"column":62}},{"start":{"line":42,"column":2},"end":{"line":42,"column":62}}]},"3":{"loc":{"start":{"line":42,"column":6},"end":{"line":42,"column":53}},"type":"binary-expr","locations":[{"start":{"line":42,"column":6},"end":{"line":42,"column":29}},{"start":{"line":42,"column":33},"end":{"line":42,"column":53}}]},"4":{"loc":{"start":{"line":48,"column":2},"end":{"line":71,"column":3}},"type":"if","locations":[{"start":{"line":48,"column":2},"end":{"line":71,"column":3}},{"start":{"line":48,"column":2},"end":{"line":71,"column":3}}]},"5":{"loc":{"start":{"line":53,"column":4},"end":{"line":64,"column":5}},"type":"if","locations":[{"start":{"line":53,"column":4},"end":{"line":64,"column":5}},{"start":{"line":53,"column":4},"end":{"line":64,"column":5}}]},"6":{"loc":{"start":{"line":67,"column":4},"end":{"line":70,"column":5}},"type":"if","locations":[{"start":{"line":67,"column":4},"end":{"line":70,"column":5}},{"start":{"line":67,"column":4},"end":{"line":70,"column":5}}]},"7":{"loc":{"start":{"line":67,"column":8},"end":{"line":67,"column":109}},"type":"binary-expr","locations":[{"start":{"line":67,"column":8},"end":{"line":67,"column":24}},{"start":{"line":67,"column":28},"end":{"line":67,"column":56}},{"start":{"line":67,"column":60},"end":{"line":67,"column":109}}]},"8":{"loc":{"start":{"line":74,"column":9},"end":{"line":74,"column":60}},"type":"binary-expr","locations":[{"start":{"line":74,"column":9},"end":{"line":74,"column":25}},{"start":{"line":74,"column":29},"end":{"line":74,"column":60}}]},"9":{"loc":{"start":{"line":78,"column":2},"end":{"line":143,"column":3}},"type":"if","locations":[{"start":{"line":78,"column":2},"end":{"line":143,"column":3}},{"start":{"line":78,"column":2},"end":{"line":143,"column":3}}]},"10":{"loc":{"start":{"line":79,"column":4},"end":{"line":94,"column":5}},"type":"if","locations":[{"start":{"line":79,"column":4},"end":{"line":94,"column":5}},{"start":{"line":79,"column":4},"end":{"line":94,"column":5}}]},"11":{"loc":{"start":{"line":80,"column":6},"end":{"line":93,"column":7}},"type":"if","locations":[{"start":{"line":80,"column":6},"end":{"line":93,"column":7}},{"start":{"line":80,"column":6},"end":{"line":93,"column":7}}]},"12":{"loc":{"start":{"line":80,"column":10},"end":{"line":80,"column":81}},"type":"binary-expr","locations":[{"start":{"line":80,"column":10},"end":{"line":80,"column":28}},{"start":{"line":80,"column":32},"end":{"line":80,"column":81}}]},"13":{"loc":{"start":{"line":88,"column":10},"end":{"line":91,"column":11}},"type":"if","locations":[{"start":{"line":88,"column":10},"end":{"line":91,"column":11}},{"start":{"line":88,"column":10},"end":{"line":91,"column":11}}]},"14":{"loc":{"start":{"line":95,"column":9},"end":{"line":143,"column":3}},"type":"if","locations":[{"start":{"line":95,"column":9},"end":{"line":143,"column":3}},{"start":{"line":95,"column":9},"end":{"line":143,"column":3}}]},"15":{"loc":{"start":{"line":96,"column":4},"end":{"line":142,"column":5}},"type":"if","locations":[{"start":{"line":96,"column":4},"end":{"line":142,"column":5}},{"start":{"line":96,"column":4},"end":{"line":142,"column":5}}]},"16":{"loc":{"start":{"line":97,"column":6},"end":{"line":104,"column":7}},"type":"if","locations":[{"start":{"line":97,"column":6},"end":{"line":104,"column":7}},{"start":{"line":97,"column":6},"end":{"line":104,"column":7}}]},"17":{"loc":{"start":{"line":99,"column":10},"end":{"line":102,"column":11}},"type":"if","locations":[{"start":{"line":99,"column":10},"end":{"line":102,"column":11}},{"start":{"line":99,"column":10},"end":{"line":102,"column":11}}]},"18":{"loc":{"start":{"line":105,"column":6},"end":{"line":108,"column":7}},"type":"if","locations":[{"start":{"line":105,"column":6},"end":{"line":108,"column":7}},{"start":{"line":105,"column":6},"end":{"line":108,"column":7}}]},"19":{"loc":{"start":{"line":122,"column":8},"end":{"line":124,"column":9}},"type":"if","locations":[{"start":{"line":122,"column":8},"end":{"line":124,"column":9}},{"start":{"line":122,"column":8},"end":{"line":124,"column":9}}]},"20":{"loc":{"start":{"line":132,"column":6},"end":{"line":134,"column":7}},"type":"if","locations":[{"start":{"line":132,"column":6},"end":{"line":134,"column":7}},{"start":{"line":132,"column":6},"end":{"line":134,"column":7}}]},"21":{"loc":{"start":{"line":139,"column":6},"end":{"line":141,"column":7}},"type":"if","locations":[{"start":{"line":139,"column":6},"end":{"line":141,"column":7}},{"start":{"line":139,"column":6},"end":{"line":141,"column":7}}]},"22":{"loc":{"start":{"line":147,"column":2},"end":{"line":153,"column":3}},"type":"if","locations":[{"start":{"line":147,"column":2},"end":{"line":153,"column":3}},{"start":{"line":147,"column":2},"end":{"line":153,"column":3}}]},"23":{"loc":{"start":{"line":148,"column":4},"end":{"line":152,"column":5}},"type":"if","locations":[{"start":{"line":148,"column":4},"end":{"line":152,"column":5}},{"start":{"line":148,"column":4},"end":{"line":152,"column":5}}]},"24":{"loc":{"start":{"line":148,"column":8},"end":{"line":148,"column":116}},"type":"binary-expr","locations":[{"start":{"line":148,"column":8},"end":{"line":148,"column":31}},{"start":{"line":148,"column":35},"end":{"line":148,"column":74}},{"start":{"line":148,"column":78},"end":{"line":148,"column":116}}]}},"s":{"1":32050,"2":1,"3":1,"4":87,"5":1,"6":87,"7":87,"8":1,"9":15001,"10":1374,"11":13627,"12":13627,"13":66,"14":32,"15":32,"16":34,"17":13561,"18":13561,"19":13,"20":13,"21":13627,"22":11082,"23":13627,"24":7786,"25":79,"26":53,"27":53,"28":26,"29":3,"30":2,"31":2,"32":5841,"33":82,"34":49,"35":29,"36":29,"37":2,"38":2,"39":49,"40":47,"41":47,"42":33,"43":35,"44":32,"45":33,"46":33,"47":30,"48":33,"49":33,"50":1,"51":13627,"52":13627,"53":52,"54":5,"55":47,"56":13627},"f":{"1":32050,"2":87,"3":15001},"b":{"1":[1,86],"2":[1374,13627],"3":[15001,1404],"4":[66,13561],"5":[32,34],"6":[13,13548],"7":[13561,11677,47],"8":[24709,18276],"9":[7786,5841],"10":[79,7707],"11":[53,26],"12":[79,79],"13":[2,1],"14":[82,5759],"15":[49,33],"16":[29,20],"17":[2,27],"18":[47,2],"19":[32,3],"20":[30,3],"21":[1,32],"22":[52,13575],"23":[5,47],"24":[52,52,52]},"hash":"763093c58e4c87d7d223c77c1209d2438b7e8e96"}
+,"/home/travis/build/babel/babylon/src/parser/expression.js": {"path":"/home/travis/build/babel/babylon/src/parser/expression.js","statementMap":{"1":{"start":{"line":26,"column":11},"end":{"line":26,"column":27}},"2":{"start":{"line":33,"column":0},"end":{"line":56,"column":2}},"3":{"start":{"line":34,"column":2},"end":{"line":34,"column":28}},"4":{"start":{"line":34,"column":21},"end":{"line":34,"column":28}},"5":{"start":{"line":36,"column":12},"end":{"line":36,"column":20}},"6":{"start":{"line":38,"column":2},"end":{"line":50,"column":3}},"7":{"start":{"line":40,"column":6},"end":{"line":40,"column":22}},"8":{"start":{"line":41,"column":6},"end":{"line":41,"column":12}},"9":{"start":{"line":45,"column":6},"end":{"line":45,"column":31}},"10":{"start":{"line":46,"column":6},"end":{"line":46,"column":12}},"11":{"start":{"line":49,"column":6},"end":{"line":49,"column":13}},"12":{"start":{"line":52,"column":2},"end":{"line":55,"column":3}},"13":{"start":{"line":53,"column":4},"end":{"line":53,"column":84}},"14":{"start":{"line":53,"column":24},"end":{"line":53,"column":84}},"15":{"start":{"line":54,"column":4},"end":{"line":54,"column":26}},"16":{"start":{"line":73,"column":0},"end":{"line":86,"column":2}},"17":{"start":{"line":74,"column":17},"end":{"line":74,"column":33}},"18":{"start":{"line":74,"column":46},"end":{"line":74,"column":65}},"19":{"start":{"line":75,"column":13},"end":{"line":75,"column":64}},"20":{"start":{"line":76,"column":2},"end":{"line":84,"column":3}},"21":{"start":{"line":77,"column":15},"end":{"line":77,"column":51}},"22":{"start":{"line":78,"column":4},"end":{"line":78,"column":30}},"23":{"start":{"line":79,"column":4},"end":{"line":81,"column":5}},"24":{"start":{"line":80,"column":6},"end":{"line":80,"column":81}},"25":{"start":{"line":82,"column":4},"end":{"line":82,"column":44}},"26":{"start":{"line":83,"column":4},"end":{"line":83,"column":55}},"27":{"start":{"line":85,"column":2},"end":{"line":85,"column":14}},"28":{"start":{"line":91,"column":0},"end":{"line":141,"column":2}},"29":{"start":{"line":92,"column":2},"end":{"line":94,"column":3}},"30":{"start":{"line":93,"column":4},"end":{"line":93,"column":29}},"31":{"start":{"line":97,"column":2},"end":{"line":102,"column":3}},"32":{"start":{"line":98,"column":4},"end":{"line":98,"column":34}},"33":{"start":{"line":100,"column":4},"end":{"line":100,"column":42}},"34":{"start":{"line":101,"column":4},"end":{"line":101,"column":33}},"35":{"start":{"line":104,"column":17},"end":{"line":104,"column":33}},"36":{"start":{"line":105,"column":17},"end":{"line":105,"column":36}},"37":{"start":{"line":107,"column":2},"end":{"line":109,"column":3}},"38":{"start":{"line":108,"column":4},"end":{"line":108,"column":51}},"39":{"start":{"line":111,"column":13},"end":{"line":111,"column":87}},"40":{"start":{"line":112,"column":2},"end":{"line":112,"column":81}},"41":{"start":{"line":112,"column":22},"end":{"line":112,"column":81}},"42":{"start":{"line":113,"column":2},"end":{"line":138,"column":3}},"43":{"start":{"line":114,"column":15},"end":{"line":114,"column":51}},"44":{"start":{"line":115,"column":4},"end":{"line":115,"column":37}},"45":{"start":{"line":116,"column":4},"end":{"line":116,"column":67}},"46":{"start":{"line":117,"column":4},"end":{"line":117,"column":37}},"47":{"start":{"line":119,"column":4},"end":{"line":119,"column":25}},"48":{"start":{"line":121,"column":4},"end":{"line":131,"column":5}},"49":{"start":{"line":123,"column":6},"end":{"line":127,"column":7}},"50":{"start":{"line":124,"column":8},"end":{"line":124,"column":49}},"51":{"start":{"line":125,"column":13},"end":{"line":127,"column":7}},"52":{"start":{"line":126,"column":8},"end":{"line":126,"column":49}},"53":{"start":{"line":128,"column":6},"end":{"line":130,"column":7}},"54":{"start":{"line":129,"column":8},"end":{"line":129,"column":116}},"55":{"start":{"line":133,"column":4},"end":{"line":133,"column":16}},"56":{"start":{"line":134,"column":4},"end":{"line":134,"column":45}},"57":{"start":{"line":135,"column":4},"end":{"line":135,"column":57}},"58":{"start":{"line":136,"column":9},"end":{"line":138,"column":3}},"59":{"start":{"line":137,"column":4},"end":{"line":137,"column":50}},"60":{"start":{"line":140,"column":2},"end":{"line":140,"column":14}},"61":{"start":{"line":145,"column":0},"end":{"line":151,"column":2}},"62":{"start":{"line":146,"column":17},"end":{"line":146,"column":33}},"63":{"start":{"line":146,"column":46},"end":{"line":146,"column":65}},"64":{"start":{"line":147,"column":13},"end":{"line":147,"column":60}},"65":{"start":{"line":148,"column":2},"end":{"line":148,"column":74}},"66":{"start":{"line":148,"column":62},"end":{"line":148,"column":74}},"67":{"start":{"line":150,"column":2},"end":{"line":150,"column":81}},"68":{"start":{"line":153,"column":0},"end":{"line":163,"column":2}},"69":{"start":{"line":154,"column":2},"end":{"line":161,"column":3}},"70":{"start":{"line":155,"column":15},"end":{"line":155,"column":51}},"71":{"start":{"line":156,"column":4},"end":{"line":156,"column":21}},"72":{"start":{"line":157,"column":4},"end":{"line":157,"column":46}},"73":{"start":{"line":158,"column":4},"end":{"line":158,"column":26}},"74":{"start":{"line":159,"column":4},"end":{"line":159,"column":49}},"75":{"start":{"line":160,"column":4},"end":{"line":160,"column":58}},"76":{"start":{"line":162,"column":2},"end":{"line":162,"column":14}},"77":{"start":{"line":167,"column":0},"end":{"line":175,"column":2}},"78":{"start":{"line":168,"column":17},"end":{"line":168,"column":33}},"79":{"start":{"line":168,"column":46},"end":{"line":168,"column":65}},"80":{"start":{"line":169,"column":13},"end":{"line":169,"column":57}},"81":{"start":{"line":170,"column":2},"end":{"line":174,"column":3}},"82":{"start":{"line":171,"column":4},"end":{"line":171,"column":16}},"83":{"start":{"line":173,"column":4},"end":{"line":173,"column":64}},"84":{"start":{"line":183,"column":0},"end":{"line":213,"column":2}},"85":{"start":{"line":184,"column":13},"end":{"line":184,"column":34}},"86":{"start":{"line":185,"column":2},"end":{"line":211,"column":3}},"87":{"start":{"line":186,"column":4},"end":{"line":210,"column":5}},"88":{"start":{"line":187,"column":17},"end":{"line":187,"column":61}},"89":{"start":{"line":188,"column":6},"end":{"line":188,"column":23}},"90":{"start":{"line":189,"column":6},"end":{"line":189,"column":39}},"91":{"start":{"line":191,"column":6},"end":{"line":199,"column":7}},"92":{"start":{"line":198,"column":8},"end":{"line":198,"column":124}},"93":{"start":{"line":201,"column":15},"end":{"line":201,"column":30}},"94":{"start":{"line":202,"column":6},"end":{"line":202,"column":18}},"95":{"start":{"line":204,"column":21},"end":{"line":204,"column":37}},"96":{"start":{"line":205,"column":21},"end":{"line":205,"column":40}},"97":{"start":{"line":206,"column":6},"end":{"line":206,"column":125}},"98":{"start":{"line":208,"column":6},"end":{"line":208,"column":118}},"99":{"start":{"line":209,"column":6},"end":{"line":209,"column":79}},"100":{"start":{"line":212,"column":2},"end":{"line":212,"column":14}},"101":{"start":{"line":217,"column":0},"end":{"line":256,"column":2}},"102":{"start":{"line":218,"column":2},"end":{"line":241,"column":3}},"103":{"start":{"line":219,"column":15},"end":{"line":219,"column":31}},"104":{"start":{"line":220,"column":17},"end":{"line":220,"column":38}},"105":{"start":{"line":221,"column":4},"end":{"line":221,"column":37}},"106":{"start":{"line":222,"column":4},"end":{"line":222,"column":23}},"107":{"start":{"line":223,"column":4},"end":{"line":223,"column":16}},"108":{"start":{"line":225,"column":18},"end":{"line":225,"column":33}},"109":{"start":{"line":226,"column":4},"end":{"line":226,"column":43}},"110":{"start":{"line":228,"column":4},"end":{"line":228,"column":136}},"111":{"start":{"line":230,"column":4},"end":{"line":232,"column":5}},"112":{"start":{"line":231,"column":6},"end":{"line":231,"column":52}},"113":{"start":{"line":234,"column":4},"end":{"line":238,"column":5}},"114":{"start":{"line":235,"column":6},"end":{"line":235,"column":36}},"115":{"start":{"line":236,"column":11},"end":{"line":238,"column":5}},"116":{"start":{"line":237,"column":6},"end":{"line":237,"column":71}},"117":{"start":{"line":240,"column":4},"end":{"line":240,"column":82}},"118":{"start":{"line":243,"column":17},"end":{"line":243,"column":33}},"119":{"start":{"line":243,"column":46},"end":{"line":243,"column":65}},"120":{"start":{"line":244,"column":13},"end":{"line":244,"column":61}},"121":{"start":{"line":245,"column":2},"end":{"line":245,"column":74}},"122":{"start":{"line":245,"column":62},"end":{"line":245,"column":74}},"123":{"start":{"line":246,"column":2},"end":{"line":254,"column":3}},"124":{"start":{"line":247,"column":15},"end":{"line":247,"column":51}},"125":{"start":{"line":248,"column":4},"end":{"line":248,"column":37}},"126":{"start":{"line":249,"column":4},"end":{"line":249,"column":24}},"127":{"start":{"line":250,"column":4},"end":{"line":250,"column":25}},"128":{"start":{"line":251,"column":4},"end":{"line":251,"column":25}},"129":{"start":{"line":252,"column":4},"end":{"line":252,"column":16}},"130":{"start":{"line":253,"column":4},"end":{"line":253,"column":53}},"131":{"start":{"line":255,"column":2},"end":{"line":255,"column":14}},"132":{"start":{"line":260,"column":0},"end":{"line":274,"column":2}},"133":{"start":{"line":261,"column":17},"end":{"line":261,"column":33}},"134":{"start":{"line":261,"column":46},"end":{"line":261,"column":65}},"135":{"start":{"line":262,"column":25},"end":{"line":262,"column":52}},"136":{"start":{"line":263,"column":13},"end":{"line":263,"column":55}},"137":{"start":{"line":265,"column":2},"end":{"line":267,"column":3}},"138":{"start":{"line":266,"column":4},"end":{"line":266,"column":16}},"139":{"start":{"line":269,"column":2},"end":{"line":271,"column":3}},"140":{"start":{"line":270,"column":4},"end":{"line":270,"column":16}},"141":{"start":{"line":273,"column":2},"end":{"line":273,"column":56}},"142":{"start":{"line":276,"column":0},"end":{"line":319,"column":2}},"143":{"start":{"line":277,"column":2},"end":{"line":318,"column":3}},"144":{"start":{"line":278,"column":4},"end":{"line":317,"column":5}},"145":{"start":{"line":279,"column":17},"end":{"line":279,"column":53}},"146":{"start":{"line":280,"column":6},"end":{"line":280,"column":25}},"147":{"start":{"line":281,"column":6},"end":{"line":281,"column":43}},"148":{"start":{"line":282,"column":6},"end":{"line":282,"column":104}},"149":{"start":{"line":283,"column":11},"end":{"line":317,"column":5}},"150":{"start":{"line":284,"column":17},"end":{"line":284,"column":53}},"151":{"start":{"line":285,"column":6},"end":{"line":285,"column":25}},"152":{"start":{"line":286,"column":6},"end":{"line":286,"column":49}},"153":{"start":{"line":287,"column":6},"end":{"line":287,"column":28}},"154":{"start":{"line":288,"column":6},"end":{"line":288,"column":55}},"155":{"start":{"line":289,"column":11},"end":{"line":317,"column":5}},"156":{"start":{"line":290,"column":17},"end":{"line":290,"column":53}},"157":{"start":{"line":291,"column":6},"end":{"line":291,"column":25}},"158":{"start":{"line":292,"column":6},"end":{"line":292,"column":45}},"159":{"start":{"line":293,"column":6},"end":{"line":293,"column":27}},"160":{"start":{"line":294,"column":6},"end":{"line":294,"column":31}},"161":{"start":{"line":295,"column":6},"end":{"line":295,"column":55}},"162":{"start":{"line":296,"column":11},"end":{"line":317,"column":5}},"163":{"start":{"line":297,"column":26},"end":{"line":297,"column":153}},"164":{"start":{"line":298,"column":6},"end":{"line":298,"column":18}},"165":{"start":{"line":300,"column":17},"end":{"line":300,"column":53}},"166":{"start":{"line":301,"column":6},"end":{"line":301,"column":25}},"167":{"start":{"line":302,"column":6},"end":{"line":302,"column":83}},"168":{"start":{"line":303,"column":6},"end":{"line":303,"column":53}},"169":{"start":{"line":305,"column":6},"end":{"line":309,"column":7}},"170":{"start":{"line":306,"column":8},"end":{"line":306,"column":98}},"171":{"start":{"line":308,"column":8},"end":{"line":308,"column":46}},"172":{"start":{"line":310,"column":11},"end":{"line":317,"column":5}},"173":{"start":{"line":311,"column":17},"end":{"line":311,"column":53}},"174":{"start":{"line":312,"column":6},"end":{"line":312,"column":22}},"175":{"start":{"line":313,"column":6},"end":{"line":313,"column":40}},"176":{"start":{"line":314,"column":6},"end":{"line":314,"column":63}},"177":{"start":{"line":316,"column":6},"end":{"line":316,"column":18}},"178":{"start":{"line":321,"column":0},"end":{"line":347,"column":2}},"179":{"start":{"line":324,"column":13},"end":{"line":324,"column":15}},"180":{"start":{"line":324,"column":25},"end":{"line":324,"column":29}},"181":{"start":{"line":325,"column":2},"end":{"line":339,"column":3}},"182":{"start":{"line":326,"column":4},"end":{"line":331,"column":5}},"183":{"start":{"line":327,"column":6},"end":{"line":327,"column":20}},"184":{"start":{"line":329,"column":6},"end":{"line":329,"column":28}},"185":{"start":{"line":330,"column":6},"end":{"line":330,"column":33}},"186":{"start":{"line":330,"column":27},"end":{"line":330,"column":33}},"187":{"start":{"line":334,"column":4},"end":{"line":336,"column":5}},"188":{"start":{"line":335,"column":6},"end":{"line":335,"column":41}},"189":{"start":{"line":338,"column":4},"end":{"line":338,"column":96}},"190":{"start":{"line":342,"column":2},"end":{"line":344,"column":3}},"191":{"start":{"line":343,"column":4},"end":{"line":343,"column":22}},"192":{"start":{"line":346,"column":2},"end":{"line":346,"column":14}},"193":{"start":{"line":349,"column":0},"end":{"line":351,"column":2}},"194":{"start":{"line":350,"column":2},"end":{"line":350,"column":30}},"195":{"start":{"line":353,"column":0},"end":{"line":356,"column":2}},"196":{"start":{"line":354,"column":2},"end":{"line":354,"column":24}},"197":{"start":{"line":355,"column":2},"end":{"line":355,"column":63}},"198":{"start":{"line":360,"column":0},"end":{"line":363,"column":2}},"199":{"start":{"line":361,"column":17},"end":{"line":361,"column":33}},"200":{"start":{"line":361,"column":46},"end":{"line":361,"column":65}},"201":{"start":{"line":362,"column":2},"end":{"line":362,"column":78}},"202":{"start":{"line":370,"column":0},"end":{"line":504,"column":2}},"203":{"start":{"line":371,"column":25},"end":{"line":371,"column":73}},"204":{"start":{"line":372,"column":2},"end":{"line":503,"column":3}},"205":{"start":{"line":374,"column":6},"end":{"line":376,"column":7}},"206":{"start":{"line":375,"column":8},"end":{"line":375,"column":77}},"207":{"start":{"line":378,"column":6},"end":{"line":378,"column":30}},"208":{"start":{"line":379,"column":6},"end":{"line":379,"column":18}},"209":{"start":{"line":380,"column":6},"end":{"line":382,"column":7}},"210":{"start":{"line":381,"column":8},"end":{"line":381,"column":26}},"211":{"start":{"line":383,"column":6},"end":{"line":385,"column":7}},"212":{"start":{"line":384,"column":8},"end":{"line":384,"column":71}},"213":{"start":{"line":386,"column":6},"end":{"line":386,"column":44}},"214":{"start":{"line":389,"column":6},"end":{"line":389,"column":30}},"215":{"start":{"line":390,"column":6},"end":{"line":390,"column":18}},"216":{"start":{"line":391,"column":6},"end":{"line":391,"column":53}},"217":{"start":{"line":394,"column":6},"end":{"line":394,"column":52}},"218":{"start":{"line":394,"column":34},"end":{"line":394,"column":52}},"219":{"start":{"line":397,"column":6},"end":{"line":397,"column":30}},"220":{"start":{"line":398,"column":23},"end":{"line":398,"column":73}},"221":{"start":{"line":399,"column":23},"end":{"line":399,"column":56}},"222":{"start":{"line":400,"column":15},"end":{"line":400,"column":61}},"223":{"start":{"line":402,"column":6},"end":{"line":414,"column":7}},"224":{"start":{"line":403,"column":8},"end":{"line":405,"column":9}},"225":{"start":{"line":404,"column":10},"end":{"line":404,"column":39}},"226":{"start":{"line":406,"column":13},"end":{"line":414,"column":7}},"227":{"start":{"line":407,"column":8},"end":{"line":407,"column":20}},"228":{"start":{"line":408,"column":8},"end":{"line":408,"column":60}},"229":{"start":{"line":409,"column":13},"end":{"line":414,"column":7}},"230":{"start":{"line":410,"column":21},"end":{"line":410,"column":45}},"231":{"start":{"line":411,"column":8},"end":{"line":411,"column":30}},"232":{"start":{"line":413,"column":8},"end":{"line":413,"column":61}},"233":{"start":{"line":416,"column":6},"end":{"line":418,"column":7}},"234":{"start":{"line":417,"column":8},"end":{"line":417,"column":53}},"235":{"start":{"line":420,"column":6},"end":{"line":420,"column":16}},"236":{"start":{"line":423,"column":6},"end":{"line":434,"column":7}},"237":{"start":{"line":424,"column":19},"end":{"line":424,"column":35}},"238":{"start":{"line":425,"column":8},"end":{"line":425,"column":20}},"239":{"start":{"line":426,"column":28},"end":{"line":426,"column":49}},"240":{"start":{"line":427,"column":24},"end":{"line":427,"column":41}},"241":{"start":{"line":428,"column":8},"end":{"line":428,"column":31}},"242":{"start":{"line":429,"column":8},"end":{"line":429,"column":38}},"243":{"start":{"line":430,"column":8},"end":{"line":430,"column":49}},"244":{"start":{"line":431,"column":8},"end":{"line":431,"column":46}},"245":{"start":{"line":432,"column":8},"end":{"line":432,"column":38}},"246":{"start":{"line":433,"column":8},"end":{"line":433,"column":53}},"247":{"start":{"line":437,"column":18},"end":{"line":437,"column":34}},"248":{"start":{"line":438,"column":6},"end":{"line":438,"column":61}},"249":{"start":{"line":439,"column":6},"end":{"line":439,"column":35}},"250":{"start":{"line":440,"column":6},"end":{"line":440,"column":31}},"251":{"start":{"line":441,"column":6},"end":{"line":441,"column":18}},"252":{"start":{"line":444,"column":6},"end":{"line":444,"column":67}},"253":{"start":{"line":447,"column":6},"end":{"line":447,"column":66}},"254":{"start":{"line":450,"column":6},"end":{"line":450,"column":30}},"255":{"start":{"line":451,"column":6},"end":{"line":451,"column":18}},"256":{"start":{"line":452,"column":6},"end":{"line":452,"column":50}},"257":{"start":{"line":455,"column":6},"end":{"line":455,"column":30}},"258":{"start":{"line":456,"column":6},"end":{"line":456,"column":40}},"259":{"start":{"line":457,"column":6},"end":{"line":457,"column":18}},"260":{"start":{"line":458,"column":6},"end":{"line":458,"column":53}},"261":{"start":{"line":461,"column":6},"end":{"line":461,"column":77}},"262":{"start":{"line":464,"column":6},"end":{"line":464,"column":30}},"263":{"start":{"line":465,"column":6},"end":{"line":465,"column":18}},"264":{"start":{"line":466,"column":6},"end":{"line":466,"column":84}},"265":{"start":{"line":467,"column":6},"end":{"line":467,"column":43}},"266":{"start":{"line":468,"column":6},"end":{"line":468,"column":54}},"267":{"start":{"line":471,"column":6},"end":{"line":471,"column":58}},"268":{"start":{"line":474,"column":6},"end":{"line":474,"column":44}},"269":{"start":{"line":477,"column":6},"end":{"line":477,"column":29}},"270":{"start":{"line":480,"column":6},"end":{"line":480,"column":30}},"271":{"start":{"line":481,"column":6},"end":{"line":481,"column":32}},"272":{"start":{"line":482,"column":6},"end":{"line":482,"column":42}},"273":{"start":{"line":485,"column":6},"end":{"line":485,"column":29}},"274":{"start":{"line":488,"column":6},"end":{"line":488,"column":34}},"275":{"start":{"line":491,"column":6},"end":{"line":491,"column":30}},"276":{"start":{"line":492,"column":6},"end":{"line":492,"column":18}},"277":{"start":{"line":493,"column":6},"end":{"line":493,"column":25}},"278":{"start":{"line":494,"column":19},"end":{"line":494,"column":55}},"279":{"start":{"line":495,"column":6},"end":{"line":499,"column":7}},"280":{"start":{"line":496,"column":8},"end":{"line":496,"column":55}},"281":{"start":{"line":498,"column":8},"end":{"line":498,"column":84}},"282":{"start":{"line":502,"column":6},"end":{"line":502,"column":24}},"283":{"start":{"line":506,"column":0},"end":{"line":514,"column":2}},"284":{"start":{"line":507,"column":13},"end":{"line":507,"column":29}},"285":{"start":{"line":508,"column":13},"end":{"line":508,"column":39}},"286":{"start":{"line":509,"column":2},"end":{"line":513,"column":3}},"287":{"start":{"line":510,"column":4},"end":{"line":510,"column":54}},"288":{"start":{"line":512,"column":4},"end":{"line":512,"column":43}},"289":{"start":{"line":516,"column":0},"end":{"line":525,"column":2}},"290":{"start":{"line":517,"column":2},"end":{"line":517,"column":19}},"291":{"start":{"line":518,"column":2},"end":{"line":518,"column":45}},"292":{"start":{"line":520,"column":2},"end":{"line":522,"column":3}},"293":{"start":{"line":521,"column":4},"end":{"line":521,"column":108}},"294":{"start":{"line":524,"column":2},"end":{"line":524,"column":47}},"295":{"start":{"line":527,"column":0},"end":{"line":534,"column":2}},"296":{"start":{"line":528,"column":13},"end":{"line":528,"column":29}},"297":{"start":{"line":529,"column":2},"end":{"line":529,"column":41}},"298":{"start":{"line":530,"column":2},"end":{"line":530,"column":81}},"299":{"start":{"line":531,"column":2},"end":{"line":531,"column":21}},"300":{"start":{"line":532,"column":2},"end":{"line":532,"column":14}},"301":{"start":{"line":533,"column":2},"end":{"line":533,"column":37}},"302":{"start":{"line":536,"column":0},"end":{"line":541,"column":2}},"303":{"start":{"line":537,"column":2},"end":{"line":537,"column":25}},"304":{"start":{"line":538,"column":12},"end":{"line":538,"column":34}},"305":{"start":{"line":539,"column":2},"end":{"line":539,"column":25}},"306":{"start":{"line":540,"column":2},"end":{"line":540,"column":13}},"307":{"start":{"line":543,"column":0},"end":{"line":614,"column":2}},"308":{"start":{"line":544,"column":2},"end":{"line":544,"column":42}},"309":{"start":{"line":545,"column":2},"end":{"line":545,"column":45}},"310":{"start":{"line":548,"column":2},"end":{"line":548,"column":25}},"311":{"start":{"line":550,"column":22},"end":{"line":550,"column":38}},"312":{"start":{"line":550,"column":56},"end":{"line":550,"column":75}},"313":{"start":{"line":551,"column":17},"end":{"line":551,"column":19}},"314":{"start":{"line":551,"column":29},"end":{"line":551,"column":33}},"315":{"start":{"line":552,"column":31},"end":{"line":552,"column":43}},"316":{"start":{"line":553,"column":25},"end":{"line":553,"column":37}},"317":{"start":{"line":554,"column":2},"end":{"line":573,"column":3}},"318":{"start":{"line":555,"column":4},"end":{"line":563,"column":5}},"319":{"start":{"line":556,"column":6},"end":{"line":556,"column":20}},"320":{"start":{"line":558,"column":6},"end":{"line":558,"column":60}},"321":{"start":{"line":559,"column":6},"end":{"line":562,"column":7}},"322":{"start":{"line":560,"column":8},"end":{"line":560,"column":46}},"323":{"start":{"line":561,"column":8},"end":{"line":561,"column":14}},"324":{"start":{"line":565,"column":4},"end":{"line":572,"column":5}},"325":{"start":{"line":566,"column":31},"end":{"line":566,"column":47}},"326":{"start":{"line":566,"column":70},"end":{"line":566,"column":89}},"327":{"start":{"line":567,"column":6},"end":{"line":567,"column":37}},"328":{"start":{"line":568,"column":6},"end":{"line":568,"column":99}},"329":{"start":{"line":569,"column":6},"end":{"line":569,"column":12}},"330":{"start":{"line":571,"column":6},"end":{"line":571,"column":113}},"331":{"start":{"line":575,"column":20},"end":{"line":575,"column":36}},"332":{"start":{"line":576,"column":20},"end":{"line":576,"column":39}},"333":{"start":{"line":577,"column":2},"end":{"line":577,"column":25}},"334":{"start":{"line":579,"column":18},"end":{"line":579,"column":54}},"335":{"start":{"line":580,"column":2},"end":{"line":586,"column":3}},"336":{"start":{"line":581,"column":4},"end":{"line":583,"column":5}},"337":{"start":{"line":582,"column":6},"end":{"line":582,"column":92}},"338":{"start":{"line":582,"column":52},"end":{"line":582,"column":92}},"339":{"start":{"line":585,"column":4},"end":{"line":585,"column":67}},"340":{"start":{"line":588,"column":2},"end":{"line":594,"column":3}},"341":{"start":{"line":589,"column":4},"end":{"line":593,"column":5}},"342":{"start":{"line":590,"column":6},"end":{"line":590,"column":13}},"343":{"start":{"line":592,"column":6},"end":{"line":592,"column":47}},"344":{"start":{"line":595,"column":2},"end":{"line":595,"column":62}},"345":{"start":{"line":595,"column":26},"end":{"line":595,"column":62}},"346":{"start":{"line":596,"column":2},"end":{"line":596,"column":48}},"347":{"start":{"line":596,"column":19},"end":{"line":596,"column":48}},"348":{"start":{"line":597,"column":2},"end":{"line":597,"column":82}},"349":{"start":{"line":597,"column":36},"end":{"line":597,"column":82}},"350":{"start":{"line":598,"column":2},"end":{"line":598,"column":70}},"351":{"start":{"line":598,"column":30},"end":{"line":598,"column":70}},"352":{"start":{"line":600,"column":2},"end":{"line":607,"column":3}},"353":{"start":{"line":601,"column":4},"end":{"line":601,"column":57}},"354":{"start":{"line":602,"column":4},"end":{"line":602,"column":31}},"355":{"start":{"line":603,"column":4},"end":{"line":603,"column":43}},"356":{"start":{"line":604,"column":4},"end":{"line":604,"column":75}},"357":{"start":{"line":606,"column":4},"end":{"line":606,"column":22}},"358":{"start":{"line":610,"column":2},"end":{"line":610,"column":44}},"359":{"start":{"line":611,"column":2},"end":{"line":611,"column":45}},"360":{"start":{"line":613,"column":2},"end":{"line":613,"column":13}},"361":{"start":{"line":616,"column":0},"end":{"line":620,"column":2}},"362":{"start":{"line":617,"column":2},"end":{"line":619,"column":3}},"363":{"start":{"line":618,"column":4},"end":{"line":618,"column":16}},"364":{"start":{"line":622,"column":0},"end":{"line":624,"column":2}},"365":{"start":{"line":623,"column":2},"end":{"line":623,"column":14}},"366":{"start":{"line":630,"column":0},"end":{"line":648,"column":2}},"367":{"start":{"line":631,"column":13},"end":{"line":631,"column":29}},"368":{"start":{"line":632,"column":13},"end":{"line":632,"column":39}},"369":{"start":{"line":634,"column":2},"end":{"line":636,"column":3}},"370":{"start":{"line":635,"column":4},"end":{"line":635,"column":56}},"371":{"start":{"line":638,"column":2},"end":{"line":638,"column":39}},"372":{"start":{"line":640,"column":2},"end":{"line":645,"column":3}},"373":{"start":{"line":641,"column":4},"end":{"line":641,"column":51}},"374":{"start":{"line":642,"column":4},"end":{"line":642,"column":42}},"375":{"start":{"line":644,"column":4},"end":{"line":644,"column":24}},"376":{"start":{"line":647,"column":2},"end":{"line":647,"column":48}},"377":{"start":{"line":652,"column":0},"end":{"line":661,"column":2}},"378":{"start":{"line":653,"column":13},"end":{"line":653,"column":29}},"379":{"start":{"line":654,"column":2},"end":{"line":657,"column":4}},"380":{"start":{"line":658,"column":2},"end":{"line":658,"column":14}},"381":{"start":{"line":659,"column":2},"end":{"line":659,"column":39}},"382":{"start":{"line":660,"column":2},"end":{"line":660,"column":50}},"383":{"start":{"line":663,"column":0},"end":{"line":677,"column":2}},"384":{"start":{"line":664,"column":13},"end":{"line":664,"column":29}},"385":{"start":{"line":665,"column":2},"end":{"line":665,"column":14}},"386":{"start":{"line":666,"column":2},"end":{"line":666,"column":24}},"387":{"start":{"line":667,"column":15},"end":{"line":667,"column":42}},"388":{"start":{"line":668,"column":2},"end":{"line":668,"column":25}},"389":{"start":{"line":669,"column":2},"end":{"line":674,"column":3}},"390":{"start":{"line":670,"column":4},"end":{"line":670,"column":33}},"391":{"start":{"line":671,"column":4},"end":{"line":671,"column":50}},"392":{"start":{"line":672,"column":4},"end":{"line":672,"column":27}},"393":{"start":{"line":673,"column":4},"end":{"line":673,"column":59}},"394":{"start":{"line":675,"column":2},"end":{"line":675,"column":14}},"395":{"start":{"line":676,"column":2},"end":{"line":676,"column":50}},"396":{"start":{"line":681,"column":0},"end":{"line":757,"column":2}},"397":{"start":{"line":682,"column":19},"end":{"line":682,"column":21}},"398":{"start":{"line":683,"column":17},"end":{"line":683,"column":36}},"399":{"start":{"line":684,"column":14},"end":{"line":684,"column":18}},"400":{"start":{"line":685,"column":13},"end":{"line":685,"column":29}},"401":{"start":{"line":687,"column":2},"end":{"line":687,"column":23}},"402":{"start":{"line":688,"column":2},"end":{"line":688,"column":14}},"403":{"start":{"line":690,"column":2},"end":{"line":750,"column":3}},"404":{"start":{"line":691,"column":4},"end":{"line":696,"column":5}},"405":{"start":{"line":692,"column":6},"end":{"line":692,"column":20}},"406":{"start":{"line":694,"column":6},"end":{"line":694,"column":28}},"407":{"start":{"line":695,"column":6},"end":{"line":695,"column":37}},"408":{"start":{"line":695,"column":31},"end":{"line":695,"column":37}},"409":{"start":{"line":698,"column":4},"end":{"line":700,"column":5}},"410":{"start":{"line":699,"column":6},"end":{"line":699,"column":45}},"411":{"start":{"line":702,"column":15},"end":{"line":702,"column":31}},"412":{"start":{"line":702,"column":47},"end":{"line":702,"column":52}},"413":{"start":{"line":702,"column":64},"end":{"line":702,"column":69}},"414":{"start":{"line":703,"column":4},"end":{"line":706,"column":5}},"415":{"start":{"line":704,"column":6},"end":{"line":704,"column":35}},"416":{"start":{"line":705,"column":6},"end":{"line":705,"column":22}},"417":{"start":{"line":708,"column":4},"end":{"line":713,"column":5}},"418":{"start":{"line":709,"column":6},"end":{"line":709,"column":32}},"419":{"start":{"line":710,"column":6},"end":{"line":710,"column":64}},"420":{"start":{"line":711,"column":6},"end":{"line":711,"column":33}},"421":{"start":{"line":712,"column":6},"end":{"line":712,"column":15}},"422":{"start":{"line":715,"column":4},"end":{"line":715,"column":24}},"423":{"start":{"line":716,"column":4},"end":{"line":716,"column":27}},"424":{"start":{"line":718,"column":4},"end":{"line":721,"column":5}},"425":{"start":{"line":719,"column":6},"end":{"line":719,"column":34}},"426":{"start":{"line":720,"column":6},"end":{"line":720,"column":37}},"427":{"start":{"line":723,"column":4},"end":{"line":725,"column":5}},"428":{"start":{"line":724,"column":6},"end":{"line":724,"column":38}},"429":{"start":{"line":727,"column":4},"end":{"line":740,"column":5}},"430":{"start":{"line":728,"column":6},"end":{"line":728,"column":41}},"431":{"start":{"line":728,"column":23},"end":{"line":728,"column":41}},"432":{"start":{"line":730,"column":20},"end":{"line":730,"column":42}},"433":{"start":{"line":731,"column":6},"end":{"line":737,"column":7}},"434":{"start":{"line":732,"column":8},"end":{"line":732,"column":27}},"435":{"start":{"line":734,"column":8},"end":{"line":734,"column":23}},"436":{"start":{"line":735,"column":8},"end":{"line":735,"column":79}},"437":{"start":{"line":735,"column":47},"end":{"line":735,"column":79}},"438":{"start":{"line":736,"column":8},"end":{"line":736,"column":37}},"439":{"start":{"line":739,"column":6},"end":{"line":739,"column":35}},"440":{"start":{"line":742,"column":4},"end":{"line":742,"column":110}},"441":{"start":{"line":743,"column":4},"end":{"line":743,"column":40}},"442":{"start":{"line":745,"column":4},"end":{"line":747,"column":5}},"443":{"start":{"line":746,"column":6},"end":{"line":746,"column":45}},"444":{"start":{"line":749,"column":4},"end":{"line":749,"column":31}},"445":{"start":{"line":752,"column":2},"end":{"line":754,"column":3}},"446":{"start":{"line":753,"column":4},"end":{"line":753,"column":82}},"447":{"start":{"line":756,"column":2},"end":{"line":756,"column":81}},"448":{"start":{"line":759,"column":0},"end":{"line":813,"column":2}},"449":{"start":{"line":760,"column":2},"end":{"line":766,"column":3}},"450":{"start":{"line":761,"column":4},"end":{"line":761,"column":37}},"451":{"start":{"line":761,"column":19},"end":{"line":761,"column":37}},"452":{"start":{"line":762,"column":4},"end":{"line":762,"column":25}},"453":{"start":{"line":763,"column":4},"end":{"line":763,"column":23}},"454":{"start":{"line":764,"column":4},"end":{"line":764,"column":49}},"455":{"start":{"line":765,"column":4},"end":{"line":765,"column":49}},"456":{"start":{"line":768,"column":2},"end":{"line":771,"column":3}},"457":{"start":{"line":769,"column":4},"end":{"line":769,"column":146}},"458":{"start":{"line":770,"column":4},"end":{"line":770,"column":51}},"459":{"start":{"line":773,"column":2},"end":{"line":788,"column":3}},"460":{"start":{"line":774,"column":4},"end":{"line":774,"column":63}},"461":{"start":{"line":774,"column":45},"end":{"line":774,"column":63}},"462":{"start":{"line":775,"column":4},"end":{"line":775,"column":30}},"463":{"start":{"line":776,"column":4},"end":{"line":776,"column":33}},"464":{"start":{"line":777,"column":4},"end":{"line":777,"column":34}},"465":{"start":{"line":778,"column":21},"end":{"line":778,"column":48}},"466":{"start":{"line":779,"column":4},"end":{"line":786,"column":5}},"467":{"start":{"line":780,"column":18},"end":{"line":780,"column":28}},"468":{"start":{"line":781,"column":6},"end":{"line":785,"column":7}},"469":{"start":{"line":782,"column":8},"end":{"line":782,"column":58}},"470":{"start":{"line":784,"column":8},"end":{"line":784,"column":66}},"471":{"start":{"line":787,"column":4},"end":{"line":787,"column":49}},"472":{"start":{"line":790,"column":2},"end":{"line":810,"column":3}},"473":{"start":{"line":791,"column":4},"end":{"line":807,"column":5}},"474":{"start":{"line":792,"column":27},"end":{"line":792,"column":56}},"475":{"start":{"line":793,"column":6},"end":{"line":795,"column":7}},"476":{"start":{"line":794,"column":8},"end":{"line":794,"column":104}},"477":{"start":{"line":796,"column":6},"end":{"line":798,"column":7}},"478":{"start":{"line":797,"column":8},"end":{"line":797,"column":63}},"479":{"start":{"line":799,"column":6},"end":{"line":799,"column":82}},"480":{"start":{"line":800,"column":11},"end":{"line":807,"column":5}},"481":{"start":{"line":801,"column":6},"end":{"line":803,"column":7}},"482":{"start":{"line":802,"column":8},"end":{"line":802,"column":56}},"483":{"start":{"line":804,"column":6},"end":{"line":804,"column":82}},"484":{"start":{"line":806,"column":6},"end":{"line":806,"column":38}},"485":{"start":{"line":808,"column":4},"end":{"line":808,"column":26}},"486":{"start":{"line":809,"column":4},"end":{"line":809,"column":51}},"487":{"start":{"line":812,"column":2},"end":{"line":812,"column":20}},"488":{"start":{"line":815,"column":0},"end":{"line":825,"column":2}},"489":{"start":{"line":816,"column":2},"end":{"line":824,"column":3}},"490":{"start":{"line":817,"column":4},"end":{"line":817,"column":25}},"491":{"start":{"line":818,"column":4},"end":{"line":818,"column":39}},"492":{"start":{"line":819,"column":4},"end":{"line":819,"column":29}},"493":{"start":{"line":820,"column":4},"end":{"line":820,"column":20}},"494":{"start":{"line":822,"column":4},"end":{"line":822,"column":26}},"495":{"start":{"line":823,"column":4},"end":{"line":823,"column":120}},"496":{"start":{"line":829,"column":0},"end":{"line":834,"column":2}},"497":{"start":{"line":830,"column":2},"end":{"line":830,"column":17}},"498":{"start":{"line":831,"column":2},"end":{"line":831,"column":25}},"499":{"start":{"line":832,"column":2},"end":{"line":832,"column":26}},"500":{"start":{"line":833,"column":2},"end":{"line":833,"column":25}},"501":{"start":{"line":838,"column":0},"end":{"line":848,"column":2}},"502":{"start":{"line":839,"column":20},"end":{"line":839,"column":39}},"503":{"start":{"line":840,"column":2},"end":{"line":840,"column":42}},"504":{"start":{"line":841,"column":2},"end":{"line":841,"column":35}},"505":{"start":{"line":842,"column":2},"end":{"line":842,"column":25}},"506":{"start":{"line":843,"column":2},"end":{"line":843,"column":49}},"507":{"start":{"line":844,"column":2},"end":{"line":844,"column":31}},"508":{"start":{"line":845,"column":2},"end":{"line":845,"column":31}},"509":{"start":{"line":846,"column":2},"end":{"line":846,"column":36}},"510":{"start":{"line":847,"column":2},"end":{"line":847,"column":14}},"511":{"start":{"line":852,"column":0},"end":{"line":857,"column":2}},"512":{"start":{"line":853,"column":2},"end":{"line":853,"column":35}},"513":{"start":{"line":854,"column":2},"end":{"line":854,"column":52}},"514":{"start":{"line":855,"column":2},"end":{"line":855,"column":37}},"515":{"start":{"line":856,"column":2},"end":{"line":856,"column":58}},"516":{"start":{"line":861,"column":0},"end":{"line":919,"column":2}},"517":{"start":{"line":862,"column":21},"end":{"line":862,"column":62}},"518":{"start":{"line":864,"column":19},"end":{"line":864,"column":37}},"519":{"start":{"line":865,"column":2},"end":{"line":865,"column":34}},"520":{"start":{"line":866,"column":2},"end":{"line":877,"column":3}},"521":{"start":{"line":867,"column":4},"end":{"line":867,"column":40}},"522":{"start":{"line":868,"column":4},"end":{"line":868,"column":27}},"523":{"start":{"line":872,"column":20},"end":{"line":872,"column":41}},"524":{"start":{"line":872,"column":54},"end":{"line":872,"column":76}},"525":{"start":{"line":872,"column":90},"end":{"line":872,"column":107}},"526":{"start":{"line":873,"column":4},"end":{"line":873,"column":33}},"527":{"start":{"line":873,"column":34},"end":{"line":873,"column":74}},"528":{"start":{"line":873,"column":75},"end":{"line":873,"column":98}},"529":{"start":{"line":874,"column":4},"end":{"line":874,"column":38}},"530":{"start":{"line":875,"column":4},"end":{"line":875,"column":28}},"531":{"start":{"line":876,"column":4},"end":{"line":876,"column":38}},"532":{"start":{"line":876,"column":39},"end":{"line":876,"column":73}},"533":{"start":{"line":876,"column":74},"end":{"line":876,"column":104}},"534":{"start":{"line":878,"column":2},"end":{"line":878,"column":34}},"535":{"start":{"line":883,"column":18},"end":{"line":883,"column":35}},"536":{"start":{"line":884,"column":24},"end":{"line":884,"column":29}},"537":{"start":{"line":885,"column":17},"end":{"line":885,"column":22}},"538":{"start":{"line":888,"column":2},"end":{"line":888,"column":40}},"539":{"start":{"line":888,"column":23},"end":{"line":888,"column":40}},"540":{"start":{"line":891,"column":2},"end":{"line":900,"column":3}},"541":{"start":{"line":892,"column":4},"end":{"line":899,"column":5}},"542":{"start":{"line":893,"column":6},"end":{"line":898,"column":7}},"543":{"start":{"line":894,"column":8},"end":{"line":894,"column":24}},"544":{"start":{"line":895,"column":8},"end":{"line":895,"column":25}},"545":{"start":{"line":896,"column":8},"end":{"line":896,"column":31}},"546":{"start":{"line":897,"column":8},"end":{"line":897,"column":14}},"547":{"start":{"line":903,"column":2},"end":{"line":905,"column":3}},"548":{"start":{"line":904,"column":4},"end":{"line":904,"column":62}},"549":{"start":{"line":907,"column":2},"end":{"line":918,"column":3}},"550":{"start":{"line":908,"column":19},"end":{"line":908,"column":38}},"551":{"start":{"line":909,"column":20},"end":{"line":909,"column":37}},"552":{"start":{"line":910,"column":4},"end":{"line":910,"column":50}},"553":{"start":{"line":910,"column":25},"end":{"line":910,"column":50}},"554":{"start":{"line":911,"column":4},"end":{"line":913,"column":5}},"555":{"start":{"line":912,"column":6},"end":{"line":912,"column":36}},"556":{"start":{"line":914,"column":4},"end":{"line":916,"column":5}},"557":{"start":{"line":915,"column":6},"end":{"line":915,"column":44}},"558":{"start":{"line":917,"column":4},"end":{"line":917,"column":34}},"559":{"start":{"line":927,"column":0},"end":{"line":940,"column":2}},"560":{"start":{"line":928,"column":13},"end":{"line":928,"column":15}},"561":{"start":{"line":928,"column":25},"end":{"line":928,"column":29}},"562":{"start":{"line":929,"column":2},"end":{"line":938,"column":3}},"563":{"start":{"line":930,"column":4},"end":{"line":935,"column":5}},"564":{"start":{"line":931,"column":6},"end":{"line":931,"column":20}},"565":{"start":{"line":933,"column":6},"end":{"line":933,"column":28}},"566":{"start":{"line":934,"column":6},"end":{"line":934,"column":33}},"567":{"start":{"line":934,"column":27},"end":{"line":934,"column":33}},"568":{"start":{"line":937,"column":4},"end":{"line":937,"column":74}},"569":{"start":{"line":939,"column":2},"end":{"line":939,"column":14}},"570":{"start":{"line":942,"column":0},"end":{"line":952,"column":2}},"571":{"start":{"line":944,"column":2},"end":{"line":950,"column":3}},"572":{"start":{"line":945,"column":4},"end":{"line":945,"column":15}},"573":{"start":{"line":946,"column":9},"end":{"line":950,"column":3}},"574":{"start":{"line":947,"column":4},"end":{"line":947,"column":51}},"575":{"start":{"line":949,"column":4},"end":{"line":949,"column":84}},"576":{"start":{"line":951,"column":2},"end":{"line":951,"column":13}},"577":{"start":{"line":958,"column":0},"end":{"line":981,"column":2}},"578":{"start":{"line":959,"column":13},"end":{"line":959,"column":29}},"579":{"start":{"line":961,"column":2},"end":{"line":971,"column":3}},"580":{"start":{"line":962,"column":4},"end":{"line":964,"column":5}},"581":{"start":{"line":963,"column":6},"end":{"line":963,"column":89}},"582":{"start":{"line":966,"column":4},"end":{"line":966,"column":33}},"583":{"start":{"line":967,"column":9},"end":{"line":971,"column":3}},"584":{"start":{"line":968,"column":4},"end":{"line":968,"column":40}},"585":{"start":{"line":970,"column":4},"end":{"line":970,"column":22}},"586":{"start":{"line":973,"column":2},"end":{"line":975,"column":3}},"587":{"start":{"line":974,"column":4},"end":{"line":974,"column":79}},"588":{"start":{"line":977,"column":2},"end":{"line":977,"column":38}},"589":{"start":{"line":979,"column":2},"end":{"line":979,"column":14}},"590":{"start":{"line":980,"column":2},"end":{"line":980,"column":45}},"591":{"start":{"line":985,"column":0},"end":{"line":994,"column":2}},"592":{"start":{"line":986,"column":2},"end":{"line":988,"column":3}},"593":{"start":{"line":987,"column":4},"end":{"line":987,"column":22}},"594":{"start":{"line":989,"column":2},"end":{"line":991,"column":3}},"595":{"start":{"line":990,"column":4},"end":{"line":990,"column":116}},"596":{"start":{"line":992,"column":2},"end":{"line":992,"column":41}},"597":{"start":{"line":993,"column":2},"end":{"line":993,"column":50}},"598":{"start":{"line":998,"column":0},"end":{"line":1009,"column":2}},"599":{"start":{"line":999,"column":13},"end":{"line":999,"column":29}},"600":{"start":{"line":1000,"column":2},"end":{"line":1000,"column":14}},"601":{"start":{"line":1001,"column":2},"end":{"line":1007,"column":3}},"602":{"start":{"line":1002,"column":4},"end":{"line":1002,"column":26}},"603":{"start":{"line":1003,"column":4},"end":{"line":1003,"column":25}},"604":{"start":{"line":1005,"column":4},"end":{"line":1005,"column":38}},"605":{"start":{"line":1006,"column":4},"end":{"line":1006,"column":44}},"606":{"start":{"line":1008,"column":2},"end":{"line":1008,"column":50}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":33,"column":20},"end":{"line":33,"column":21}},"loc":{"start":{"line":33,"column":46},"end":{"line":56,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":73,"column":21},"end":{"line":73,"column":22}},"loc":{"start":{"line":73,"column":61},"end":{"line":86,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":91,"column":22},"end":{"line":91,"column":23}},"loc":{"start":{"line":91,"column":96},"end":{"line":141,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":145,"column":27},"end":{"line":145,"column":28}},"loc":{"start":{"line":145,"column":85},"end":{"line":151,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":153,"column":22},"end":{"line":153,"column":23}},"loc":{"start":{"line":153,"column":64},"end":{"line":163,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":167,"column":18},"end":{"line":167,"column":19}},"loc":{"start":{"line":167,"column":58},"end":{"line":175,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":183,"column":17},"end":{"line":183,"column":18}},"loc":{"start":{"line":183,"column":75},"end":{"line":213,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":217,"column":21},"end":{"line":217,"column":22}},"loc":{"start":{"line":217,"column":55},"end":{"line":256,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":260,"column":25},"end":{"line":260,"column":26}},"loc":{"start":{"line":260,"column":59},"end":{"line":274,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":276,"column":21},"end":{"line":276,"column":22}},"loc":{"start":{"line":276,"column":66},"end":{"line":319,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":321,"column":34},"end":{"line":321,"column":35}},"loc":{"start":{"line":321,"column":71},"end":{"line":347,"column":1}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":349,"column":27},"end":{"line":349,"column":28}},"loc":{"start":{"line":349,"column":39},"end":{"line":351,"column":1}}},"13":{"name":"(anonymous_13)","decl":{"start":{"line":353,"column":39},"end":{"line":353,"column":40}},"loc":{"start":{"line":353,"column":61},"end":{"line":356,"column":1}}},"14":{"name":"(anonymous_14)","decl":{"start":{"line":360,"column":21},"end":{"line":360,"column":22}},"loc":{"start":{"line":360,"column":33},"end":{"line":363,"column":1}}},"15":{"name":"(anonymous_15)","decl":{"start":{"line":370,"column":19},"end":{"line":370,"column":20}},"loc":{"start":{"line":370,"column":53},"end":{"line":504,"column":1}}},"16":{"name":"(anonymous_16)","decl":{"start":{"line":506,"column":29},"end":{"line":506,"column":30}},"loc":{"start":{"line":506,"column":41},"end":{"line":514,"column":1}}},"17":{"name":"(anonymous_17)","decl":{"start":{"line":516,"column":23},"end":{"line":516,"column":24}},"loc":{"start":{"line":516,"column":59},"end":{"line":525,"column":1}}},"18":{"name":"(anonymous_18)","decl":{"start":{"line":527,"column":18},"end":{"line":527,"column":19}},"loc":{"start":{"line":527,"column":41},"end":{"line":534,"column":1}}},"19":{"name":"(anonymous_19)","decl":{"start":{"line":536,"column":26},"end":{"line":536,"column":27}},"loc":{"start":{"line":536,"column":38},"end":{"line":541,"column":1}}},"20":{"name":"(anonymous_20)","decl":{"start":{"line":543,"column":40},"end":{"line":543,"column":41}},"loc":{"start":{"line":543,"column":91},"end":{"line":614,"column":1}}},"21":{"name":"(anonymous_21)","decl":{"start":{"line":616,"column":16},"end":{"line":616,"column":17}},"loc":{"start":{"line":616,"column":32},"end":{"line":620,"column":1}}},"22":{"name":"(anonymous_22)","decl":{"start":{"line":622,"column":20},"end":{"line":622,"column":21}},"loc":{"start":{"line":622,"column":36},"end":{"line":624,"column":1}}},"23":{"name":"(anonymous_23)","decl":{"start":{"line":630,"column":14},"end":{"line":630,"column":15}},"loc":{"start":{"line":630,"column":26},"end":{"line":648,"column":1}}},"24":{"name":"(anonymous_24)","decl":{"start":{"line":652,"column":26},"end":{"line":652,"column":27}},"loc":{"start":{"line":652,"column":38},"end":{"line":661,"column":1}}},"25":{"name":"(anonymous_25)","decl":{"start":{"line":663,"column":19},"end":{"line":663,"column":20}},"loc":{"start":{"line":663,"column":31},"end":{"line":677,"column":1}}},"26":{"name":"(anonymous_26)","decl":{"start":{"line":681,"column":14},"end":{"line":681,"column":15}},"loc":{"start":{"line":681,"column":59},"end":{"line":757,"column":1}}},"27":{"name":"(anonymous_27)","decl":{"start":{"line":759,"column":23},"end":{"line":759,"column":24}},"loc":{"start":{"line":759,"column":116},"end":{"line":813,"column":1}}},"28":{"name":"(anonymous_28)","decl":{"start":{"line":815,"column":23},"end":{"line":815,"column":24}},"loc":{"start":{"line":815,"column":39},"end":{"line":825,"column":1}}},"29":{"name":"(anonymous_29)","decl":{"start":{"line":829,"column":18},"end":{"line":829,"column":19}},"loc":{"start":{"line":829,"column":43},"end":{"line":834,"column":1}}},"30":{"name":"(anonymous_30)","decl":{"start":{"line":838,"column":17},"end":{"line":838,"column":18}},"loc":{"start":{"line":838,"column":55},"end":{"line":848,"column":1}}},"31":{"name":"(anonymous_31)","decl":{"start":{"line":852,"column":26},"end":{"line":852,"column":27}},"loc":{"start":{"line":852,"column":59},"end":{"line":857,"column":1}}},"32":{"name":"(anonymous_32)","decl":{"start":{"line":861,"column":23},"end":{"line":861,"column":24}},"loc":{"start":{"line":861,"column":56},"end":{"line":919,"column":1}}},"33":{"name":"(anonymous_33)","decl":{"start":{"line":927,"column":19},"end":{"line":927,"column":20}},"loc":{"start":{"line":927,"column":72},"end":{"line":940,"column":1}}},"34":{"name":"(anonymous_34)","decl":{"start":{"line":942,"column":23},"end":{"line":942,"column":24}},"loc":{"start":{"line":942,"column":69},"end":{"line":952,"column":1}}},"35":{"name":"(anonymous_35)","decl":{"start":{"line":958,"column":21},"end":{"line":958,"column":22}},"loc":{"start":{"line":958,"column":40},"end":{"line":981,"column":1}}},"36":{"name":"(anonymous_36)","decl":{"start":{"line":985,"column":16},"end":{"line":985,"column":17}},"loc":{"start":{"line":985,"column":32},"end":{"line":994,"column":1}}},"37":{"name":"(anonymous_37)","decl":{"start":{"line":998,"column":16},"end":{"line":998,"column":17}},"loc":{"start":{"line":998,"column":28},"end":{"line":1009,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":34,"column":2},"end":{"line":34,"column":28}},"type":"if","locations":[{"start":{"line":34,"column":2},"end":{"line":34,"column":28}},{"start":{"line":34,"column":2},"end":{"line":34,"column":28}}]},"2":{"loc":{"start":{"line":38,"column":2},"end":{"line":50,"column":3}},"type":"switch","locations":[{"start":{"line":39,"column":4},"end":{"line":41,"column":12}},{"start":{"line":43,"column":4},"end":{"line":43,"column":25}},{"start":{"line":44,"column":4},"end":{"line":46,"column":12}},{"start":{"line":48,"column":4},"end":{"line":49,"column":13}}]},"3":{"loc":{"start":{"line":52,"column":2},"end":{"line":55,"column":3}},"type":"if","locations":[{"start":{"line":52,"column":2},"end":{"line":55,"column":3}},{"start":{"line":52,"column":2},"end":{"line":55,"column":3}}]},"4":{"loc":{"start":{"line":52,"column":6},"end":{"line":52,"column":50}},"type":"binary-expr","locations":[{"start":{"line":52,"column":6},"end":{"line":52,"column":26}},{"start":{"line":52,"column":30},"end":{"line":52,"column":50}}]},"5":{"loc":{"start":{"line":53,"column":4},"end":{"line":53,"column":84}},"type":"if","locations":[{"start":{"line":53,"column":4},"end":{"line":53,"column":84}},{"start":{"line":53,"column":4},"end":{"line":53,"column":84}}]},"6":{"loc":{"start":{"line":76,"column":2},"end":{"line":84,"column":3}},"type":"if","locations":[{"start":{"line":76,"column":2},"end":{"line":84,"column":3}},{"start":{"line":76,"column":2},"end":{"line":84,"column":3}}]},"7":{"loc":{"start":{"line":92,"column":2},"end":{"line":94,"column":3}},"type":"if","locations":[{"start":{"line":92,"column":2},"end":{"line":94,"column":3}},{"start":{"line":92,"column":2},"end":{"line":94,"column":3}}]},"8":{"loc":{"start":{"line":92,"column":6},"end":{"line":92,"column":53}},"type":"binary-expr","locations":[{"start":{"line":92,"column":6},"end":{"line":92,"column":27}},{"start":{"line":92,"column":31},"end":{"line":92,"column":53}}]},"9":{"loc":{"start":{"line":97,"column":2},"end":{"line":102,"column":3}},"type":"if","locations":[{"start":{"line":97,"column":2},"end":{"line":102,"column":3}},{"start":{"line":97,"column":2},"end":{"line":102,"column":3}}]},"10":{"loc":{"start":{"line":107,"column":2},"end":{"line":109,"column":3}},"type":"if","locations":[{"start":{"line":107,"column":2},"end":{"line":109,"column":3}},{"start":{"line":107,"column":2},"end":{"line":109,"column":3}}]},"11":{"loc":{"start":{"line":107,"column":6},"end":{"line":107,"column":50}},"type":"binary-expr","locations":[{"start":{"line":107,"column":6},"end":{"line":107,"column":27}},{"start":{"line":107,"column":31},"end":{"line":107,"column":50}}]},"12":{"loc":{"start":{"line":112,"column":2},"end":{"line":112,"column":81}},"type":"if","locations":[{"start":{"line":112,"column":2},"end":{"line":112,"column":81}},{"start":{"line":112,"column":2},"end":{"line":112,"column":81}}]},"13":{"loc":{"start":{"line":113,"column":2},"end":{"line":138,"column":3}},"type":"if","locations":[{"start":{"line":113,"column":2},"end":{"line":138,"column":3}},{"start":{"line":113,"column":2},"end":{"line":138,"column":3}}]},"14":{"loc":{"start":{"line":116,"column":16},"end":{"line":116,"column":66}},"type":"cond-expr","locations":[{"start":{"line":116,"column":36},"end":{"line":116,"column":59}},{"start":{"line":116,"column":62},"end":{"line":116,"column":66}}]},"15":{"loc":{"start":{"line":121,"column":4},"end":{"line":131,"column":5}},"type":"if","locations":[{"start":{"line":121,"column":4},"end":{"line":131,"column":5}},{"start":{"line":121,"column":4},"end":{"line":131,"column":5}}]},"16":{"loc":{"start":{"line":121,"column":8},"end":{"line":121,"column":46}},"type":"binary-expr","locations":[{"start":{"line":121,"column":8},"end":{"line":121,"column":18}},{"start":{"line":121,"column":22},"end":{"line":121,"column":46}}]},"17":{"loc":{"start":{"line":123,"column":6},"end":{"line":127,"column":7}},"type":"if","locations":[{"start":{"line":123,"column":6},"end":{"line":127,"column":7}},{"start":{"line":123,"column":6},"end":{"line":127,"column":7}}]},"18":{"loc":{"start":{"line":125,"column":13},"end":{"line":127,"column":7}},"type":"if","locations":[{"start":{"line":125,"column":13},"end":{"line":127,"column":7}},{"start":{"line":125,"column":13},"end":{"line":127,"column":7}}]},"19":{"loc":{"start":{"line":128,"column":6},"end":{"line":130,"column":7}},"type":"if","locations":[{"start":{"line":128,"column":6},"end":{"line":130,"column":7}},{"start":{"line":128,"column":6},"end":{"line":130,"column":7}}]},"20":{"loc":{"start":{"line":136,"column":9},"end":{"line":138,"column":3}},"type":"if","locations":[{"start":{"line":136,"column":9},"end":{"line":138,"column":3}},{"start":{"line":136,"column":9},"end":{"line":138,"column":3}}]},"21":{"loc":{"start":{"line":136,"column":13},"end":{"line":136,"column":66}},"type":"binary-expr","locations":[{"start":{"line":136,"column":13},"end":{"line":136,"column":34}},{"start":{"line":136,"column":38},"end":{"line":136,"column":66}}]},"22":{"loc":{"start":{"line":148,"column":2},"end":{"line":148,"column":74}},"type":"if","locations":[{"start":{"line":148,"column":2},"end":{"line":148,"column":74}},{"start":{"line":148,"column":2},"end":{"line":148,"column":74}}]},"23":{"loc":{"start":{"line":148,"column":6},"end":{"line":148,"column":60}},"type":"binary-expr","locations":[{"start":{"line":148,"column":6},"end":{"line":148,"column":28}},{"start":{"line":148,"column":32},"end":{"line":148,"column":60}}]},"24":{"loc":{"start":{"line":154,"column":2},"end":{"line":161,"column":3}},"type":"if","locations":[{"start":{"line":154,"column":2},"end":{"line":161,"column":3}},{"start":{"line":154,"column":2},"end":{"line":161,"column":3}}]},"25":{"loc":{"start":{"line":170,"column":2},"end":{"line":174,"column":3}},"type":"if","locations":[{"start":{"line":170,"column":2},"end":{"line":174,"column":3}},{"start":{"line":170,"column":2},"end":{"line":174,"column":3}}]},"26":{"loc":{"start":{"line":170,"column":6},"end":{"line":170,"column":60}},"type":"binary-expr","locations":[{"start":{"line":170,"column":6},"end":{"line":170,"column":28}},{"start":{"line":170,"column":32},"end":{"line":170,"column":60}}]},"27":{"loc":{"start":{"line":185,"column":2},"end":{"line":211,"column":3}},"type":"if","locations":[{"start":{"line":185,"column":2},"end":{"line":211,"column":3}},{"start":{"line":185,"column":2},"end":{"line":211,"column":3}}]},"28":{"loc":{"start":{"line":185,"column":6},"end":{"line":185,"column":52}},"type":"binary-expr","locations":[{"start":{"line":185,"column":6},"end":{"line":185,"column":18}},{"start":{"line":185,"column":23},"end":{"line":185,"column":28}},{"start":{"line":185,"column":32},"end":{"line":185,"column":51}}]},"29":{"loc":{"start":{"line":186,"column":4},"end":{"line":210,"column":5}},"type":"if","locations":[{"start":{"line":186,"column":4},"end":{"line":210,"column":5}},{"start":{"line":186,"column":4},"end":{"line":210,"column":5}}]},"30":{"loc":{"start":{"line":191,"column":6},"end":{"line":199,"column":7}},"type":"if","locations":[{"start":{"line":191,"column":6},"end":{"line":199,"column":7}},{"start":{"line":191,"column":6},"end":{"line":199,"column":7}}]},"31":{"loc":{"start":{"line":192,"column":8},"end":{"line":196,"column":33}},"type":"binary-expr","locations":[{"start":{"line":192,"column":8},"end":{"line":192,"column":30}},{"start":{"line":193,"column":8},"end":{"line":193,"column":39}},{"start":{"line":194,"column":8},"end":{"line":194,"column":18}},{"start":{"line":195,"column":8},"end":{"line":195,"column":41}},{"start":{"line":196,"column":8},"end":{"line":196,"column":33}}]},"32":{"loc":{"start":{"line":206,"column":80},"end":{"line":206,"column":117}},"type":"cond-expr","locations":[{"start":{"line":206,"column":102},"end":{"line":206,"column":110}},{"start":{"line":206,"column":113},"end":{"line":206,"column":117}}]},"33":{"loc":{"start":{"line":208,"column":28},"end":{"line":208,"column":116}},"type":"cond-expr","locations":[{"start":{"line":208,"column":76},"end":{"line":208,"column":95}},{"start":{"line":208,"column":98},"end":{"line":208,"column":116}}]},"34":{"loc":{"start":{"line":208,"column":29},"end":{"line":208,"column":72}},"type":"binary-expr","locations":[{"start":{"line":208,"column":29},"end":{"line":208,"column":48}},{"start":{"line":208,"column":52},"end":{"line":208,"column":72}}]},"35":{"loc":{"start":{"line":218,"column":2},"end":{"line":241,"column":3}},"type":"if","locations":[{"start":{"line":218,"column":2},"end":{"line":241,"column":3}},{"start":{"line":218,"column":2},"end":{"line":241,"column":3}}]},"36":{"loc":{"start":{"line":228,"column":49},"end":{"line":228,"column":134}},"type":"binary-expr","locations":[{"start":{"line":228,"column":49},"end":{"line":228,"column":70}},{"start":{"line":228,"column":75},"end":{"line":228,"column":95}},{"start":{"line":228,"column":99},"end":{"line":228,"column":133}}]},"37":{"loc":{"start":{"line":230,"column":4},"end":{"line":232,"column":5}},"type":"if","locations":[{"start":{"line":230,"column":4},"end":{"line":232,"column":5}},{"start":{"line":230,"column":4},"end":{"line":232,"column":5}}]},"38":{"loc":{"start":{"line":230,"column":8},"end":{"line":230,"column":62}},"type":"binary-expr","locations":[{"start":{"line":230,"column":8},"end":{"line":230,"column":30}},{"start":{"line":230,"column":34},"end":{"line":230,"column":62}}]},"39":{"loc":{"start":{"line":234,"column":4},"end":{"line":238,"column":5}},"type":"if","locations":[{"start":{"line":234,"column":4},"end":{"line":238,"column":5}},{"start":{"line":234,"column":4},"end":{"line":238,"column":5}}]},"40":{"loc":{"start":{"line":236,"column":11},"end":{"line":238,"column":5}},"type":"if","locations":[{"start":{"line":236,"column":11},"end":{"line":238,"column":5}},{"start":{"line":236,"column":11},"end":{"line":238,"column":5}}]},"41":{"loc":{"start":{"line":236,"column":15},"end":{"line":236,"column":101}},"type":"binary-expr","locations":[{"start":{"line":236,"column":15},"end":{"line":236,"column":32}},{"start":{"line":236,"column":36},"end":{"line":236,"column":62}},{"start":{"line":236,"column":66},"end":{"line":236,"column":101}}]},"42":{"loc":{"start":{"line":240,"column":33},"end":{"line":240,"column":80}},"type":"cond-expr","locations":[{"start":{"line":240,"column":42},"end":{"line":240,"column":60}},{"start":{"line":240,"column":63},"end":{"line":240,"column":80}}]},"43":{"loc":{"start":{"line":245,"column":2},"end":{"line":245,"column":74}},"type":"if","locations":[{"start":{"line":245,"column":2},"end":{"line":245,"column":74}},{"start":{"line":245,"column":2},"end":{"line":245,"column":74}}]},"44":{"loc":{"start":{"line":245,"column":6},"end":{"line":245,"column":60}},"type":"binary-expr","locations":[{"start":{"line":245,"column":6},"end":{"line":245,"column":28}},{"start":{"line":245,"column":32},"end":{"line":245,"column":60}}]},"45":{"loc":{"start":{"line":246,"column":9},"end":{"line":246,"column":62}},"type":"binary-expr","locations":[{"start":{"line":246,"column":9},"end":{"line":246,"column":32}},{"start":{"line":246,"column":36},"end":{"line":246,"column":62}}]},"46":{"loc":{"start":{"line":265,"column":2},"end":{"line":267,"column":3}},"type":"if","locations":[{"start":{"line":265,"column":2},"end":{"line":267,"column":3}},{"start":{"line":265,"column":2},"end":{"line":267,"column":3}}]},"47":{"loc":{"start":{"line":265,"column":6},"end":{"line":265,"column":80}},"type":"binary-expr","locations":[{"start":{"line":265,"column":6},"end":{"line":265,"column":45}},{"start":{"line":265,"column":49},"end":{"line":265,"column":80}}]},"48":{"loc":{"start":{"line":269,"column":2},"end":{"line":271,"column":3}},"type":"if","locations":[{"start":{"line":269,"column":2},"end":{"line":271,"column":3}},{"start":{"line":269,"column":2},"end":{"line":271,"column":3}}]},"49":{"loc":{"start":{"line":269,"column":6},"end":{"line":269,"column":60}},"type":"binary-expr","locations":[{"start":{"line":269,"column":6},"end":{"line":269,"column":28}},{"start":{"line":269,"column":32},"end":{"line":269,"column":60}}]},"50":{"loc":{"start":{"line":278,"column":4},"end":{"line":317,"column":5}},"type":"if","locations":[{"start":{"line":278,"column":4},"end":{"line":317,"column":5}},{"start":{"line":278,"column":4},"end":{"line":317,"column":5}}]},"51":{"loc":{"start":{"line":278,"column":8},"end":{"line":278,"column":44}},"type":"binary-expr","locations":[{"start":{"line":278,"column":8},"end":{"line":278,"column":16}},{"start":{"line":278,"column":20},"end":{"line":278,"column":44}}]},"52":{"loc":{"start":{"line":283,"column":11},"end":{"line":317,"column":5}},"type":"if","locations":[{"start":{"line":283,"column":11},"end":{"line":317,"column":5}},{"start":{"line":283,"column":11},"end":{"line":317,"column":5}}]},"53":{"loc":{"start":{"line":289,"column":11},"end":{"line":317,"column":5}},"type":"if","locations":[{"start":{"line":289,"column":11},"end":{"line":317,"column":5}},{"start":{"line":289,"column":11},"end":{"line":317,"column":5}}]},"54":{"loc":{"start":{"line":296,"column":11},"end":{"line":317,"column":5}},"type":"if","locations":[{"start":{"line":296,"column":11},"end":{"line":317,"column":5}},{"start":{"line":296,"column":11},"end":{"line":317,"column":5}}]},"55":{"loc":{"start":{"line":296,"column":15},"end":{"line":296,"column":48}},"type":"binary-expr","locations":[{"start":{"line":296,"column":15},"end":{"line":296,"column":23}},{"start":{"line":296,"column":27},"end":{"line":296,"column":48}}]},"56":{"loc":{"start":{"line":297,"column":26},"end":{"line":297,"column":153}},"type":"binary-expr","locations":[{"start":{"line":297,"column":26},"end":{"line":297,"column":68}},{"start":{"line":297,"column":72},"end":{"line":297,"column":98}},{"start":{"line":297,"column":102},"end":{"line":297,"column":123}},{"start":{"line":297,"column":127},"end":{"line":297,"column":153}}]},"57":{"loc":{"start":{"line":305,"column":6},"end":{"line":309,"column":7}},"type":"if","locations":[{"start":{"line":305,"column":6},"end":{"line":309,"column":7}},{"start":{"line":305,"column":6},"end":{"line":309,"column":7}}]},"58":{"loc":{"start":{"line":305,"column":10},"end":{"line":305,"column":55}},"type":"binary-expr","locations":[{"start":{"line":305,"column":10},"end":{"line":305,"column":23}},{"start":{"line":305,"column":27},"end":{"line":305,"column":55}}]},"59":{"loc":{"start":{"line":310,"column":11},"end":{"line":317,"column":5}},"type":"if","locations":[{"start":{"line":310,"column":11},"end":{"line":317,"column":5}},{"start":{"line":310,"column":11},"end":{"line":317,"column":5}}]},"60":{"loc":{"start":{"line":326,"column":4},"end":{"line":331,"column":5}},"type":"if","locations":[{"start":{"line":326,"column":4},"end":{"line":331,"column":5}},{"start":{"line":326,"column":4},"end":{"line":331,"column":5}}]},"61":{"loc":{"start":{"line":330,"column":6},"end":{"line":330,"column":33}},"type":"if","locations":[{"start":{"line":330,"column":6},"end":{"line":330,"column":33}},{"start":{"line":330,"column":6},"end":{"line":330,"column":33}}]},"62":{"loc":{"start":{"line":334,"column":4},"end":{"line":336,"column":5}},"type":"if","locations":[{"start":{"line":334,"column":4},"end":{"line":336,"column":5}},{"start":{"line":334,"column":4},"end":{"line":336,"column":5}}]},"63":{"loc":{"start":{"line":334,"column":8},"end":{"line":334,"column":49}},"type":"binary-expr","locations":[{"start":{"line":334,"column":8},"end":{"line":334,"column":29}},{"start":{"line":334,"column":33},"end":{"line":334,"column":49}}]},"64":{"loc":{"start":{"line":338,"column":48},"end":{"line":338,"column":93}},"type":"cond-expr","locations":[{"start":{"line":338,"column":69},"end":{"line":338,"column":81}},{"start":{"line":338,"column":84},"end":{"line":338,"column":93}}]},"65":{"loc":{"start":{"line":342,"column":2},"end":{"line":344,"column":3}},"type":"if","locations":[{"start":{"line":342,"column":2},"end":{"line":344,"column":3}},{"start":{"line":342,"column":2},"end":{"line":344,"column":3}}]},"66":{"loc":{"start":{"line":342,"column":6},"end":{"line":342,"column":75}},"type":"binary-expr","locations":[{"start":{"line":342,"column":6},"end":{"line":342,"column":24}},{"start":{"line":342,"column":28},"end":{"line":342,"column":43}},{"start":{"line":342,"column":47},"end":{"line":342,"column":75}}]},"67":{"loc":{"start":{"line":372,"column":2},"end":{"line":503,"column":3}},"type":"switch","locations":[{"start":{"line":373,"column":4},"end":{"line":386,"column":44}},{"start":{"line":388,"column":4},"end":{"line":391,"column":53}},{"start":{"line":393,"column":4},"end":{"line":394,"column":52}},{"start":{"line":396,"column":4},"end":{"line":420,"column":16}},{"start":{"line":422,"column":4},"end":{"line":434,"column":7}},{"start":{"line":436,"column":4},"end":{"line":441,"column":18}},{"start":{"line":443,"column":4},"end":{"line":444,"column":67}},{"start":{"line":446,"column":4},"end":{"line":447,"column":66}},{"start":{"line":449,"column":4},"end":{"line":452,"column":50}},{"start":{"line":454,"column":4},"end":{"line":454,"column":18}},{"start":{"line":454,"column":19},"end":{"line":458,"column":53}},{"start":{"line":460,"column":4},"end":{"line":461,"column":77}},{"start":{"line":463,"column":4},"end":{"line":468,"column":54}},{"start":{"line":470,"column":4},"end":{"line":471,"column":58}},{"start":{"line":473,"column":4},"end":{"line":474,"column":44}},{"start":{"line":476,"column":4},"end":{"line":477,"column":29}},{"start":{"line":479,"column":4},"end":{"line":482,"column":42}},{"start":{"line":484,"column":4},"end":{"line":485,"column":29}},{"start":{"line":487,"column":4},"end":{"line":488,"column":34}},{"start":{"line":490,"column":4},"end":{"line":499,"column":7}},{"start":{"line":501,"column":4},"end":{"line":502,"column":24}}]},"68":{"loc":{"start":{"line":374,"column":6},"end":{"line":376,"column":7}},"type":"if","locations":[{"start":{"line":374,"column":6},"end":{"line":376,"column":7}},{"start":{"line":374,"column":6},"end":{"line":376,"column":7}}]},"69":{"loc":{"start":{"line":374,"column":10},"end":{"line":374,"column":71}},"type":"binary-expr","locations":[{"start":{"line":374,"column":10},"end":{"line":374,"column":30}},{"start":{"line":374,"column":34},"end":{"line":374,"column":71}}]},"70":{"loc":{"start":{"line":380,"column":6},"end":{"line":382,"column":7}},"type":"if","locations":[{"start":{"line":380,"column":6},"end":{"line":382,"column":7}},{"start":{"line":380,"column":6},"end":{"line":382,"column":7}}]},"71":{"loc":{"start":{"line":380,"column":10},"end":{"line":380,"column":83}},"type":"binary-expr","locations":[{"start":{"line":380,"column":10},"end":{"line":380,"column":32}},{"start":{"line":380,"column":36},"end":{"line":380,"column":60}},{"start":{"line":380,"column":64},"end":{"line":380,"column":83}}]},"72":{"loc":{"start":{"line":383,"column":6},"end":{"line":385,"column":7}},"type":"if","locations":[{"start":{"line":383,"column":6},"end":{"line":385,"column":7}},{"start":{"line":383,"column":6},"end":{"line":385,"column":7}}]},"73":{"loc":{"start":{"line":383,"column":10},"end":{"line":383,"column":113}},"type":"binary-expr","locations":[{"start":{"line":383,"column":10},"end":{"line":383,"column":31}},{"start":{"line":383,"column":35},"end":{"line":383,"column":72}},{"start":{"line":383,"column":76},"end":{"line":383,"column":113}}]},"74":{"loc":{"start":{"line":394,"column":6},"end":{"line":394,"column":52}},"type":"if","locations":[{"start":{"line":394,"column":6},"end":{"line":394,"column":52}},{"start":{"line":394,"column":6},"end":{"line":394,"column":52}}]},"75":{"loc":{"start":{"line":398,"column":23},"end":{"line":398,"column":73}},"type":"binary-expr","locations":[{"start":{"line":398,"column":23},"end":{"line":398,"column":51}},{"start":{"line":398,"column":55},"end":{"line":398,"column":73}}]},"76":{"loc":{"start":{"line":400,"column":36},"end":{"line":400,"column":60}},"type":"binary-expr","locations":[{"start":{"line":400,"column":36},"end":{"line":400,"column":46}},{"start":{"line":400,"column":50},"end":{"line":400,"column":60}}]},"77":{"loc":{"start":{"line":402,"column":6},"end":{"line":414,"column":7}},"type":"if","locations":[{"start":{"line":402,"column":6},"end":{"line":414,"column":7}},{"start":{"line":402,"column":6},"end":{"line":414,"column":7}}]},"78":{"loc":{"start":{"line":403,"column":8},"end":{"line":405,"column":9}},"type":"if","locations":[{"start":{"line":403,"column":8},"end":{"line":405,"column":9}},{"start":{"line":403,"column":8},"end":{"line":405,"column":9}}]},"79":{"loc":{"start":{"line":403,"column":12},"end":{"line":403,"column":47}},"type":"binary-expr","locations":[{"start":{"line":403,"column":12},"end":{"line":403,"column":30}},{"start":{"line":403,"column":34},"end":{"line":403,"column":47}}]},"80":{"loc":{"start":{"line":406,"column":13},"end":{"line":414,"column":7}},"type":"if","locations":[{"start":{"line":406,"column":13},"end":{"line":414,"column":7}},{"start":{"line":406,"column":13},"end":{"line":414,"column":7}}]},"81":{"loc":{"start":{"line":406,"column":17},"end":{"line":406,"column":94}},"type":"binary-expr","locations":[{"start":{"line":406,"column":17},"end":{"line":406,"column":36}},{"start":{"line":406,"column":40},"end":{"line":406,"column":64}},{"start":{"line":406,"column":68},"end":{"line":406,"column":94}}]},"82":{"loc":{"start":{"line":409,"column":13},"end":{"line":414,"column":7}},"type":"if","locations":[{"start":{"line":409,"column":13},"end":{"line":414,"column":7}},{"start":{"line":409,"column":13},"end":{"line":414,"column":7}}]},"83":{"loc":{"start":{"line":409,"column":17},"end":{"line":409,"column":73}},"type":"binary-expr","locations":[{"start":{"line":409,"column":17},"end":{"line":409,"column":27}},{"start":{"line":409,"column":31},"end":{"line":409,"column":50}},{"start":{"line":409,"column":54},"end":{"line":409,"column":73}}]},"84":{"loc":{"start":{"line":416,"column":6},"end":{"line":418,"column":7}},"type":"if","locations":[{"start":{"line":416,"column":6},"end":{"line":418,"column":7}},{"start":{"line":416,"column":6},"end":{"line":418,"column":7}}]},"85":{"loc":{"start":{"line":416,"column":10},"end":{"line":416,"column":72}},"type":"binary-expr","locations":[{"start":{"line":416,"column":10},"end":{"line":416,"column":20}},{"start":{"line":416,"column":24},"end":{"line":416,"column":50}},{"start":{"line":416,"column":54},"end":{"line":416,"column":72}}]},"86":{"loc":{"start":{"line":423,"column":6},"end":{"line":434,"column":7}},"type":"if","locations":[{"start":{"line":423,"column":6},"end":{"line":434,"column":7}},{"start":{"line":423,"column":6},"end":{"line":434,"column":7}}]},"87":{"loc":{"start":{"line":495,"column":6},"end":{"line":499,"column":7}},"type":"if","locations":[{"start":{"line":495,"column":6},"end":{"line":499,"column":7}},{"start":{"line":495,"column":6},"end":{"line":499,"column":7}}]},"88":{"loc":{"start":{"line":509,"column":2},"end":{"line":513,"column":3}},"type":"if","locations":[{"start":{"line":509,"column":2},"end":{"line":513,"column":3}},{"start":{"line":509,"column":2},"end":{"line":513,"column":3}}]},"89":{"loc":{"start":{"line":509,"column":6},"end":{"line":509,"column":82}},"type":"binary-expr","locations":[{"start":{"line":509,"column":6},"end":{"line":509,"column":28}},{"start":{"line":509,"column":32},"end":{"line":509,"column":48}},{"start":{"line":509,"column":52},"end":{"line":509,"column":82}}]},"90":{"loc":{"start":{"line":520,"column":2},"end":{"line":522,"column":3}},"type":"if","locations":[{"start":{"line":520,"column":2},"end":{"line":522,"column":3}},{"start":{"line":520,"column":2},"end":{"line":522,"column":3}}]},"91":{"loc":{"start":{"line":544,"column":13},"end":{"line":544,"column":41}},"type":"binary-expr","locations":[{"start":{"line":544,"column":13},"end":{"line":544,"column":21}},{"start":{"line":544,"column":25},"end":{"line":544,"column":41}}]},"92":{"loc":{"start":{"line":545,"column":13},"end":{"line":545,"column":44}},"type":"binary-expr","locations":[{"start":{"line":545,"column":13},"end":{"line":545,"column":21}},{"start":{"line":545,"column":25},"end":{"line":545,"column":44}}]},"93":{"loc":{"start":{"line":555,"column":4},"end":{"line":563,"column":5}},"type":"if","locations":[{"start":{"line":555,"column":4},"end":{"line":563,"column":5}},{"start":{"line":555,"column":4},"end":{"line":563,"column":5}}]},"94":{"loc":{"start":{"line":558,"column":28},"end":{"line":558,"column":58}},"type":"binary-expr","locations":[{"start":{"line":558,"column":28},"end":{"line":558,"column":50}},{"start":{"line":558,"column":54},"end":{"line":558,"column":58}}]},"95":{"loc":{"start":{"line":559,"column":6},"end":{"line":562,"column":7}},"type":"if","locations":[{"start":{"line":559,"column":6},"end":{"line":562,"column":7}},{"start":{"line":559,"column":6},"end":{"line":562,"column":7}}]},"96":{"loc":{"start":{"line":565,"column":4},"end":{"line":572,"column":5}},"type":"if","locations":[{"start":{"line":565,"column":4},"end":{"line":572,"column":5}},{"start":{"line":565,"column":4},"end":{"line":572,"column":5}}]},"97":{"loc":{"start":{"line":580,"column":2},"end":{"line":586,"column":3}},"type":"if","locations":[{"start":{"line":580,"column":2},"end":{"line":586,"column":3}},{"start":{"line":580,"column":2},"end":{"line":586,"column":3}}]},"98":{"loc":{"start":{"line":580,"column":6},"end":{"line":580,"column":90}},"type":"binary-expr","locations":[{"start":{"line":580,"column":6},"end":{"line":580,"column":16}},{"start":{"line":580,"column":20},"end":{"line":580,"column":46}},{"start":{"line":580,"column":51},"end":{"line":580,"column":89}}]},"99":{"loc":{"start":{"line":582,"column":6},"end":{"line":582,"column":92}},"type":"if","locations":[{"start":{"line":582,"column":6},"end":{"line":582,"column":92}},{"start":{"line":582,"column":6},"end":{"line":582,"column":92}}]},"100":{"loc":{"start":{"line":582,"column":10},"end":{"line":582,"column":50}},"type":"binary-expr","locations":[{"start":{"line":582,"column":10},"end":{"line":582,"column":21}},{"start":{"line":582,"column":25},"end":{"line":582,"column":50}}]},"101":{"loc":{"start":{"line":588,"column":2},"end":{"line":594,"column":3}},"type":"if","locations":[{"start":{"line":588,"column":2},"end":{"line":594,"column":3}},{"start":{"line":588,"column":2},"end":{"line":594,"column":3}}]},"102":{"loc":{"start":{"line":589,"column":4},"end":{"line":593,"column":5}},"type":"if","locations":[{"start":{"line":589,"column":4},"end":{"line":593,"column":5}},{"start":{"line":589,"column":4},"end":{"line":593,"column":5}}]},"103":{"loc":{"start":{"line":595,"column":2},"end":{"line":595,"column":62}},"type":"if","locations":[{"start":{"line":595,"column":2},"end":{"line":595,"column":62}},{"start":{"line":595,"column":2},"end":{"line":595,"column":62}}]},"104":{"loc":{"start":{"line":596,"column":2},"end":{"line":596,"column":48}},"type":"if","locations":[{"start":{"line":596,"column":2},"end":{"line":596,"column":48}},{"start":{"line":596,"column":2},"end":{"line":596,"column":48}}]},"105":{"loc":{"start":{"line":597,"column":2},"end":{"line":597,"column":82}},"type":"if","locations":[{"start":{"line":597,"column":2},"end":{"line":597,"column":82}},{"start":{"line":597,"column":2},"end":{"line":597,"column":82}}]},"106":{"loc":{"start":{"line":598,"column":2},"end":{"line":598,"column":70}},"type":"if","locations":[{"start":{"line":598,"column":2},"end":{"line":598,"column":70}},{"start":{"line":598,"column":2},"end":{"line":598,"column":70}}]},"107":{"loc":{"start":{"line":600,"column":2},"end":{"line":607,"column":3}},"type":"if","locations":[{"start":{"line":600,"column":2},"end":{"line":607,"column":3}},{"start":{"line":600,"column":2},"end":{"line":607,"column":3}}]},"108":{"loc":{"start":{"line":617,"column":2},"end":{"line":619,"column":3}},"type":"if","locations":[{"start":{"line":617,"column":2},"end":{"line":619,"column":3}},{"start":{"line":617,"column":2},"end":{"line":619,"column":3}}]},"109":{"loc":{"start":{"line":634,"column":2},"end":{"line":636,"column":3}},"type":"if","locations":[{"start":{"line":634,"column":2},"end":{"line":636,"column":3}},{"start":{"line":634,"column":2},"end":{"line":636,"column":3}}]},"110":{"loc":{"start":{"line":640,"column":2},"end":{"line":645,"column":3}},"type":"if","locations":[{"start":{"line":640,"column":2},"end":{"line":645,"column":3}},{"start":{"line":640,"column":2},"end":{"line":645,"column":3}}]},"111":{"loc":{"start":{"line":691,"column":4},"end":{"line":696,"column":5}},"type":"if","locations":[{"start":{"line":691,"column":4},"end":{"line":696,"column":5}},{"start":{"line":691,"column":4},"end":{"line":696,"column":5}}]},"112":{"loc":{"start":{"line":695,"column":6},"end":{"line":695,"column":37}},"type":"if","locations":[{"start":{"line":695,"column":6},"end":{"line":695,"column":37}},{"start":{"line":695,"column":6},"end":{"line":695,"column":37}}]},"113":{"loc":{"start":{"line":703,"column":4},"end":{"line":706,"column":5}},"type":"if","locations":[{"start":{"line":703,"column":4},"end":{"line":706,"column":5}},{"start":{"line":703,"column":4},"end":{"line":706,"column":5}}]},"114":{"loc":{"start":{"line":708,"column":4},"end":{"line":713,"column":5}},"type":"if","locations":[{"start":{"line":708,"column":4},"end":{"line":713,"column":5}},{"start":{"line":708,"column":4},"end":{"line":713,"column":5}}]},"115":{"loc":{"start":{"line":708,"column":8},"end":{"line":708,"column":69}},"type":"binary-expr","locations":[{"start":{"line":708,"column":8},"end":{"line":708,"column":42}},{"start":{"line":708,"column":46},"end":{"line":708,"column":69}}]},"116":{"loc":{"start":{"line":710,"column":18},"end":{"line":710,"column":63}},"type":"cond-expr","locations":[{"start":{"line":710,"column":30},"end":{"line":710,"column":44}},{"start":{"line":710,"column":47},"end":{"line":710,"column":63}}]},"117":{"loc":{"start":{"line":718,"column":4},"end":{"line":721,"column":5}},"type":"if","locations":[{"start":{"line":718,"column":4},"end":{"line":721,"column":5}},{"start":{"line":718,"column":4},"end":{"line":721,"column":5}}]},"118":{"loc":{"start":{"line":718,"column":8},"end":{"line":718,"column":43}},"type":"binary-expr","locations":[{"start":{"line":718,"column":8},"end":{"line":718,"column":17}},{"start":{"line":718,"column":21},"end":{"line":718,"column":43}}]},"119":{"loc":{"start":{"line":723,"column":4},"end":{"line":725,"column":5}},"type":"if","locations":[{"start":{"line":723,"column":4},"end":{"line":725,"column":5}},{"start":{"line":723,"column":4},"end":{"line":725,"column":5}}]},"120":{"loc":{"start":{"line":727,"column":4},"end":{"line":740,"column":5}},"type":"if","locations":[{"start":{"line":727,"column":4},"end":{"line":740,"column":5}},{"start":{"line":727,"column":4},"end":{"line":740,"column":5}}]},"121":{"loc":{"start":{"line":727,"column":8},"end":{"line":727,"column":48}},"type":"binary-expr","locations":[{"start":{"line":727,"column":8},"end":{"line":727,"column":18}},{"start":{"line":727,"column":22},"end":{"line":727,"column":48}}]},"122":{"loc":{"start":{"line":728,"column":6},"end":{"line":728,"column":41}},"type":"if","locations":[{"start":{"line":728,"column":6},"end":{"line":728,"column":41}},{"start":{"line":728,"column":6},"end":{"line":728,"column":41}}]},"123":{"loc":{"start":{"line":731,"column":6},"end":{"line":737,"column":7}},"type":"if","locations":[{"start":{"line":731,"column":6},"end":{"line":737,"column":7}},{"start":{"line":731,"column":6},"end":{"line":737,"column":7}}]},"124":{"loc":{"start":{"line":731,"column":10},"end":{"line":731,"column":80}},"type":"binary-expr","locations":[{"start":{"line":731,"column":10},"end":{"line":731,"column":30}},{"start":{"line":731,"column":34},"end":{"line":731,"column":55}},{"start":{"line":731,"column":59},"end":{"line":731,"column":80}}]},"125":{"loc":{"start":{"line":735,"column":8},"end":{"line":735,"column":79}},"type":"if","locations":[{"start":{"line":735,"column":8},"end":{"line":735,"column":79}},{"start":{"line":735,"column":8},"end":{"line":735,"column":79}}]},"126":{"loc":{"start":{"line":745,"column":4},"end":{"line":747,"column":5}},"type":"if","locations":[{"start":{"line":745,"column":4},"end":{"line":747,"column":5}},{"start":{"line":745,"column":4},"end":{"line":747,"column":5}}]},"127":{"loc":{"start":{"line":752,"column":2},"end":{"line":754,"column":3}},"type":"if","locations":[{"start":{"line":752,"column":2},"end":{"line":754,"column":3}},{"start":{"line":752,"column":2},"end":{"line":754,"column":3}}]},"128":{"loc":{"start":{"line":756,"column":31},"end":{"line":756,"column":79}},"type":"cond-expr","locations":[{"start":{"line":756,"column":43},"end":{"line":756,"column":58}},{"start":{"line":756,"column":61},"end":{"line":756,"column":79}}]},"129":{"loc":{"start":{"line":760,"column":2},"end":{"line":766,"column":3}},"type":"if","locations":[{"start":{"line":760,"column":2},"end":{"line":766,"column":3}},{"start":{"line":760,"column":2},"end":{"line":766,"column":3}}]},"130":{"loc":{"start":{"line":760,"column":6},"end":{"line":760,"column":53}},"type":"binary-expr","locations":[{"start":{"line":760,"column":6},"end":{"line":760,"column":13}},{"start":{"line":760,"column":17},"end":{"line":760,"column":28}},{"start":{"line":760,"column":32},"end":{"line":760,"column":53}}]},"131":{"loc":{"start":{"line":761,"column":4},"end":{"line":761,"column":37}},"type":"if","locations":[{"start":{"line":761,"column":4},"end":{"line":761,"column":37}},{"start":{"line":761,"column":4},"end":{"line":761,"column":37}}]},"132":{"loc":{"start":{"line":768,"column":2},"end":{"line":771,"column":3}},"type":"if","locations":[{"start":{"line":768,"column":2},"end":{"line":771,"column":3}},{"start":{"line":768,"column":2},"end":{"line":771,"column":3}}]},"133":{"loc":{"start":{"line":769,"column":17},"end":{"line":769,"column":145}},"type":"cond-expr","locations":[{"start":{"line":769,"column":29},"end":{"line":769,"column":90}},{"start":{"line":769,"column":93},"end":{"line":769,"column":145}}]},"134":{"loc":{"start":{"line":773,"column":2},"end":{"line":788,"column":3}},"type":"if","locations":[{"start":{"line":773,"column":2},"end":{"line":788,"column":3}},{"start":{"line":773,"column":2},"end":{"line":788,"column":3}}]},"135":{"loc":{"start":{"line":773,"column":6},"end":{"line":773,"column":163}},"type":"binary-expr","locations":[{"start":{"line":773,"column":6},"end":{"line":773,"column":20}},{"start":{"line":773,"column":24},"end":{"line":773,"column":54}},{"start":{"line":773,"column":59},"end":{"line":773,"column":82}},{"start":{"line":773,"column":86},"end":{"line":773,"column":109}},{"start":{"line":773,"column":115},"end":{"line":773,"column":136}},{"start":{"line":773,"column":140},"end":{"line":773,"column":162}}]},"136":{"loc":{"start":{"line":774,"column":4},"end":{"line":774,"column":63}},"type":"if","locations":[{"start":{"line":774,"column":4},"end":{"line":774,"column":63}},{"start":{"line":774,"column":4},"end":{"line":774,"column":63}}]},"137":{"loc":{"start":{"line":774,"column":8},"end":{"line":774,"column":43}},"type":"binary-expr","locations":[{"start":{"line":774,"column":8},"end":{"line":774,"column":19}},{"start":{"line":774,"column":23},"end":{"line":774,"column":30}},{"start":{"line":774,"column":34},"end":{"line":774,"column":43}}]},"138":{"loc":{"start":{"line":778,"column":21},"end":{"line":778,"column":48}},"type":"cond-expr","locations":[{"start":{"line":778,"column":43},"end":{"line":778,"column":44}},{"start":{"line":778,"column":47},"end":{"line":778,"column":48}}]},"139":{"loc":{"start":{"line":779,"column":4},"end":{"line":786,"column":5}},"type":"if","locations":[{"start":{"line":779,"column":4},"end":{"line":786,"column":5}},{"start":{"line":779,"column":4},"end":{"line":786,"column":5}}]},"140":{"loc":{"start":{"line":781,"column":6},"end":{"line":785,"column":7}},"type":"if","locations":[{"start":{"line":781,"column":6},"end":{"line":785,"column":7}},{"start":{"line":781,"column":6},"end":{"line":785,"column":7}}]},"141":{"loc":{"start":{"line":790,"column":2},"end":{"line":810,"column":3}},"type":"if","locations":[{"start":{"line":790,"column":2},"end":{"line":810,"column":3}},{"start":{"line":790,"column":2},"end":{"line":810,"column":3}}]},"142":{"loc":{"start":{"line":790,"column":6},"end":{"line":790,"column":54}},"type":"binary-expr","locations":[{"start":{"line":790,"column":6},"end":{"line":790,"column":20}},{"start":{"line":790,"column":24},"end":{"line":790,"column":54}}]},"143":{"loc":{"start":{"line":791,"column":4},"end":{"line":807,"column":5}},"type":"if","locations":[{"start":{"line":791,"column":4},"end":{"line":807,"column":5}},{"start":{"line":791,"column":4},"end":{"line":807,"column":5}}]},"144":{"loc":{"start":{"line":793,"column":6},"end":{"line":795,"column":7}},"type":"if","locations":[{"start":{"line":793,"column":6},"end":{"line":795,"column":7}},{"start":{"line":793,"column":6},"end":{"line":795,"column":7}}]},"145":{"loc":{"start":{"line":793,"column":10},"end":{"line":793,"column":46}},"type":"binary-expr","locations":[{"start":{"line":793,"column":10},"end":{"line":793,"column":25}},{"start":{"line":793,"column":29},"end":{"line":793,"column":46}}]},"146":{"loc":{"start":{"line":794,"column":25},"end":{"line":794,"column":103}},"type":"binary-expr","locations":[{"start":{"line":794,"column":25},"end":{"line":794,"column":64}},{"start":{"line":794,"column":68},"end":{"line":794,"column":103}}]},"147":{"loc":{"start":{"line":796,"column":6},"end":{"line":798,"column":7}},"type":"if","locations":[{"start":{"line":796,"column":6},"end":{"line":798,"column":7}},{"start":{"line":796,"column":6},"end":{"line":798,"column":7}}]},"148":{"loc":{"start":{"line":800,"column":11},"end":{"line":807,"column":5}},"type":"if","locations":[{"start":{"line":800,"column":11},"end":{"line":807,"column":5}},{"start":{"line":800,"column":11},"end":{"line":807,"column":5}}]},"149":{"loc":{"start":{"line":800,"column":15},"end":{"line":800,"column":58}},"type":"binary-expr","locations":[{"start":{"line":800,"column":15},"end":{"line":800,"column":32}},{"start":{"line":800,"column":36},"end":{"line":800,"column":58}}]},"150":{"loc":{"start":{"line":801,"column":6},"end":{"line":803,"column":7}},"type":"if","locations":[{"start":{"line":801,"column":6},"end":{"line":803,"column":7}},{"start":{"line":801,"column":6},"end":{"line":803,"column":7}}]},"151":{"loc":{"start":{"line":816,"column":2},"end":{"line":824,"column":3}},"type":"if","locations":[{"start":{"line":816,"column":2},"end":{"line":824,"column":3}},{"start":{"line":816,"column":2},"end":{"line":824,"column":3}}]},"152":{"loc":{"start":{"line":823,"column":22},"end":{"line":823,"column":119}},"type":"cond-expr","locations":[{"start":{"line":823,"column":70},"end":{"line":823,"column":90}},{"start":{"line":823,"column":93},"end":{"line":823,"column":119}}]},"153":{"loc":{"start":{"line":823,"column":23},"end":{"line":823,"column":66}},"type":"binary-expr","locations":[{"start":{"line":823,"column":23},"end":{"line":823,"column":41}},{"start":{"line":823,"column":45},"end":{"line":823,"column":66}}]},"154":{"loc":{"start":{"line":840,"column":24},"end":{"line":840,"column":41}},"type":"binary-expr","locations":[{"start":{"line":840,"column":24},"end":{"line":840,"column":33}},{"start":{"line":840,"column":37},"end":{"line":840,"column":41}}]},"155":{"loc":{"start":{"line":862,"column":21},"end":{"line":862,"column":62}},"type":"binary-expr","locations":[{"start":{"line":862,"column":21},"end":{"line":862,"column":36}},{"start":{"line":862,"column":40},"end":{"line":862,"column":62}}]},"156":{"loc":{"start":{"line":866,"column":2},"end":{"line":877,"column":3}},"type":"if","locations":[{"start":{"line":866,"column":2},"end":{"line":877,"column":3}},{"start":{"line":866,"column":2},"end":{"line":877,"column":3}}]},"157":{"loc":{"start":{"line":888,"column":2},"end":{"line":888,"column":40}},"type":"if","locations":[{"start":{"line":888,"column":2},"end":{"line":888,"column":40}},{"start":{"line":888,"column":2},"end":{"line":888,"column":40}}]},"158":{"loc":{"start":{"line":891,"column":2},"end":{"line":900,"column":3}},"type":"if","locations":[{"start":{"line":891,"column":2},"end":{"line":900,"column":3}},{"start":{"line":891,"column":2},"end":{"line":900,"column":3}}]},"159":{"loc":{"start":{"line":891,"column":6},"end":{"line":891,"column":50}},"type":"binary-expr","locations":[{"start":{"line":891,"column":6},"end":{"line":891,"column":19}},{"start":{"line":891,"column":23},"end":{"line":891,"column":50}}]},"160":{"loc":{"start":{"line":893,"column":6},"end":{"line":898,"column":7}},"type":"if","locations":[{"start":{"line":893,"column":6},"end":{"line":898,"column":7}},{"start":{"line":893,"column":6},"end":{"line":898,"column":7}}]},"161":{"loc":{"start":{"line":903,"column":2},"end":{"line":905,"column":3}},"type":"if","locations":[{"start":{"line":903,"column":2},"end":{"line":905,"column":3}},{"start":{"line":903,"column":2},"end":{"line":905,"column":3}}]},"162":{"loc":{"start":{"line":903,"column":6},"end":{"line":903,"column":86}},"type":"binary-expr","locations":[{"start":{"line":903,"column":6},"end":{"line":903,"column":14}},{"start":{"line":903,"column":18},"end":{"line":903,"column":25}},{"start":{"line":903,"column":29},"end":{"line":903,"column":58}},{"start":{"line":903,"column":62},"end":{"line":903,"column":86}}]},"163":{"loc":{"start":{"line":907,"column":2},"end":{"line":918,"column":3}},"type":"if","locations":[{"start":{"line":907,"column":2},"end":{"line":918,"column":3}},{"start":{"line":907,"column":2},"end":{"line":918,"column":3}}]},"164":{"loc":{"start":{"line":910,"column":4},"end":{"line":910,"column":50}},"type":"if","locations":[{"start":{"line":910,"column":4},"end":{"line":910,"column":50}},{"start":{"line":910,"column":4},"end":{"line":910,"column":50}}]},"165":{"loc":{"start":{"line":911,"column":4},"end":{"line":913,"column":5}},"type":"if","locations":[{"start":{"line":911,"column":4},"end":{"line":913,"column":5}},{"start":{"line":911,"column":4},"end":{"line":913,"column":5}}]},"166":{"loc":{"start":{"line":930,"column":4},"end":{"line":935,"column":5}},"type":"if","locations":[{"start":{"line":930,"column":4},"end":{"line":935,"column":5}},{"start":{"line":930,"column":4},"end":{"line":935,"column":5}}]},"167":{"loc":{"start":{"line":934,"column":6},"end":{"line":934,"column":33}},"type":"if","locations":[{"start":{"line":934,"column":6},"end":{"line":934,"column":33}},{"start":{"line":934,"column":6},"end":{"line":934,"column":33}}]},"168":{"loc":{"start":{"line":944,"column":2},"end":{"line":950,"column":3}},"type":"if","locations":[{"start":{"line":944,"column":2},"end":{"line":950,"column":3}},{"start":{"line":944,"column":2},"end":{"line":950,"column":3}}]},"169":{"loc":{"start":{"line":944,"column":6},"end":{"line":944,"column":40}},"type":"binary-expr","locations":[{"start":{"line":944,"column":6},"end":{"line":944,"column":16}},{"start":{"line":944,"column":20},"end":{"line":944,"column":40}}]},"170":{"loc":{"start":{"line":946,"column":9},"end":{"line":950,"column":3}},"type":"if","locations":[{"start":{"line":946,"column":9},"end":{"line":950,"column":3}},{"start":{"line":946,"column":9},"end":{"line":950,"column":3}}]},"171":{"loc":{"start":{"line":961,"column":2},"end":{"line":971,"column":3}},"type":"if","locations":[{"start":{"line":961,"column":2},"end":{"line":971,"column":3}},{"start":{"line":961,"column":2},"end":{"line":971,"column":3}}]},"172":{"loc":{"start":{"line":962,"column":4},"end":{"line":964,"column":5}},"type":"if","locations":[{"start":{"line":962,"column":4},"end":{"line":964,"column":5}},{"start":{"line":962,"column":4},"end":{"line":964,"column":5}}]},"173":{"loc":{"start":{"line":962,"column":8},"end":{"line":962,"column":79}},"type":"binary-expr","locations":[{"start":{"line":962,"column":8},"end":{"line":962,"column":16}},{"start":{"line":962,"column":20},"end":{"line":962,"column":37}},{"start":{"line":962,"column":41},"end":{"line":962,"column":79}}]},"174":{"loc":{"start":{"line":967,"column":9},"end":{"line":971,"column":3}},"type":"if","locations":[{"start":{"line":967,"column":9},"end":{"line":971,"column":3}},{"start":{"line":967,"column":9},"end":{"line":971,"column":3}}]},"175":{"loc":{"start":{"line":967,"column":13},"end":{"line":967,"column":47}},"type":"binary-expr","locations":[{"start":{"line":967,"column":13},"end":{"line":967,"column":20}},{"start":{"line":967,"column":24},"end":{"line":967,"column":47}}]},"176":{"loc":{"start":{"line":973,"column":2},"end":{"line":975,"column":3}},"type":"if","locations":[{"start":{"line":973,"column":2},"end":{"line":975,"column":3}},{"start":{"line":973,"column":2},"end":{"line":975,"column":3}}]},"177":{"loc":{"start":{"line":973,"column":6},"end":{"line":973,"column":61}},"type":"binary-expr","locations":[{"start":{"line":973,"column":6},"end":{"line":973,"column":14}},{"start":{"line":973,"column":18},"end":{"line":973,"column":39}},{"start":{"line":973,"column":43},"end":{"line":973,"column":61}}]},"178":{"loc":{"start":{"line":986,"column":2},"end":{"line":988,"column":3}},"type":"if","locations":[{"start":{"line":986,"column":2},"end":{"line":988,"column":3}},{"start":{"line":986,"column":2},"end":{"line":988,"column":3}}]},"179":{"loc":{"start":{"line":989,"column":2},"end":{"line":991,"column":3}},"type":"if","locations":[{"start":{"line":989,"column":2},"end":{"line":991,"column":3}},{"start":{"line":989,"column":2},"end":{"line":991,"column":3}}]},"180":{"loc":{"start":{"line":1001,"column":2},"end":{"line":1007,"column":3}},"type":"if","locations":[{"start":{"line":1001,"column":2},"end":{"line":1007,"column":3}},{"start":{"line":1001,"column":2},"end":{"line":1007,"column":3}}]},"181":{"loc":{"start":{"line":1001,"column":6},"end":{"line":1001,"column":111}},"type":"binary-expr","locations":[{"start":{"line":1001,"column":6},"end":{"line":1001,"column":25}},{"start":{"line":1001,"column":29},"end":{"line":1001,"column":54}},{"start":{"line":1001,"column":59},"end":{"line":1001,"column":79}},{"start":{"line":1001,"column":83},"end":{"line":1001,"column":110}}]}},"s":{"1":1,"2":1,"3":263,"4":15,"5":248,"6":248,"7":235,"8":235,"9":13,"10":13,"11":0,"12":248,"13":0,"14":0,"15":0,"16":1,"17":1793,"18":1793,"19":1793,"20":1487,"21":8,"22":8,"23":8,"24":10,"25":7,"26":7,"27":1479,"28":1,"29":3228,"30":34,"31":3194,"32":634,"33":2560,"34":2560,"35":3194,"36":3194,"37":3194,"38":1593,"39":3194,"40":2780,"41":520,"42":2780,"43":209,"44":209,"45":209,"46":190,"47":190,"48":178,"49":2,"50":1,"51":1,"52":1,"53":2,"54":2,"55":176,"56":176,"57":169,"58":2571,"59":3,"60":2568,"61":1,"62":3194,"63":3194,"64":3194,"65":2787,"66":53,"67":2734,"68":1,"69":2734,"70":21,"71":21,"72":21,"73":17,"74":17,"75":9,"76":2713,"77":1,"78":3194,"79":3194,"80":3194,"81":2800,"82":53,"83":2747,"84":1,"85":3205,"86":3205,"87":277,"88":242,"89":242,"90":242,"91":242,"92":3,"93":239,"94":239,"95":238,"96":238,"97":238,"98":229,"99":229,"100":2963,"101":1,"102":3503,"103":61,"104":61,"105":61,"106":61,"107":61,"108":61,"109":61,"110":58,"111":58,"112":0,"113":58,"114":28,"115":30,"116":2,"117":44,"118":3442,"119":3442,"120":3442,"121":3065,"122":53,"123":3012,"124":39,"125":39,"126":39,"127":39,"128":39,"129":27,"130":27,"131":3000,"132":1,"133":3466,"134":3466,"135":3466,"136":3466,"137":3100,"138":101,"139":2999,"140":53,"141":2946,"142":1,"143":2981,"144":3246,"145":0,"146":0,"147":0,"148":0,"149":3246,"150":74,"151":74,"152":74,"153":72,"154":72,"155":3172,"156":18,"157":18,"158":18,"159":18,"160":18,"161":18,"162":3154,"163":186,"164":186,"165":186,"166":186,"167":186,"168":177,"169":177,"170":11,"171":166,"172":2968,"173":9,"174":9,"175":9,"176":9,"177":2959,"178":1,"179":186,"180":186,"181":186,"182":124,"183":98,"184":26,"185":26,"186":1,"187":123,"188":6,"189":123,"190":178,"191":1,"192":177,"193":1,"194":9,"195":1,"196":11,"197":11,"198":1,"199":35,"200":35,"201":35,"202":1,"203":3533,"204":3533,"205":9,"206":2,"207":7,"208":7,"209":7,"210":0,"211":7,"212":0,"213":7,"214":8,"215":8,"216":8,"217":18,"218":0,"219":1501,"220":1501,"221":1501,"222":1501,"223":1498,"224":14,"225":11,"226":1484,"227":2,"228":2,"229":1482,"230":1,"231":1,"232":1,"233":1484,"234":24,"235":1460,"236":0,"237":0,"238":0,"239":0,"240":0,"241":0,"242":0,"243":0,"244":0,"245":0,"246":0,"247":26,"248":26,"249":26,"250":26,"251":26,"252":550,"253":346,"254":11,"255":11,"256":11,"257":76,"258":76,"259":76,"260":76,"261":383,"262":114,"263":114,"264":114,"265":109,"266":109,"267":223,"268":122,"269":2,"270":19,"271":19,"272":19,"273":45,"274":24,"275":0,"276":0,"277":0,"278":0,"279":0,"280":0,"281":0,"282":76,"283":1,"284":122,"285":122,"286":122,"287":1,"288":121,"289":1,"290":11,"291":11,"292":10,"293":2,"294":8,"295":1,"296":944,"297":944,"298":944,"299":944,"300":944,"301":944,"302":1,"303":129,"304":125,"305":124,"306":124,"307":1,"308":383,"309":383,"310":383,"311":383,"312":383,"313":383,"314":383,"315":383,"316":383,"317":383,"318":421,"319":366,"320":55,"321":53,"322":3,"323":3,"324":416,"325":22,"326":22,"327":22,"328":22,"329":18,"330":394,"331":286,"332":286,"333":286,"334":284,"335":284,"336":111,"337":140,"338":7,"339":104,"340":171,"341":7,"342":0,"343":7,"344":164,"345":1,"346":163,"347":3,"348":160,"349":0,"350":160,"351":1,"352":159,"353":4,"354":4,"355":4,"356":4,"357":155,"358":159,"359":159,"360":159,"361":1,"362":178,"363":111,"364":1,"365":538,"366":1,"367":45,"368":45,"369":45,"370":10,"371":35,"372":35,"373":20,"374":17,"375":15,"376":32,"377":1,"378":41,"379":41,"380":41,"381":41,"382":41,"383":1,"384":33,"385":33,"386":29,"387":29,"388":29,"389":29,"390":17,"391":17,"392":17,"393":12,"394":24,"395":24,"396":1,"397":284,"398":284,"399":284,"400":284,"401":284,"402":284,"403":280,"404":325,"405":254,"406":71,"407":61,"408":3,"409":312,"410":2,"411":312,"412":312,"413":312,"414":312,"415":2,"416":2,"417":312,"418":8,"419":8,"420":8,"421":8,"422":304,"423":304,"424":304,"425":295,"426":295,"427":304,"428":233,"429":304,"430":8,"431":0,"432":8,"433":8,"434":3,"435":5,"436":5,"437":1,"438":5,"439":296,"440":295,"441":263,"442":263,"443":81,"444":263,"445":229,"446":0,"447":229,"448":1,"449":295,"450":46,"451":0,"452":46,"453":46,"454":46,"455":37,"456":249,"457":120,"458":113,"459":129,"460":43,"461":0,"462":43,"463":43,"464":40,"465":32,"466":32,"467":0,"468":0,"469":0,"470":0,"471":32,"472":86,"473":81,"474":44,"475":44,"476":8,"477":44,"478":0,"479":44,"480":37,"481":11,"482":11,"483":11,"484":26,"485":81,"486":81,"487":5,"488":1,"489":544,"490":31,"491":31,"492":30,"493":26,"494":513,"495":513,"496":1,"497":762,"498":762,"499":762,"500":762,"501":1,"502":190,"503":190,"504":190,"505":190,"506":182,"507":178,"508":178,"509":168,"510":168,"511":1,"512":140,"513":140,"514":131,"515":112,"516":1,"517":696,"518":696,"519":696,"520":696,"521":89,"522":89,"523":607,"524":607,"525":607,"526":607,"527":607,"528":607,"529":607,"530":474,"531":474,"532":474,"533":474,"534":563,"535":563,"536":563,"537":563,"538":563,"539":131,"540":563,"541":52,"542":52,"543":50,"544":50,"545":50,"546":50,"547":563,"548":2,"549":561,"550":361,"551":361,"552":361,"553":48,"554":361,"555":105,"556":337,"557":261,"558":276,"559":1,"560":134,"561":134,"562":134,"563":158,"564":106,"565":52,"566":52,"567":8,"568":150,"569":126,"570":1,"571":273,"572":12,"573":261,"574":35,"575":226,"576":257,"577":1,"578":4037,"579":4037,"580":3760,"581":3,"582":3757,"583":277,"584":243,"585":34,"586":4000,"587":0,"588":4000,"589":4000,"590":3998,"591":1,"592":11,"593":0,"594":11,"595":1,"596":10,"597":10,"598":1,"599":34,"600":34,"601":34,"602":10,"603":10,"604":24,"605":24,"606":33},"f":{"1":263,"2":1793,"3":3228,"4":3194,"5":2734,"6":3194,"7":3205,"8":3503,"9":3466,"10":2981,"11":186,"12":9,"13":11,"14":35,"15":3533,"16":122,"17":11,"18":944,"19":129,"20":383,"21":178,"22":538,"23":45,"24":41,"25":33,"26":284,"27":295,"28":544,"29":762,"30":190,"31":140,"32":696,"33":134,"34":273,"35":4037,"36":11,"37":34},"b":{"1":[15,248],"2":[235,9,13,0],"3":[0,248],"4":[248,23],"5":[0,0],"6":[8,1479],"7":[34,3194],"8":[3228,50],"9":[634,2560],"10":[1593,1601],"11":[3194,2820],"12":[520,2260],"13":[209,2571],"14":[185,24],"15":[2,176],"16":[178,2],"17":[1,1],"18":[1,0],"19":[2,0],"20":[3,2568],"21":[2571,2070],"22":[53,2734],"23":[2787,2787],"24":[21,2713],"25":[53,2747],"26":[2800,2800],"27":[277,2928],"28":[3205,293,18],"29":[242,35],"30":[3,239],"31":[242,13,4,4,4],"32":[10,219],"33":[22,207],"34":[229,218],"35":[61,3442],"36":[58,2,2],"37":[0,58],"38":[58,56],"39":[28,30],"40":[2,28],"41":[30,3,2],"42":[16,28],"43":[53,3012],"44":[3065,2770],"45":[3039,43],"46":[101,2999],"47":[3100,113],"48":[53,2946],"49":[2999,2680],"50":[0,3246],"51":[3246,3204],"52":[74,3172],"53":[18,3154],"54":[186,2968],"55":[3154,3117],"56":[186,167,153,15],"57":[11,166],"58":[177,13],"59":[9,2959],"60":[98,26],"61":[1,25],"62":[6,117],"63":[123,6],"64":[13,110],"65":[1,177],"66":[178,14,1],"67":[9,8,18,1501,0,26,550,346,11,62,76,383,114,223,122,2,19,45,24,0,76],"68":[2,7],"69":[9,2],"70":[0,7],"71":[7,4,3],"72":[0,7],"73":[7,3,0],"74":[0,18],"75":[1501,14],"76":[1501,1490],"77":[14,1484],"78":[11,3],"79":[14,3],"80":[2,1482],"81":[1484,20,3],"82":[1,1481],"83":[1482,1201,18],"84":[24,1460],"85":[1484,1203,1101],"86":[0,0],"87":[0,0],"88":[1,121],"89":[122,3,1],"90":[2,8],"91":[383,383],"92":[383,383],"93":[366,55],"94":[55,53],"95":[3,50],"96":[22,394],"97":[111,171],"98":[284,277,178],"99":[7,133],"100":[140,13],"101":[7,164],"102":[0,7],"103":[1,163],"104":[3,160],"105":[0,160],"106":[1,159],"107":[4,155],"108":[111,65],"109":[10,35],"110":[20,15],"111":[254,71],"112":[3,58],"113":[2,310],"114":[8,304],"115":[312,15],"116":[3,5],"117":[295,9],"118":[304,233],"119":[233,71],"120":[8,296],"121":[304,233],"122":[0,8],"123":[3,5],"124":[8,7,6],"125":[1,4],"126":[81,182],"127":[0,229],"128":[59,170],"129":[46,249],"130":[295,290,279],"131":[0,46],"132":[120,129],"133":[27,93],"134":[43,86],"135":[129,128,124,101,44,44],"136":[0,43],"137":[43,43,43],"138":[18,14],"139":[0,32],"140":[0,0],"141":[81,5],"142":[86,85],"143":[44,37],"144":[8,36],"145":[44,44],"146":[8,8],"147":[0,44],"148":[11,26],"149":[37,11],"150":[11,0],"151":[31,513],"152":[23,490],"153":[513,507],"154":[190,0],"155":[696,131],"156":[89,607],"157":[131,432],"158":[52,511],"159":[563,474],"160":[50,2],"161":[2,561],"162":[563,50,44,44],"163":[361,200],"164":[48,313],"165":[105,256],"166":[106,52],"167":[8,44],"168":[12,261],"169":[273,137],"170":[35,226],"171":[3760,277],"172":[3,3757],"173":[3760,2567,905],"174":[243,34],"175":[277,257],"176":[0,4000],"177":[4000,2564,3],"178":[0,11],"179":[1,10],"180":[10,24],"181":[34,33,29,19]},"hash":"9c7923c433094c1b8d0d0030ec04dc5512d950e0"}
+,"/home/travis/build/babel/babylon/src/parser/index.js": {"path":"/home/travis/build/babel/babylon/src/parser/index.js","statementMap":{"1":{"start":{"line":5,"column":23},"end":{"line":5,"column":25}},"2":{"start":{"line":9,"column":4},"end":{"line":9,"column":34}},"3":{"start":{"line":10,"column":4},"end":{"line":10,"column":26}},"4":{"start":{"line":12,"column":4},"end":{"line":12,"column":27}},"5":{"start":{"line":13,"column":4},"end":{"line":13,"column":57}},"6":{"start":{"line":14,"column":4},"end":{"line":14,"column":43}},"7":{"start":{"line":15,"column":4},"end":{"line":15,"column":23}},"8":{"start":{"line":16,"column":4},"end":{"line":16,"column":58}},"9":{"start":{"line":17,"column":4},"end":{"line":17,"column":43}},"10":{"start":{"line":20,"column":4},"end":{"line":22,"column":5}},"11":{"start":{"line":21,"column":6},"end":{"line":21,"column":30}},"12":{"start":{"line":26,"column":4},"end":{"line":26,"column":55}},"13":{"start":{"line":30,"column":4},"end":{"line":30,"column":31}},"14":{"start":{"line":34,"column":20},"end":{"line":34,"column":22}},"15":{"start":{"line":36,"column":4},"end":{"line":40,"column":5}},"16":{"start":{"line":38,"column":6},"end":{"line":38,"column":62}},"17":{"start":{"line":38,"column":43},"end":{"line":38,"column":60}},"18":{"start":{"line":39,"column":6},"end":{"line":39,"column":27}},"19":{"start":{"line":42,"column":4},"end":{"line":49,"column":5}},"20":{"start":{"line":43,"column":6},"end":{"line":48,"column":7}},"21":{"start":{"line":44,"column":8},"end":{"line":44,"column":31}},"22":{"start":{"line":46,"column":21},"end":{"line":46,"column":42}},"23":{"start":{"line":47,"column":8},"end":{"line":47,"column":33}},"24":{"start":{"line":47,"column":20},"end":{"line":47,"column":33}},"25":{"start":{"line":51,"column":4},"end":{"line":51,"column":21}},"26":{"start":{"line":61,"column":15},"end":{"line":61,"column":31}},"27":{"start":{"line":62,"column":18},"end":{"line":62,"column":34}},"28":{"start":{"line":63,"column":4},"end":{"line":63,"column":21}},"29":{"start":{"line":64,"column":4},"end":{"line":64,"column":45}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":8,"column":2},"end":{"line":8,"column":3}},"loc":{"start":{"line":8,"column":46},"end":{"line":23,"column":3}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":25,"column":2},"end":{"line":25,"column":3}},"loc":{"start":{"line":25,"column":35},"end":{"line":27,"column":3}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":29,"column":2},"end":{"line":29,"column":3}},"loc":{"start":{"line":29,"column":36},"end":{"line":31,"column":3}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":33,"column":2},"end":{"line":33,"column":3}},"loc":{"start":{"line":33,"column":46},"end":{"line":52,"column":3}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":38,"column":31},"end":{"line":38,"column":32}},"loc":{"start":{"line":38,"column":43},"end":{"line":38,"column":60}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":54,"column":2},"end":{"line":54,"column":3}},"loc":{"start":{"line":60,"column":4},"end":{"line":65,"column":3}}}},"branchMap":{"1":{"loc":{"start":{"line":20,"column":4},"end":{"line":22,"column":5}},"type":"if","locations":[{"start":{"line":20,"column":4},"end":{"line":22,"column":5}},{"start":{"line":20,"column":4},"end":{"line":22,"column":5}}]},"2":{"loc":{"start":{"line":20,"column":8},"end":{"line":20,"column":78}},"type":"binary-expr","locations":[{"start":{"line":20,"column":8},"end":{"line":20,"column":28}},{"start":{"line":20,"column":32},"end":{"line":20,"column":53}},{"start":{"line":20,"column":57},"end":{"line":20,"column":78}}]},"3":{"loc":{"start":{"line":26,"column":14},"end":{"line":26,"column":53}},"type":"binary-expr","locations":[{"start":{"line":26,"column":14},"end":{"line":26,"column":31}},{"start":{"line":26,"column":35},"end":{"line":26,"column":53}}]},"4":{"loc":{"start":{"line":36,"column":4},"end":{"line":40,"column":5}},"type":"if","locations":[{"start":{"line":36,"column":4},"end":{"line":40,"column":5}},{"start":{"line":36,"column":4},"end":{"line":40,"column":5}}]},"5":{"loc":{"start":{"line":43,"column":6},"end":{"line":48,"column":7}},"type":"if","locations":[{"start":{"line":43,"column":6},"end":{"line":48,"column":7}},{"start":{"line":43,"column":6},"end":{"line":48,"column":7}}]},"6":{"loc":{"start":{"line":47,"column":8},"end":{"line":47,"column":33}},"type":"if","locations":[{"start":{"line":47,"column":8},"end":{"line":47,"column":33}},{"start":{"line":47,"column":8},"end":{"line":47,"column":33}}]}},"s":{"1":1,"2":2123,"3":2123,"4":2123,"5":2123,"6":2123,"7":2123,"8":2123,"9":2123,"10":2123,"11":2,"12":1088,"13":8596,"14":2123,"15":2123,"16":253,"17":506,"18":253,"19":2123,"20":557,"21":557,"22":557,"23":557,"24":504,"25":2123,"26":2123,"27":2123,"28":2123,"29":2026},"f":{"1":2123,"2":1088,"3":8596,"4":2123,"5":506,"6":2123},"b":{"1":[2,2121],"2":[2123,2123,2],"3":[1088,1088],"4":[253,1870],"5":[557,0],"6":[504,53]},"hash":"f7e9006d113cd234a5c7df0c1859b1ef3cdf039a"}
+,"/home/travis/build/babel/babylon/src/parser/location.js": {"path":"/home/travis/build/babel/babylon/src/parser/location.js","statementMap":{"1":{"start":{"line":4,"column":11},"end":{"line":4,"column":27}},"2":{"start":{"line":12,"column":0},"end":{"line":19,"column":2}},"3":{"start":{"line":13,"column":12},"end":{"line":13,"column":40}},"4":{"start":{"line":14,"column":2},"end":{"line":14,"column":44}},"5":{"start":{"line":15,"column":12},"end":{"line":15,"column":36}},"6":{"start":{"line":16,"column":2},"end":{"line":16,"column":16}},"7":{"start":{"line":17,"column":2},"end":{"line":17,"column":16}},"8":{"start":{"line":18,"column":2},"end":{"line":18,"column":12}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":12,"column":11},"end":{"line":12,"column":12}},"loc":{"start":{"line":12,"column":35},"end":{"line":19,"column":1}}}},"branchMap":{},"s":{"1":1,"2":1,"3":738,"4":738,"5":738,"6":738,"7":738,"8":738},"f":{"1":738},"b":{},"hash":"513a036132dc8836c5d9933be753a4fe6a778c29"}
+,"/home/travis/build/babel/babylon/src/parser/lval.js": {"path":"/home/travis/build/babel/babylon/src/parser/lval.js","statementMap":{"1":{"start":{"line":7,"column":11},"end":{"line":7,"column":27}},"2":{"start":{"line":12,"column":0},"end":{"line":66,"column":2}},"3":{"start":{"line":13,"column":2},"end":{"line":64,"column":3}},"4":{"start":{"line":14,"column":4},"end":{"line":63,"column":5}},"5":{"start":{"line":19,"column":8},"end":{"line":19,"column":14}},"6":{"start":{"line":22,"column":8},"end":{"line":22,"column":36}},"7":{"start":{"line":23,"column":8},"end":{"line":33,"column":9}},"8":{"start":{"line":24,"column":10},"end":{"line":32,"column":11}},"9":{"start":{"line":25,"column":12},"end":{"line":29,"column":13}},"10":{"start":{"line":26,"column":14},"end":{"line":26,"column":90}},"11":{"start":{"line":28,"column":14},"end":{"line":28,"column":81}},"12":{"start":{"line":31,"column":12},"end":{"line":31,"column":47}},"13":{"start":{"line":34,"column":8},"end":{"line":34,"column":14}},"14":{"start":{"line":37,"column":8},"end":{"line":37,"column":49}},"15":{"start":{"line":38,"column":8},"end":{"line":38,"column":14}},"16":{"start":{"line":41,"column":8},"end":{"line":41,"column":35}},"17":{"start":{"line":42,"column":8},"end":{"line":42,"column":14}},"18":{"start":{"line":45,"column":8},"end":{"line":45,"column":35}},"19":{"start":{"line":46,"column":8},"end":{"line":46,"column":56}},"20":{"start":{"line":47,"column":8},"end":{"line":47,"column":14}},"21":{"start":{"line":50,"column":8},"end":{"line":55,"column":9}},"22":{"start":{"line":51,"column":10},"end":{"line":51,"column":42}},"23":{"start":{"line":52,"column":10},"end":{"line":52,"column":31}},"24":{"start":{"line":54,"column":10},"end":{"line":54,"column":99}},"25":{"start":{"line":56,"column":8},"end":{"line":56,"column":14}},"26":{"start":{"line":59,"column":8},"end":{"line":59,"column":30}},"27":{"start":{"line":59,"column":24},"end":{"line":59,"column":30}},"28":{"start":{"line":62,"column":8},"end":{"line":62,"column":54}},"29":{"start":{"line":65,"column":2},"end":{"line":65,"column":14}},"30":{"start":{"line":70,"column":0},"end":{"line":91,"column":2}},"31":{"start":{"line":71,"column":12},"end":{"line":71,"column":27}},"32":{"start":{"line":72,"column":2},"end":{"line":85,"column":3}},"33":{"start":{"line":73,"column":15},"end":{"line":73,"column":32}},"34":{"start":{"line":74,"column":4},"end":{"line":84,"column":5}},"35":{"start":{"line":75,"column":6},"end":{"line":75,"column":12}},"36":{"start":{"line":76,"column":11},"end":{"line":84,"column":5}},"37":{"start":{"line":77,"column":6},"end":{"line":77,"column":32}},"38":{"start":{"line":78,"column":16},"end":{"line":78,"column":29}},"39":{"start":{"line":79,"column":6},"end":{"line":79,"column":40}},"40":{"start":{"line":80,"column":6},"end":{"line":82,"column":7}},"41":{"start":{"line":81,"column":8},"end":{"line":81,"column":35}},"42":{"start":{"line":83,"column":6},"end":{"line":83,"column":12}},"43":{"start":{"line":86,"column":2},"end":{"line":89,"column":3}},"44":{"start":{"line":87,"column":14},"end":{"line":87,"column":25}},"45":{"start":{"line":88,"column":4},"end":{"line":88,"column":47}},"46":{"start":{"line":88,"column":13},"end":{"line":88,"column":47}},"47":{"start":{"line":90,"column":2},"end":{"line":90,"column":18}},"48":{"start":{"line":95,"column":0},"end":{"line":97,"column":2}},"49":{"start":{"line":96,"column":2},"end":{"line":96,"column":18}},"50":{"start":{"line":101,"column":0},"end":{"line":106,"column":2}},"51":{"start":{"line":102,"column":13},"end":{"line":102,"column":29}},"52":{"start":{"line":103,"column":2},"end":{"line":103,"column":14}},"53":{"start":{"line":104,"column":2},"end":{"line":104,"column":64}},"54":{"start":{"line":105,"column":2},"end":{"line":105,"column":48}},"55":{"start":{"line":108,"column":0},"end":{"line":113,"column":2}},"56":{"start":{"line":109,"column":13},"end":{"line":109,"column":29}},"57":{"start":{"line":110,"column":2},"end":{"line":110,"column":14}},"58":{"start":{"line":111,"column":2},"end":{"line":111,"column":48}},"59":{"start":{"line":112,"column":2},"end":{"line":112,"column":46}},"60":{"start":{"line":115,"column":0},"end":{"line":117,"column":2}},"61":{"start":{"line":116,"column":2},"end":{"line":116,"column":80}},"62":{"start":{"line":119,"column":0},"end":{"line":121,"column":2}},"63":{"start":{"line":120,"column":2},"end":{"line":120,"column":65}},"64":{"start":{"line":125,"column":0},"end":{"line":145,"column":2}},"65":{"start":{"line":126,"column":2},"end":{"line":144,"column":3}},"66":{"start":{"line":128,"column":6},"end":{"line":128,"column":73}},"67":{"start":{"line":128,"column":55},"end":{"line":128,"column":73}},"68":{"start":{"line":131,"column":6},"end":{"line":131,"column":40}},"69":{"start":{"line":134,"column":17},"end":{"line":134,"column":33}},"70":{"start":{"line":135,"column":6},"end":{"line":135,"column":18}},"71":{"start":{"line":136,"column":6},"end":{"line":136,"column":63}},"72":{"start":{"line":137,"column":6},"end":{"line":137,"column":51}},"73":{"start":{"line":140,"column":6},"end":{"line":140,"column":33}},"74":{"start":{"line":143,"column":6},"end":{"line":143,"column":24}},"75":{"start":{"line":147,"column":0},"end":{"line":178,"column":2}},"76":{"start":{"line":148,"column":13},"end":{"line":148,"column":15}},"77":{"start":{"line":149,"column":14},"end":{"line":149,"column":18}},"78":{"start":{"line":150,"column":2},"end":{"line":176,"column":3}},"79":{"start":{"line":151,"column":4},"end":{"line":155,"column":5}},"80":{"start":{"line":152,"column":6},"end":{"line":152,"column":20}},"81":{"start":{"line":154,"column":6},"end":{"line":154,"column":28}},"82":{"start":{"line":156,"column":4},"end":{"line":175,"column":5}},"83":{"start":{"line":157,"column":6},"end":{"line":157,"column":22}},"84":{"start":{"line":158,"column":11},"end":{"line":175,"column":5}},"85":{"start":{"line":159,"column":6},"end":{"line":159,"column":12}},"86":{"start":{"line":160,"column":11},"end":{"line":175,"column":5}},"87":{"start":{"line":161,"column":6},"end":{"line":161,"column":69}},"88":{"start":{"line":162,"column":6},"end":{"line":162,"column":25}},"89":{"start":{"line":163,"column":6},"end":{"line":163,"column":12}},"90":{"start":{"line":165,"column":23},"end":{"line":165,"column":25}},"91":{"start":{"line":166,"column":6},"end":{"line":168,"column":7}},"92":{"start":{"line":167,"column":8},"end":{"line":167,"column":47}},"93":{"start":{"line":169,"column":17},"end":{"line":169,"column":41}},"94":{"start":{"line":170,"column":6},"end":{"line":172,"column":7}},"95":{"start":{"line":171,"column":8},"end":{"line":171,"column":37}},"96":{"start":{"line":173,"column":6},"end":{"line":173,"column":46}},"97":{"start":{"line":174,"column":6},"end":{"line":174,"column":74}},"98":{"start":{"line":177,"column":2},"end":{"line":177,"column":14}},"99":{"start":{"line":180,"column":0},"end":{"line":182,"column":2}},"100":{"start":{"line":181,"column":2},"end":{"line":181,"column":15}},"101":{"start":{"line":186,"column":0},"end":{"line":196,"column":2}},"102":{"start":{"line":187,"column":2},"end":{"line":187,"column":45}},"103":{"start":{"line":188,"column":2},"end":{"line":188,"column":42}},"104":{"start":{"line":189,"column":2},"end":{"line":189,"column":41}},"105":{"start":{"line":190,"column":2},"end":{"line":190,"column":36}},"106":{"start":{"line":190,"column":24},"end":{"line":190,"column":36}},"107":{"start":{"line":192,"column":13},"end":{"line":192,"column":49}},"108":{"start":{"line":193,"column":2},"end":{"line":193,"column":19}},"109":{"start":{"line":194,"column":2},"end":{"line":194,"column":39}},"110":{"start":{"line":195,"column":2},"end":{"line":195,"column":52}},"111":{"start":{"line":201,"column":0},"end":{"line":259,"column":2}},"112":{"start":{"line":202,"column":2},"end":{"line":258,"column":3}},"113":{"start":{"line":204,"column":6},"end":{"line":206,"column":7}},"114":{"start":{"line":205,"column":8},"end":{"line":205,"column":107}},"115":{"start":{"line":208,"column":6},"end":{"line":227,"column":7}},"116":{"start":{"line":220,"column":18},"end":{"line":220,"column":33}},"117":{"start":{"line":222,"column":8},"end":{"line":226,"column":9}},"118":{"start":{"line":223,"column":10},"end":{"line":223,"column":71}},"119":{"start":{"line":225,"column":10},"end":{"line":225,"column":35}},"120":{"start":{"line":228,"column":6},"end":{"line":228,"column":12}},"121":{"start":{"line":231,"column":6},"end":{"line":231,"column":109}},"122":{"start":{"line":231,"column":21},"end":{"line":231,"column":109}},"123":{"start":{"line":232,"column":6},"end":{"line":232,"column":12}},"124":{"start":{"line":235,"column":6},"end":{"line":238,"column":7}},"125":{"start":{"line":236,"column":8},"end":{"line":236,"column":62}},"126":{"start":{"line":236,"column":44},"end":{"line":236,"column":62}},"127":{"start":{"line":237,"column":8},"end":{"line":237,"column":54}},"128":{"start":{"line":239,"column":6},"end":{"line":239,"column":12}},"129":{"start":{"line":242,"column":6},"end":{"line":244,"column":7}},"130":{"start":{"line":243,"column":8},"end":{"line":243,"column":64}},"131":{"start":{"line":243,"column":18},"end":{"line":243,"column":64}},"132":{"start":{"line":245,"column":6},"end":{"line":245,"column":12}},"133":{"start":{"line":248,"column":6},"end":{"line":248,"column":57}},"134":{"start":{"line":249,"column":6},"end":{"line":249,"column":12}},"135":{"start":{"line":253,"column":6},"end":{"line":253,"column":61}},"136":{"start":{"line":254,"column":6},"end":{"line":254,"column":12}},"137":{"start":{"line":257,"column":6},"end":{"line":257,"column":83}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":12,"column":18},"end":{"line":12,"column":19}},"loc":{"start":{"line":12,"column":45},"end":{"line":66,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":70,"column":22},"end":{"line":70,"column":23}},"loc":{"start":{"line":70,"column":53},"end":{"line":91,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":95,"column":22},"end":{"line":95,"column":23}},"loc":{"start":{"line":95,"column":42},"end":{"line":97,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":101,"column":17},"end":{"line":101,"column":18}},"loc":{"start":{"line":101,"column":51},"end":{"line":106,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":108,"column":15},"end":{"line":108,"column":16}},"loc":{"start":{"line":108,"column":27},"end":{"line":113,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":115,"column":32},"end":{"line":115,"column":33}},"loc":{"start":{"line":115,"column":44},"end":{"line":117,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":119,"column":28},"end":{"line":119,"column":29}},"loc":{"start":{"line":119,"column":40},"end":{"line":121,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":125,"column":22},"end":{"line":125,"column":23}},"loc":{"start":{"line":125,"column":34},"end":{"line":145,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":147,"column":22},"end":{"line":147,"column":23}},"loc":{"start":{"line":147,"column":51},"end":{"line":178,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":180,"column":34},"end":{"line":180,"column":35}},"loc":{"start":{"line":180,"column":51},"end":{"line":182,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":186,"column":23},"end":{"line":186,"column":24}},"loc":{"start":{"line":186,"column":59},"end":{"line":196,"column":1}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":201,"column":15},"end":{"line":201,"column":16}},"loc":{"start":{"line":201,"column":56},"end":{"line":259,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":13,"column":2},"end":{"line":64,"column":3}},"type":"if","locations":[{"start":{"line":13,"column":2},"end":{"line":64,"column":3}},{"start":{"line":13,"column":2},"end":{"line":64,"column":3}}]},"2":{"loc":{"start":{"line":14,"column":4},"end":{"line":63,"column":5}},"type":"switch","locations":[{"start":{"line":15,"column":6},"end":{"line":15,"column":24}},{"start":{"line":16,"column":6},"end":{"line":16,"column":27}},{"start":{"line":17,"column":6},"end":{"line":17,"column":26}},{"start":{"line":18,"column":6},"end":{"line":19,"column":14}},{"start":{"line":21,"column":6},"end":{"line":34,"column":14}},{"start":{"line":36,"column":6},"end":{"line":38,"column":14}},{"start":{"line":40,"column":6},"end":{"line":42,"column":14}},{"start":{"line":44,"column":6},"end":{"line":47,"column":14}},{"start":{"line":49,"column":6},"end":{"line":56,"column":14}},{"start":{"line":58,"column":6},"end":{"line":59,"column":30}},{"start":{"line":61,"column":6},"end":{"line":62,"column":54}}]},"3":{"loc":{"start":{"line":24,"column":10},"end":{"line":32,"column":11}},"type":"if","locations":[{"start":{"line":24,"column":10},"end":{"line":32,"column":11}},{"start":{"line":24,"column":10},"end":{"line":32,"column":11}}]},"4":{"loc":{"start":{"line":25,"column":12},"end":{"line":29,"column":13}},"type":"if","locations":[{"start":{"line":25,"column":12},"end":{"line":29,"column":13}},{"start":{"line":25,"column":12},"end":{"line":29,"column":13}}]},"5":{"loc":{"start":{"line":25,"column":16},"end":{"line":25,"column":58}},"type":"binary-expr","locations":[{"start":{"line":25,"column":16},"end":{"line":25,"column":35}},{"start":{"line":25,"column":39},"end":{"line":25,"column":58}}]},"6":{"loc":{"start":{"line":50,"column":8},"end":{"line":55,"column":9}},"type":"if","locations":[{"start":{"line":50,"column":8},"end":{"line":55,"column":9}},{"start":{"line":50,"column":8},"end":{"line":55,"column":9}}]},"7":{"loc":{"start":{"line":59,"column":8},"end":{"line":59,"column":30}},"type":"if","locations":[{"start":{"line":59,"column":8},"end":{"line":59,"column":30}},{"start":{"line":59,"column":8},"end":{"line":59,"column":30}}]},"8":{"loc":{"start":{"line":72,"column":2},"end":{"line":85,"column":3}},"type":"if","locations":[{"start":{"line":72,"column":2},"end":{"line":85,"column":3}},{"start":{"line":72,"column":2},"end":{"line":85,"column":3}}]},"9":{"loc":{"start":{"line":74,"column":4},"end":{"line":84,"column":5}},"type":"if","locations":[{"start":{"line":74,"column":4},"end":{"line":84,"column":5}},{"start":{"line":74,"column":4},"end":{"line":84,"column":5}}]},"10":{"loc":{"start":{"line":74,"column":8},"end":{"line":74,"column":43}},"type":"binary-expr","locations":[{"start":{"line":74,"column":8},"end":{"line":74,"column":12}},{"start":{"line":74,"column":16},"end":{"line":74,"column":43}}]},"11":{"loc":{"start":{"line":76,"column":11},"end":{"line":84,"column":5}},"type":"if","locations":[{"start":{"line":76,"column":11},"end":{"line":84,"column":5}},{"start":{"line":76,"column":11},"end":{"line":84,"column":5}}]},"12":{"loc":{"start":{"line":76,"column":15},"end":{"line":76,"column":52}},"type":"binary-expr","locations":[{"start":{"line":76,"column":15},"end":{"line":76,"column":19}},{"start":{"line":76,"column":23},"end":{"line":76,"column":52}}]},"13":{"loc":{"start":{"line":80,"column":6},"end":{"line":82,"column":7}},"type":"if","locations":[{"start":{"line":80,"column":6},"end":{"line":82,"column":7}},{"start":{"line":80,"column":6},"end":{"line":82,"column":7}}]},"14":{"loc":{"start":{"line":80,"column":10},"end":{"line":80,"column":101}},"type":"binary-expr","locations":[{"start":{"line":80,"column":10},"end":{"line":80,"column":35}},{"start":{"line":80,"column":39},"end":{"line":80,"column":70}},{"start":{"line":80,"column":74},"end":{"line":80,"column":101}}]},"15":{"loc":{"start":{"line":88,"column":4},"end":{"line":88,"column":47}},"type":"if","locations":[{"start":{"line":88,"column":4},"end":{"line":88,"column":47}},{"start":{"line":88,"column":4},"end":{"line":88,"column":47}}]},"16":{"loc":{"start":{"line":116,"column":9},"end":{"line":116,"column":79}},"type":"binary-expr","locations":[{"start":{"line":116,"column":9},"end":{"line":116,"column":30}},{"start":{"line":116,"column":34},"end":{"line":116,"column":52}},{"start":{"line":116,"column":56},"end":{"line":116,"column":79}}]},"17":{"loc":{"start":{"line":126,"column":2},"end":{"line":144,"column":3}},"type":"switch","locations":[{"start":{"line":127,"column":4},"end":{"line":128,"column":73}},{"start":{"line":130,"column":4},"end":{"line":131,"column":40}},{"start":{"line":133,"column":4},"end":{"line":137,"column":51}},{"start":{"line":139,"column":4},"end":{"line":140,"column":33}},{"start":{"line":142,"column":4},"end":{"line":143,"column":24}}]},"18":{"loc":{"start":{"line":128,"column":6},"end":{"line":128,"column":73}},"type":"if","locations":[{"start":{"line":128,"column":6},"end":{"line":128,"column":73}},{"start":{"line":128,"column":6},"end":{"line":128,"column":73}}]},"19":{"loc":{"start":{"line":128,"column":10},"end":{"line":128,"column":53}},"type":"binary-expr","locations":[{"start":{"line":128,"column":10},"end":{"line":128,"column":27}},{"start":{"line":128,"column":31},"end":{"line":128,"column":53}}]},"20":{"loc":{"start":{"line":151,"column":4},"end":{"line":155,"column":5}},"type":"if","locations":[{"start":{"line":151,"column":4},"end":{"line":155,"column":5}},{"start":{"line":151,"column":4},"end":{"line":155,"column":5}}]},"21":{"loc":{"start":{"line":156,"column":4},"end":{"line":175,"column":5}},"type":"if","locations":[{"start":{"line":156,"column":4},"end":{"line":175,"column":5}},{"start":{"line":156,"column":4},"end":{"line":175,"column":5}}]},"22":{"loc":{"start":{"line":156,"column":8},"end":{"line":156,"column":42}},"type":"binary-expr","locations":[{"start":{"line":156,"column":8},"end":{"line":156,"column":18}},{"start":{"line":156,"column":22},"end":{"line":156,"column":42}}]},"23":{"loc":{"start":{"line":158,"column":11},"end":{"line":175,"column":5}},"type":"if","locations":[{"start":{"line":158,"column":11},"end":{"line":175,"column":5}},{"start":{"line":158,"column":11},"end":{"line":175,"column":5}}]},"24":{"loc":{"start":{"line":160,"column":11},"end":{"line":175,"column":5}},"type":"if","locations":[{"start":{"line":160,"column":11},"end":{"line":175,"column":5}},{"start":{"line":160,"column":11},"end":{"line":175,"column":5}}]},"25":{"loc":{"start":{"line":170,"column":6},"end":{"line":172,"column":7}},"type":"if","locations":[{"start":{"line":170,"column":6},"end":{"line":172,"column":7}},{"start":{"line":170,"column":6},"end":{"line":172,"column":7}}]},"26":{"loc":{"start":{"line":187,"column":13},"end":{"line":187,"column":44}},"type":"binary-expr","locations":[{"start":{"line":187,"column":13},"end":{"line":187,"column":21}},{"start":{"line":187,"column":25},"end":{"line":187,"column":44}}]},"27":{"loc":{"start":{"line":188,"column":13},"end":{"line":188,"column":41}},"type":"binary-expr","locations":[{"start":{"line":188,"column":13},"end":{"line":188,"column":21}},{"start":{"line":188,"column":25},"end":{"line":188,"column":41}}]},"28":{"loc":{"start":{"line":189,"column":9},"end":{"line":189,"column":40}},"type":"binary-expr","locations":[{"start":{"line":189,"column":9},"end":{"line":189,"column":13}},{"start":{"line":189,"column":17},"end":{"line":189,"column":40}}]},"29":{"loc":{"start":{"line":190,"column":2},"end":{"line":190,"column":36}},"type":"if","locations":[{"start":{"line":190,"column":2},"end":{"line":190,"column":36}},{"start":{"line":190,"column":2},"end":{"line":190,"column":36}}]},"30":{"loc":{"start":{"line":202,"column":2},"end":{"line":258,"column":3}},"type":"switch","locations":[{"start":{"line":203,"column":4},"end":{"line":228,"column":12}},{"start":{"line":230,"column":4},"end":{"line":232,"column":12}},{"start":{"line":234,"column":4},"end":{"line":239,"column":12}},{"start":{"line":241,"column":4},"end":{"line":245,"column":12}},{"start":{"line":247,"column":4},"end":{"line":249,"column":12}},{"start":{"line":251,"column":4},"end":{"line":251,"column":24}},{"start":{"line":252,"column":4},"end":{"line":254,"column":12}},{"start":{"line":256,"column":4},"end":{"line":257,"column":83}}]},"31":{"loc":{"start":{"line":204,"column":6},"end":{"line":206,"column":7}},"type":"if","locations":[{"start":{"line":204,"column":6},"end":{"line":206,"column":7}},{"start":{"line":204,"column":6},"end":{"line":206,"column":7}}]},"32":{"loc":{"start":{"line":204,"column":10},"end":{"line":204,"column":103}},"type":"binary-expr","locations":[{"start":{"line":204,"column":10},"end":{"line":204,"column":27}},{"start":{"line":204,"column":32},"end":{"line":204,"column":67}},{"start":{"line":204,"column":71},"end":{"line":204,"column":102}}]},"33":{"loc":{"start":{"line":205,"column":32},"end":{"line":205,"column":72}},"type":"cond-expr","locations":[{"start":{"line":205,"column":44},"end":{"line":205,"column":54}},{"start":{"line":205,"column":57},"end":{"line":205,"column":72}}]},"34":{"loc":{"start":{"line":208,"column":6},"end":{"line":227,"column":7}},"type":"if","locations":[{"start":{"line":208,"column":6},"end":{"line":227,"column":7}},{"start":{"line":208,"column":6},"end":{"line":227,"column":7}}]},"35":{"loc":{"start":{"line":222,"column":8},"end":{"line":226,"column":9}},"type":"if","locations":[{"start":{"line":222,"column":8},"end":{"line":226,"column":9}},{"start":{"line":222,"column":8},"end":{"line":226,"column":9}}]},"36":{"loc":{"start":{"line":231,"column":6},"end":{"line":231,"column":109}},"type":"if","locations":[{"start":{"line":231,"column":6},"end":{"line":231,"column":109}},{"start":{"line":231,"column":6},"end":{"line":231,"column":109}}]},"37":{"loc":{"start":{"line":231,"column":45},"end":{"line":231,"column":83}},"type":"cond-expr","locations":[{"start":{"line":231,"column":57},"end":{"line":231,"column":66}},{"start":{"line":231,"column":69},"end":{"line":231,"column":83}}]},"38":{"loc":{"start":{"line":236,"column":8},"end":{"line":236,"column":62}},"type":"if","locations":[{"start":{"line":236,"column":8},"end":{"line":236,"column":62}},{"start":{"line":236,"column":8},"end":{"line":236,"column":62}}]},"39":{"loc":{"start":{"line":243,"column":8},"end":{"line":243,"column":64}},"type":"if","locations":[{"start":{"line":243,"column":8},"end":{"line":243,"column":64}},{"start":{"line":243,"column":8},"end":{"line":243,"column":64}}]},"40":{"loc":{"start":{"line":257,"column":30},"end":{"line":257,"column":68}},"type":"cond-expr","locations":[{"start":{"line":257,"column":42},"end":{"line":257,"column":51}},{"start":{"line":257,"column":54},"end":{"line":257,"column":68}}]}},"s":{"1":1,"2":1,"3":495,"4":495,"5":317,"6":31,"7":31,"8":39,"9":3,"10":3,"11":0,"12":36,"13":25,"14":35,"15":32,"16":1,"17":1,"18":55,"19":55,"20":50,"21":17,"22":17,"23":17,"24":0,"25":17,"26":9,"27":8,"28":31,"29":450,"30":1,"31":195,"32":195,"33":180,"34":180,"35":12,"36":168,"37":13,"38":13,"39":13,"40":13,"41":1,"42":12,"43":194,"44":216,"45":216,"46":209,"47":181,"48":1,"49":289,"50":1,"51":43,"52":43,"53":43,"54":39,"55":1,"56":55,"57":55,"58":55,"59":41,"60":1,"61":1885,"62":1,"63":384,"64":1,"65":726,"66":16,"67":10,"68":583,"69":46,"70":46,"71":46,"72":46,"73":61,"74":26,"75":1,"76":639,"77":639,"78":639,"79":320,"80":241,"81":79,"82":320,"83":2,"84":318,"85":5,"86":313,"87":33,"88":23,"89":18,"90":280,"91":280,"92":15,"93":280,"94":267,"95":10,"96":267,"97":267,"98":611,"99":1,"100":254,"101":1,"102":629,"103":629,"104":629,"105":615,"106":576,"107":39,"108":39,"109":39,"110":39,"111":1,"112":1338,"113":1094,"114":113,"115":981,"116":254,"117":254,"118":22,"119":232,"120":959,"121":9,"122":0,"123":9,"124":66,"125":79,"126":76,"127":79,"128":60,"129":88,"130":117,"131":108,"132":80,"133":36,"134":35,"135":36,"136":31,"137":9},"f":{"1":495,"2":195,"3":289,"4":43,"5":55,"6":1885,"7":384,"8":726,"9":639,"10":254,"11":629,"12":1338},"b":{"1":[495,0],"2":[309,309,309,317,31,35,1,55,17,9,31],"3":[3,36],"4":[3,0],"5":[3,0],"6":[17,0],"7":[8,1],"8":[180,15],"9":[12,168],"10":[180,177],"11":[13,155],"12":[168,165],"13":[1,12],"14":[13,4,2],"15":[209,7],"16":[1885,28,25],"17":[16,583,46,61,26],"18":[10,6],"19":[16,9],"20":[241,79],"21":[2,318],"22":[320,59],"23":[5,313],"24":[33,280],"25":[10,257],"26":[629,280],"27":[629,280],"28":[629,307],"29":[576,39],"30":[1094,9,66,88,36,3,36,9],"31":[113,981],"32":[1094,491,402],"33":[86,27],"34":[254,727],"35":[22,232],"36":[0,9],"37":[0,0],"38":[76,3],"39":[108,9],"40":[0,9]},"hash":"e5cf49673e0d2c7539d9376c61cba4715defc0b8"}
+,"/home/travis/build/babel/babylon/src/parser/node.js": {"path":"/home/travis/build/babel/babylon/src/parser/node.js","statementMap":{"1":{"start":{"line":6,"column":11},"end":{"line":6,"column":27}},"2":{"start":{"line":7,"column":20},"end":{"line":7,"column":76}},"3":{"start":{"line":11,"column":4},"end":{"line":11,"column":19}},"4":{"start":{"line":12,"column":4},"end":{"line":12,"column":21}},"5":{"start":{"line":13,"column":4},"end":{"line":13,"column":17}},"6":{"start":{"line":14,"column":4},"end":{"line":14,"column":39}},"7":{"start":{"line":15,"column":4},"end":{"line":15,"column":47}},"8":{"start":{"line":15,"column":18},"end":{"line":15,"column":47}},"9":{"start":{"line":24,"column":18},"end":{"line":24,"column":26}},"10":{"start":{"line":25,"column":4},"end":{"line":30,"column":5}},"11":{"start":{"line":27,"column":6},"end":{"line":29,"column":7}},"12":{"start":{"line":28,"column":8},"end":{"line":28,"column":31}},"13":{"start":{"line":32,"column":4},"end":{"line":32,"column":17}},"14":{"start":{"line":36,"column":0},"end":{"line":38,"column":2}},"15":{"start":{"line":37,"column":2},"end":{"line":37,"column":72}},"16":{"start":{"line":40,"column":0},"end":{"line":42,"column":2}},"17":{"start":{"line":41,"column":2},"end":{"line":41,"column":43}},"18":{"start":{"line":45,"column":2},"end":{"line":45,"column":19}},"19":{"start":{"line":46,"column":2},"end":{"line":46,"column":17}},"20":{"start":{"line":47,"column":2},"end":{"line":47,"column":21}},"21":{"start":{"line":48,"column":2},"end":{"line":48,"column":28}},"22":{"start":{"line":49,"column":2},"end":{"line":49,"column":14}},"23":{"start":{"line":54,"column":0},"end":{"line":56,"column":2}},"24":{"start":{"line":55,"column":2},"end":{"line":55,"column":94}},"25":{"start":{"line":60,"column":0},"end":{"line":62,"column":2}},"26":{"start":{"line":61,"column":2},"end":{"line":61,"column":55}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":10,"column":2},"end":{"line":10,"column":3}},"loc":{"start":{"line":10,"column":69},"end":{"line":16,"column":3}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":23,"column":2},"end":{"line":23,"column":3}},"loc":{"start":{"line":23,"column":18},"end":{"line":33,"column":3}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":36,"column":15},"end":{"line":36,"column":16}},"loc":{"start":{"line":36,"column":27},"end":{"line":38,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":40,"column":17},"end":{"line":40,"column":18}},"loc":{"start":{"line":40,"column":37},"end":{"line":42,"column":1}}},"5":{"name":"finishNodeAt","decl":{"start":{"line":44,"column":9},"end":{"line":44,"column":21}},"loc":{"start":{"line":44,"column":44},"end":{"line":50,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":54,"column":16},"end":{"line":54,"column":17}},"loc":{"start":{"line":54,"column":38},"end":{"line":56,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":60,"column":18},"end":{"line":60,"column":19}},"loc":{"start":{"line":60,"column":50},"end":{"line":62,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":15,"column":4},"end":{"line":15,"column":47}},"type":"if","locations":[{"start":{"line":15,"column":4},"end":{"line":15,"column":47}},{"start":{"line":15,"column":4},"end":{"line":15,"column":47}}]},"2":{"loc":{"start":{"line":27,"column":6},"end":{"line":29,"column":7}},"type":"if","locations":[{"start":{"line":27,"column":6},"end":{"line":29,"column":7}},{"start":{"line":27,"column":6},"end":{"line":29,"column":7}}]}},"s":{"1":1,"2":1,"3":21359,"4":21359,"5":21359,"6":21359,"7":21359,"8":6,"9":126,"10":126,"11":632,"12":630,"13":126,"14":1,"15":19360,"16":1,"17":1873,"18":15001,"19":15001,"20":15001,"21":15001,"22":15001,"23":1,"24":14554,"25":1,"26":447},"f":{"1":21359,"2":126,"3":19360,"4":1873,"5":15001,"6":14554,"7":447},"b":{"1":[6,21353],"2":[630,2]},"hash":"ffcbe25b257f58ca63f96ef73c3940f99e1cd030"}
+,"/home/travis/build/babel/babylon/src/parser/statement.js": {"path":"/home/travis/build/babel/babylon/src/parser/statement.js","statementMap":{"1":{"start":{"line":8,"column":11},"end":{"line":8,"column":27}},"2":{"start":{"line":17,"column":0},"end":{"line":27,"column":2}},"3":{"start":{"line":18,"column":2},"end":{"line":18,"column":47}},"4":{"start":{"line":20,"column":2},"end":{"line":20,"column":51}},"5":{"start":{"line":22,"column":2},"end":{"line":22,"column":54}},"6":{"start":{"line":23,"column":2},"end":{"line":23,"column":38}},"7":{"start":{"line":24,"column":2},"end":{"line":24,"column":36}},"8":{"start":{"line":26,"column":2},"end":{"line":26,"column":39}},"9":{"start":{"line":29,"column":18},"end":{"line":29,"column":32}},"10":{"start":{"line":29,"column":48},"end":{"line":29,"column":64}},"11":{"start":{"line":33,"column":0},"end":{"line":48,"column":2}},"12":{"start":{"line":34,"column":13},"end":{"line":34,"column":28}},"13":{"start":{"line":36,"column":25},"end":{"line":36,"column":69}},"14":{"start":{"line":37,"column":25},"end":{"line":37,"column":69}},"15":{"start":{"line":39,"column":12},"end":{"line":39,"column":50}},"16":{"start":{"line":40,"column":12},"end":{"line":40,"column":53}},"17":{"start":{"line":42,"column":2},"end":{"line":42,"column":46}},"18":{"start":{"line":43,"column":2},"end":{"line":43,"column":51}},"19":{"start":{"line":45,"column":2},"end":{"line":45,"column":100}},"20":{"start":{"line":47,"column":2},"end":{"line":47,"column":75}},"21":{"start":{"line":57,"column":0},"end":{"line":139,"column":2}},"22":{"start":{"line":58,"column":2},"end":{"line":60,"column":3}},"23":{"start":{"line":59,"column":4},"end":{"line":59,"column":31}},"24":{"start":{"line":62,"column":18},"end":{"line":62,"column":33}},"25":{"start":{"line":62,"column":42},"end":{"line":62,"column":58}},"26":{"start":{"line":68,"column":2},"end":{"line":124,"column":3}},"27":{"start":{"line":69,"column":39},"end":{"line":69,"column":104}},"28":{"start":{"line":70,"column":23},"end":{"line":70,"column":64}},"29":{"start":{"line":71,"column":17},"end":{"line":71,"column":52}},"30":{"start":{"line":72,"column":18},"end":{"line":72,"column":54}},"31":{"start":{"line":74,"column":6},"end":{"line":74,"column":42}},"32":{"start":{"line":74,"column":24},"end":{"line":74,"column":42}},"33":{"start":{"line":75,"column":6},"end":{"line":75,"column":47}},"34":{"start":{"line":78,"column":6},"end":{"line":78,"column":42}},"35":{"start":{"line":78,"column":24},"end":{"line":78,"column":42}},"36":{"start":{"line":79,"column":6},"end":{"line":79,"column":32}},"37":{"start":{"line":80,"column":6},"end":{"line":80,"column":41}},"38":{"start":{"line":82,"column":17},"end":{"line":82,"column":52}},"39":{"start":{"line":83,"column":21},"end":{"line":83,"column":60}},"40":{"start":{"line":84,"column":21},"end":{"line":84,"column":60}},"41":{"start":{"line":85,"column":20},"end":{"line":85,"column":58}},"42":{"start":{"line":86,"column":18},"end":{"line":86,"column":54}},"43":{"start":{"line":90,"column":6},"end":{"line":90,"column":42}},"44":{"start":{"line":90,"column":24},"end":{"line":90,"column":42}},"45":{"start":{"line":93,"column":6},"end":{"line":93,"column":53}},"46":{"start":{"line":95,"column":20},"end":{"line":95,"column":58}},"47":{"start":{"line":96,"column":19},"end":{"line":96,"column":56}},"48":{"start":{"line":97,"column":20},"end":{"line":97,"column":45}},"49":{"start":{"line":98,"column":18},"end":{"line":98,"column":56}},"50":{"start":{"line":101,"column":6},"end":{"line":109,"column":7}},"51":{"start":{"line":102,"column":8},"end":{"line":104,"column":9}},"52":{"start":{"line":103,"column":10},"end":{"line":103,"column":97}},"53":{"start":{"line":106,"column":8},"end":{"line":108,"column":9}},"54":{"start":{"line":107,"column":10},"end":{"line":107,"column":106}},"55":{"start":{"line":110,"column":6},"end":{"line":110,"column":88}},"56":{"start":{"line":113,"column":6},"end":{"line":123,"column":7}},"57":{"start":{"line":115,"column":20},"end":{"line":115,"column":38}},"58":{"start":{"line":116,"column":8},"end":{"line":116,"column":20}},"59":{"start":{"line":117,"column":8},"end":{"line":122,"column":9}},"60":{"start":{"line":118,"column":10},"end":{"line":118,"column":36}},"61":{"start":{"line":119,"column":10},"end":{"line":119,"column":61}},"62":{"start":{"line":121,"column":10},"end":{"line":121,"column":29}},"63":{"start":{"line":131,"column":18},"end":{"line":131,"column":34}},"64":{"start":{"line":132,"column":13},"end":{"line":132,"column":35}},"65":{"start":{"line":134,"column":2},"end":{"line":138,"column":3}},"66":{"start":{"line":135,"column":4},"end":{"line":135,"column":61}},"67":{"start":{"line":137,"column":4},"end":{"line":137,"column":53}},"68":{"start":{"line":141,"column":0},"end":{"line":146,"column":2}},"69":{"start":{"line":142,"column":2},"end":{"line":145,"column":3}},"70":{"start":{"line":143,"column":4},"end":{"line":143,"column":44}},"71":{"start":{"line":144,"column":4},"end":{"line":144,"column":31}},"72":{"start":{"line":148,"column":0},"end":{"line":160,"column":2}},"73":{"start":{"line":149,"column":2},"end":{"line":151,"column":3}},"74":{"start":{"line":150,"column":4},"end":{"line":150,"column":54}},"75":{"start":{"line":153,"column":2},"end":{"line":155,"column":3}},"76":{"start":{"line":154,"column":4},"end":{"line":154,"column":11}},"77":{"start":{"line":157,"column":2},"end":{"line":159,"column":3}},"78":{"start":{"line":158,"column":4},"end":{"line":158,"column":95}},"79":{"start":{"line":162,"column":0},"end":{"line":170,"column":2}},"80":{"start":{"line":163,"column":2},"end":{"line":165,"column":3}},"81":{"start":{"line":164,"column":4},"end":{"line":164,"column":22}},"82":{"start":{"line":166,"column":13},"end":{"line":166,"column":29}},"83":{"start":{"line":167,"column":2},"end":{"line":167,"column":14}},"84":{"start":{"line":168,"column":2},"end":{"line":168,"column":44}},"85":{"start":{"line":169,"column":2},"end":{"line":169,"column":44}},"86":{"start":{"line":172,"column":0},"end":{"line":197,"column":2}},"87":{"start":{"line":173,"column":16},"end":{"line":173,"column":35}},"88":{"start":{"line":174,"column":2},"end":{"line":174,"column":14}},"89":{"start":{"line":176,"column":2},"end":{"line":183,"column":3}},"90":{"start":{"line":177,"column":4},"end":{"line":177,"column":22}},"91":{"start":{"line":178,"column":9},"end":{"line":183,"column":3}},"92":{"start":{"line":179,"column":4},"end":{"line":179,"column":22}},"93":{"start":{"line":181,"column":4},"end":{"line":181,"column":40}},"94":{"start":{"line":182,"column":4},"end":{"line":182,"column":21}},"95":{"start":{"line":188,"column":2},"end":{"line":194,"column":3}},"96":{"start":{"line":189,"column":14},"end":{"line":189,"column":34}},"97":{"start":{"line":190,"column":4},"end":{"line":193,"column":5}},"98":{"start":{"line":191,"column":6},"end":{"line":191,"column":70}},"99":{"start":{"line":191,"column":64},"end":{"line":191,"column":70}},"100":{"start":{"line":192,"column":6},"end":{"line":192,"column":39}},"101":{"start":{"line":192,"column":33},"end":{"line":192,"column":39}},"102":{"start":{"line":195,"column":2},"end":{"line":195,"column":87}},"103":{"start":{"line":195,"column":38},"end":{"line":195,"column":87}},"104":{"start":{"line":196,"column":2},"end":{"line":196,"column":81}},"105":{"start":{"line":199,"column":0},"end":{"line":203,"column":2}},"106":{"start":{"line":200,"column":2},"end":{"line":200,"column":14}},"107":{"start":{"line":201,"column":2},"end":{"line":201,"column":19}},"108":{"start":{"line":202,"column":2},"end":{"line":202,"column":52}},"109":{"start":{"line":205,"column":0},"end":{"line":214,"column":2}},"110":{"start":{"line":206,"column":2},"end":{"line":206,"column":14}},"111":{"start":{"line":207,"column":2},"end":{"line":207,"column":36}},"112":{"start":{"line":208,"column":2},"end":{"line":208,"column":41}},"113":{"start":{"line":209,"column":2},"end":{"line":209,"column":26}},"114":{"start":{"line":210,"column":2},"end":{"line":210,"column":25}},"115":{"start":{"line":211,"column":2},"end":{"line":211,"column":42}},"116":{"start":{"line":212,"column":2},"end":{"line":212,"column":20}},"117":{"start":{"line":213,"column":2},"end":{"line":213,"column":51}},"118":{"start":{"line":224,"column":0},"end":{"line":272,"column":2}},"119":{"start":{"line":225,"column":2},"end":{"line":225,"column":14}},"120":{"start":{"line":226,"column":2},"end":{"line":226,"column":36}},"121":{"start":{"line":228,"column":17},"end":{"line":228,"column":22}},"122":{"start":{"line":229,"column":2},"end":{"line":232,"column":3}},"123":{"start":{"line":230,"column":6},"end":{"line":230,"column":22}},"124":{"start":{"line":231,"column":6},"end":{"line":231,"column":18}},"125":{"start":{"line":233,"column":2},"end":{"line":233,"column":25}},"126":{"start":{"line":235,"column":2},"end":{"line":240,"column":3}},"127":{"start":{"line":236,"column":4},"end":{"line":238,"column":5}},"128":{"start":{"line":237,"column":6},"end":{"line":237,"column":24}},"129":{"start":{"line":239,"column":4},"end":{"line":239,"column":37}},"130":{"start":{"line":242,"column":2},"end":{"line":257,"column":3}},"131":{"start":{"line":243,"column":15},"end":{"line":243,"column":31}},"132":{"start":{"line":243,"column":43},"end":{"line":243,"column":58}},"133":{"start":{"line":244,"column":4},"end":{"line":244,"column":16}},"134":{"start":{"line":245,"column":4},"end":{"line":245,"column":39}},"135":{"start":{"line":246,"column":4},"end":{"line":246,"column":49}},"136":{"start":{"line":248,"column":4},"end":{"line":252,"column":5}},"137":{"start":{"line":249,"column":6},"end":{"line":251,"column":7}},"138":{"start":{"line":250,"column":8},"end":{"line":250,"column":53}},"139":{"start":{"line":253,"column":4},"end":{"line":255,"column":5}},"140":{"start":{"line":254,"column":6},"end":{"line":254,"column":24}},"141":{"start":{"line":256,"column":4},"end":{"line":256,"column":37}},"142":{"start":{"line":259,"column":31},"end":{"line":259,"column":41}},"143":{"start":{"line":260,"column":13},"end":{"line":260,"column":63}},"144":{"start":{"line":261,"column":2},"end":{"line":267,"column":3}},"145":{"start":{"line":262,"column":4},"end":{"line":262,"column":28}},"146":{"start":{"line":263,"column":4},"end":{"line":263,"column":25}},"147":{"start":{"line":264,"column":4},"end":{"line":264,"column":49}},"148":{"start":{"line":265,"column":9},"end":{"line":267,"column":3}},"149":{"start":{"line":266,"column":4},"end":{"line":266,"column":50}},"150":{"start":{"line":268,"column":2},"end":{"line":270,"column":3}},"151":{"start":{"line":269,"column":4},"end":{"line":269,"column":22}},"152":{"start":{"line":271,"column":2},"end":{"line":271,"column":35}},"153":{"start":{"line":274,"column":0},"end":{"line":277,"column":2}},"154":{"start":{"line":275,"column":2},"end":{"line":275,"column":14}},"155":{"start":{"line":276,"column":2},"end":{"line":276,"column":40}},"156":{"start":{"line":279,"column":0},"end":{"line":285,"column":2}},"157":{"start":{"line":280,"column":2},"end":{"line":280,"column":14}},"158":{"start":{"line":281,"column":2},"end":{"line":281,"column":42}},"159":{"start":{"line":282,"column":2},"end":{"line":282,"column":47}},"160":{"start":{"line":283,"column":2},"end":{"line":283,"column":74}},"161":{"start":{"line":284,"column":2},"end":{"line":284,"column":46}},"162":{"start":{"line":287,"column":0},"end":{"line":306,"column":2}},"163":{"start":{"line":288,"column":2},"end":{"line":290,"column":3}},"164":{"start":{"line":289,"column":4},"end":{"line":289,"column":65}},"165":{"start":{"line":292,"column":2},"end":{"line":292,"column":14}},"166":{"start":{"line":298,"column":2},"end":{"line":303,"column":3}},"167":{"start":{"line":299,"column":4},"end":{"line":299,"column":25}},"168":{"start":{"line":301,"column":4},"end":{"line":301,"column":43}},"169":{"start":{"line":302,"column":4},"end":{"line":302,"column":21}},"170":{"start":{"line":305,"column":2},"end":{"line":305,"column":50}},"171":{"start":{"line":308,"column":0},"end":{"line":347,"column":2}},"172":{"start":{"line":309,"column":2},"end":{"line":309,"column":14}},"173":{"start":{"line":310,"column":2},"end":{"line":310,"column":50}},"174":{"start":{"line":311,"column":2},"end":{"line":311,"column":18}},"175":{"start":{"line":312,"column":2},"end":{"line":312,"column":25}},"176":{"start":{"line":313,"column":2},"end":{"line":313,"column":38}},"177":{"start":{"line":320,"column":2},"end":{"line":342,"column":3}},"178":{"start":{"line":321,"column":4},"end":{"line":341,"column":5}},"179":{"start":{"line":322,"column":19},"end":{"line":322,"column":39}},"180":{"start":{"line":323,"column":6},"end":{"line":323,"column":50}},"181":{"start":{"line":323,"column":15},"end":{"line":323,"column":50}},"182":{"start":{"line":324,"column":6},"end":{"line":324,"column":46}},"183":{"start":{"line":325,"column":6},"end":{"line":325,"column":26}},"184":{"start":{"line":326,"column":6},"end":{"line":326,"column":18}},"185":{"start":{"line":327,"column":6},"end":{"line":333,"column":7}},"186":{"start":{"line":328,"column":8},"end":{"line":328,"column":42}},"187":{"start":{"line":330,"column":8},"end":{"line":330,"column":88}},"188":{"start":{"line":330,"column":24},"end":{"line":330,"column":88}},"189":{"start":{"line":331,"column":8},"end":{"line":331,"column":26}},"190":{"start":{"line":332,"column":8},"end":{"line":332,"column":24}},"191":{"start":{"line":334,"column":6},"end":{"line":334,"column":28}},"192":{"start":{"line":336,"column":6},"end":{"line":340,"column":7}},"193":{"start":{"line":337,"column":8},"end":{"line":337,"column":55}},"194":{"start":{"line":339,"column":8},"end":{"line":339,"column":26}},"195":{"start":{"line":343,"column":2},"end":{"line":343,"column":46}},"196":{"start":{"line":343,"column":11},"end":{"line":343,"column":46}},"197":{"start":{"line":344,"column":2},"end":{"line":344,"column":14}},"198":{"start":{"line":345,"column":2},"end":{"line":345,"column":26}},"199":{"start":{"line":346,"column":2},"end":{"line":346,"column":50}},"200":{"start":{"line":349,"column":0},"end":{"line":356,"column":2}},"201":{"start":{"line":350,"column":2},"end":{"line":350,"column":14}},"202":{"start":{"line":351,"column":2},"end":{"line":352,"column":69}},"203":{"start":{"line":352,"column":4},"end":{"line":352,"column":69}},"204":{"start":{"line":353,"column":2},"end":{"line":353,"column":41}},"205":{"start":{"line":354,"column":2},"end":{"line":354,"column":19}},"206":{"start":{"line":355,"column":2},"end":{"line":355,"column":49}},"207":{"start":{"line":360,"column":12},"end":{"line":360,"column":14}},"208":{"start":{"line":362,"column":0},"end":{"line":389,"column":2}},"209":{"start":{"line":363,"column":2},"end":{"line":363,"column":14}},"210":{"start":{"line":365,"column":2},"end":{"line":365,"column":33}},"211":{"start":{"line":366,"column":2},"end":{"line":366,"column":22}},"212":{"start":{"line":368,"column":2},"end":{"line":379,"column":3}},"213":{"start":{"line":369,"column":17},"end":{"line":369,"column":33}},"214":{"start":{"line":370,"column":4},"end":{"line":370,"column":16}},"215":{"start":{"line":372,"column":4},"end":{"line":372,"column":27}},"216":{"start":{"line":373,"column":4},"end":{"line":373,"column":43}},"217":{"start":{"line":374,"column":4},"end":{"line":374,"column":60}},"218":{"start":{"line":375,"column":4},"end":{"line":375,"column":27}},"219":{"start":{"line":377,"column":4},"end":{"line":377,"column":36}},"220":{"start":{"line":378,"column":4},"end":{"line":378,"column":58}},"221":{"start":{"line":381,"column":2},"end":{"line":381,"column":31}},"222":{"start":{"line":382,"column":2},"end":{"line":382,"column":68}},"223":{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},"224":{"start":{"line":385,"column":4},"end":{"line":385,"column":62}},"225":{"start":{"line":388,"column":2},"end":{"line":388,"column":47}},"226":{"start":{"line":391,"column":0},"end":{"line":396,"column":2}},"227":{"start":{"line":392,"column":2},"end":{"line":392,"column":14}},"228":{"start":{"line":393,"column":2},"end":{"line":393,"column":35}},"229":{"start":{"line":394,"column":2},"end":{"line":394,"column":19}},"230":{"start":{"line":395,"column":2},"end":{"line":395,"column":54}},"231":{"start":{"line":398,"column":0},"end":{"line":405,"column":2}},"232":{"start":{"line":399,"column":2},"end":{"line":399,"column":14}},"233":{"start":{"line":400,"column":2},"end":{"line":400,"column":42}},"234":{"start":{"line":401,"column":2},"end":{"line":401,"column":36}},"235":{"start":{"line":402,"column":2},"end":{"line":402,"column":41}},"236":{"start":{"line":403,"column":2},"end":{"line":403,"column":26}},"237":{"start":{"line":404,"column":2},"end":{"line":404,"column":49}},"238":{"start":{"line":407,"column":0},"end":{"line":413,"column":2}},"239":{"start":{"line":408,"column":2},"end":{"line":408,"column":79}},"240":{"start":{"line":408,"column":25},"end":{"line":408,"column":79}},"241":{"start":{"line":409,"column":2},"end":{"line":409,"column":14}},"242":{"start":{"line":410,"column":2},"end":{"line":410,"column":44}},"243":{"start":{"line":411,"column":2},"end":{"line":411,"column":41}},"244":{"start":{"line":412,"column":2},"end":{"line":412,"column":48}},"245":{"start":{"line":415,"column":0},"end":{"line":418,"column":2}},"246":{"start":{"line":416,"column":2},"end":{"line":416,"column":14}},"247":{"start":{"line":417,"column":2},"end":{"line":417,"column":49}},"248":{"start":{"line":420,"column":0},"end":{"line":443,"column":2}},"249":{"start":{"line":421,"column":2},"end":{"line":425,"column":3}},"250":{"start":{"line":422,"column":4},"end":{"line":424,"column":5}},"251":{"start":{"line":423,"column":6},"end":{"line":423,"column":73}},"252":{"start":{"line":427,"column":13},"end":{"line":427,"column":87}},"253":{"start":{"line":428,"column":2},"end":{"line":436,"column":3}},"254":{"start":{"line":429,"column":16},"end":{"line":429,"column":36}},"255":{"start":{"line":430,"column":4},"end":{"line":435,"column":5}},"256":{"start":{"line":431,"column":6},"end":{"line":431,"column":46}},"257":{"start":{"line":432,"column":6},"end":{"line":432,"column":24}},"258":{"start":{"line":434,"column":6},"end":{"line":434,"column":12}},"259":{"start":{"line":438,"column":2},"end":{"line":438,"column":90}},"260":{"start":{"line":439,"column":2},"end":{"line":439,"column":40}},"261":{"start":{"line":440,"column":2},"end":{"line":440,"column":26}},"262":{"start":{"line":441,"column":2},"end":{"line":441,"column":20}},"263":{"start":{"line":442,"column":2},"end":{"line":442,"column":51}},"264":{"start":{"line":445,"column":0},"end":{"line":449,"column":2}},"265":{"start":{"line":446,"column":2},"end":{"line":446,"column":25}},"266":{"start":{"line":447,"column":2},"end":{"line":447,"column":19}},"267":{"start":{"line":448,"column":2},"end":{"line":448,"column":54}},"268":{"start":{"line":455,"column":0},"end":{"line":460,"column":2}},"269":{"start":{"line":456,"column":13},"end":{"line":456,"column":29}},"270":{"start":{"line":457,"column":2},"end":{"line":457,"column":25}},"271":{"start":{"line":458,"column":2},"end":{"line":458,"column":63}},"272":{"start":{"line":459,"column":2},"end":{"line":459,"column":49}},"273":{"start":{"line":464,"column":0},"end":{"line":504,"column":2}},"274":{"start":{"line":465,"column":2},"end":{"line":465,"column":17}},"275":{"start":{"line":466,"column":2},"end":{"line":466,"column":23}},"276":{"start":{"line":468,"column":27},"end":{"line":468,"column":32}},"277":{"start":{"line":472,"column":2},"end":{"line":499,"column":3}},"278":{"start":{"line":473,"column":4},"end":{"line":475,"column":5}},"279":{"start":{"line":474,"column":6},"end":{"line":474,"column":47}},"280":{"start":{"line":477,"column":15},"end":{"line":477,"column":50}},"281":{"start":{"line":479,"column":4},"end":{"line":495,"column":5}},"282":{"start":{"line":482,"column":22},"end":{"line":482,"column":48}},"283":{"start":{"line":483,"column":6},"end":{"line":483,"column":38}},"284":{"start":{"line":485,"column":6},"end":{"line":492,"column":7}},"285":{"start":{"line":486,"column":8},"end":{"line":486,"column":38}},"286":{"start":{"line":487,"column":8},"end":{"line":487,"column":29}},"287":{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},"288":{"start":{"line":490,"column":10},"end":{"line":490,"column":68}},"289":{"start":{"line":494,"column":6},"end":{"line":494,"column":15}},"290":{"start":{"line":497,"column":4},"end":{"line":497,"column":30}},"291":{"start":{"line":498,"column":4},"end":{"line":498,"column":25}},"292":{"start":{"line":501,"column":2},"end":{"line":503,"column":3}},"293":{"start":{"line":502,"column":4},"end":{"line":502,"column":26}},"294":{"start":{"line":510,"column":0},"end":{"line":520,"column":2}},"295":{"start":{"line":511,"column":2},"end":{"line":511,"column":19}},"296":{"start":{"line":512,"column":2},"end":{"line":512,"column":23}},"297":{"start":{"line":513,"column":2},"end":{"line":513,"column":66}},"298":{"start":{"line":514,"column":2},"end":{"line":514,"column":23}},"299":{"start":{"line":515,"column":2},"end":{"line":515,"column":70}},"300":{"start":{"line":516,"column":2},"end":{"line":516,"column":25}},"301":{"start":{"line":517,"column":2},"end":{"line":517,"column":41}},"302":{"start":{"line":518,"column":2},"end":{"line":518,"column":26}},"303":{"start":{"line":519,"column":2},"end":{"line":519,"column":47}},"304":{"start":{"line":525,"column":0},"end":{"line":540,"column":2}},"305":{"start":{"line":527,"column":2},"end":{"line":533,"column":3}},"306":{"start":{"line":528,"column":4},"end":{"line":528,"column":29}},"307":{"start":{"line":529,"column":4},"end":{"line":529,"column":31}},"308":{"start":{"line":531,"column":4},"end":{"line":531,"column":68}},"309":{"start":{"line":532,"column":4},"end":{"line":532,"column":16}},"310":{"start":{"line":534,"column":2},"end":{"line":534,"column":19}},"311":{"start":{"line":535,"column":2},"end":{"line":535,"column":38}},"312":{"start":{"line":536,"column":2},"end":{"line":536,"column":25}},"313":{"start":{"line":537,"column":2},"end":{"line":537,"column":41}},"314":{"start":{"line":538,"column":2},"end":{"line":538,"column":26}},"315":{"start":{"line":539,"column":2},"end":{"line":539,"column":37}},"316":{"start":{"line":544,"column":0},"end":{"line":563,"column":2}},"317":{"start":{"line":545,"column":2},"end":{"line":545,"column":25}},"318":{"start":{"line":546,"column":2},"end":{"line":546,"column":27}},"319":{"start":{"line":547,"column":2},"end":{"line":561,"column":3}},"320":{"start":{"line":548,"column":15},"end":{"line":548,"column":31}},"321":{"start":{"line":549,"column":4},"end":{"line":549,"column":28}},"322":{"start":{"line":550,"column":4},"end":{"line":558,"column":5}},"323":{"start":{"line":551,"column":6},"end":{"line":551,"column":47}},"324":{"start":{"line":552,"column":11},"end":{"line":558,"column":5}},"325":{"start":{"line":553,"column":6},"end":{"line":553,"column":24}},"326":{"start":{"line":554,"column":11},"end":{"line":558,"column":5}},"327":{"start":{"line":555,"column":6},"end":{"line":555,"column":100}},"328":{"start":{"line":557,"column":6},"end":{"line":557,"column":23}},"329":{"start":{"line":559,"column":4},"end":{"line":559,"column":72}},"330":{"start":{"line":560,"column":4},"end":{"line":560,"column":35}},"331":{"start":{"line":560,"column":29},"end":{"line":560,"column":35}},"332":{"start":{"line":562,"column":2},"end":{"line":562,"column":14}},"333":{"start":{"line":565,"column":0},"end":{"line":568,"column":2}},"334":{"start":{"line":566,"column":2},"end":{"line":566,"column":36}},"335":{"start":{"line":567,"column":2},"end":{"line":567,"column":32}},"336":{"start":{"line":573,"column":0},"end":{"line":602,"column":2}},"337":{"start":{"line":574,"column":20},"end":{"line":574,"column":39}},"338":{"start":{"line":575,"column":2},"end":{"line":575,"column":30}},"339":{"start":{"line":577,"column":2},"end":{"line":577,"column":35}},"340":{"start":{"line":579,"column":2},"end":{"line":586,"column":3}},"341":{"start":{"line":580,"column":4},"end":{"line":585,"column":5}},"342":{"start":{"line":581,"column":6},"end":{"line":581,"column":24}},"343":{"start":{"line":583,"column":6},"end":{"line":583,"column":28}},"344":{"start":{"line":584,"column":6},"end":{"line":584,"column":18}},"345":{"start":{"line":588,"column":2},"end":{"line":590,"column":3}},"346":{"start":{"line":589,"column":4},"end":{"line":589,"column":22}},"347":{"start":{"line":592,"column":2},"end":{"line":594,"column":3}},"348":{"start":{"line":593,"column":4},"end":{"line":593,"column":44}},"349":{"start":{"line":596,"column":2},"end":{"line":596,"column":33}},"350":{"start":{"line":597,"column":2},"end":{"line":597,"column":52}},"351":{"start":{"line":599,"column":2},"end":{"line":599,"column":36}},"352":{"start":{"line":601,"column":2},"end":{"line":601,"column":91}},"353":{"start":{"line":604,"column":0},"end":{"line":607,"column":2}},"354":{"start":{"line":605,"column":2},"end":{"line":605,"column":25}},"355":{"start":{"line":606,"column":2},"end":{"line":606,"column":49}},"356":{"start":{"line":612,"column":0},"end":{"line":618,"column":2}},"357":{"start":{"line":613,"column":2},"end":{"line":613,"column":14}},"358":{"start":{"line":614,"column":2},"end":{"line":614,"column":51}},"359":{"start":{"line":615,"column":2},"end":{"line":615,"column":29}},"360":{"start":{"line":616,"column":2},"end":{"line":616,"column":28}},"361":{"start":{"line":617,"column":2},"end":{"line":617,"column":85}},"362":{"start":{"line":620,"column":0},"end":{"line":622,"column":2}},"363":{"start":{"line":621,"column":2},"end":{"line":621,"column":54}},"364":{"start":{"line":624,"column":0},"end":{"line":626,"column":2}},"365":{"start":{"line":625,"column":2},"end":{"line":625,"column":15}},"366":{"start":{"line":628,"column":0},"end":{"line":767,"column":2}},"367":{"start":{"line":630,"column":18},"end":{"line":630,"column":35}},"368":{"start":{"line":631,"column":2},"end":{"line":631,"column":27}},"369":{"start":{"line":633,"column":27},"end":{"line":633,"column":32}},"370":{"start":{"line":634,"column":23},"end":{"line":634,"column":28}},"371":{"start":{"line":635,"column":19},"end":{"line":635,"column":21}},"372":{"start":{"line":636,"column":18},"end":{"line":636,"column":34}},"373":{"start":{"line":638,"column":2},"end":{"line":638,"column":22}},"374":{"start":{"line":640,"column":2},"end":{"line":640,"column":25}},"375":{"start":{"line":642,"column":2},"end":{"line":758,"column":3}},"376":{"start":{"line":643,"column":4},"end":{"line":645,"column":5}},"377":{"start":{"line":644,"column":6},"end":{"line":644,"column":15}},"378":{"start":{"line":647,"column":4},"end":{"line":650,"column":5}},"379":{"start":{"line":648,"column":6},"end":{"line":648,"column":45}},"380":{"start":{"line":649,"column":6},"end":{"line":649,"column":15}},"381":{"start":{"line":652,"column":17},"end":{"line":652,"column":33}},"382":{"start":{"line":655,"column":4},"end":{"line":658,"column":5}},"383":{"start":{"line":656,"column":6},"end":{"line":656,"column":37}},"384":{"start":{"line":657,"column":6},"end":{"line":657,"column":22}},"385":{"start":{"line":660,"column":28},"end":{"line":660,"column":33}},"386":{"start":{"line":661,"column":24},"end":{"line":661,"column":76}},"387":{"start":{"line":662,"column":22},"end":{"line":662,"column":39}},"388":{"start":{"line":663,"column":19},"end":{"line":663,"column":24}},"389":{"start":{"line":664,"column":18},"end":{"line":664,"column":23}},"390":{"start":{"line":666,"column":4},"end":{"line":666,"column":35}},"391":{"start":{"line":668,"column":4},"end":{"line":668,"column":60}},"392":{"start":{"line":669,"column":4},"end":{"line":673,"column":5}},"393":{"start":{"line":670,"column":6},"end":{"line":670,"column":41}},"394":{"start":{"line":670,"column":23},"end":{"line":670,"column":41}},"395":{"start":{"line":671,"column":6},"end":{"line":671,"column":38}},"396":{"start":{"line":672,"column":6},"end":{"line":672,"column":37}},"397":{"start":{"line":675,"column":4},"end":{"line":685,"column":5}},"398":{"start":{"line":676,"column":6},"end":{"line":679,"column":7}},"399":{"start":{"line":677,"column":8},"end":{"line":677,"column":61}},"400":{"start":{"line":678,"column":8},"end":{"line":678,"column":17}},"401":{"start":{"line":681,"column":6},"end":{"line":684,"column":7}},"402":{"start":{"line":682,"column":8},"end":{"line":682,"column":33}},"403":{"start":{"line":683,"column":8},"end":{"line":683,"column":39}},"404":{"start":{"line":687,"column":24},"end":{"line":687,"column":133}},"405":{"start":{"line":688,"column":4},"end":{"line":692,"column":5}},"406":{"start":{"line":689,"column":6},"end":{"line":689,"column":85}},"407":{"start":{"line":689,"column":66},"end":{"line":689,"column":85}},"408":{"start":{"line":690,"column":6},"end":{"line":690,"column":21}},"409":{"start":{"line":691,"column":6},"end":{"line":691,"column":37}},"410":{"start":{"line":694,"column":4},"end":{"line":694,"column":27}},"411":{"start":{"line":696,"column":4},"end":{"line":729,"column":5}},"412":{"start":{"line":697,"column":20},"end":{"line":697,"column":26}},"413":{"start":{"line":701,"column":6},"end":{"line":705,"column":7}},"414":{"start":{"line":702,"column":8},"end":{"line":702,"column":24}},"415":{"start":{"line":703,"column":8},"end":{"line":703,"column":31}},"416":{"start":{"line":704,"column":8},"end":{"line":704,"column":45}},"417":{"start":{"line":708,"column":26},"end":{"line":711,"column":7}},"418":{"start":{"line":712,"column":6},"end":{"line":719,"column":7}},"419":{"start":{"line":713,"column":8},"end":{"line":713,"column":93}},"420":{"start":{"line":713,"column":28},"end":{"line":713,"column":93}},"421":{"start":{"line":714,"column":8},"end":{"line":714,"column":87}},"422":{"start":{"line":714,"column":22},"end":{"line":714,"column":87}},"423":{"start":{"line":715,"column":8},"end":{"line":715,"column":83}},"424":{"start":{"line":715,"column":25},"end":{"line":715,"column":83}},"425":{"start":{"line":716,"column":8},"end":{"line":716,"column":85}},"426":{"start":{"line":716,"column":21},"end":{"line":716,"column":85}},"427":{"start":{"line":717,"column":8},"end":{"line":717,"column":36}},"428":{"start":{"line":718,"column":8},"end":{"line":718,"column":30}},"429":{"start":{"line":722,"column":30},"end":{"line":725,"column":7}},"430":{"start":{"line":726,"column":6},"end":{"line":728,"column":7}},"431":{"start":{"line":727,"column":8},"end":{"line":727,"column":86}},"432":{"start":{"line":732,"column":4},"end":{"line":736,"column":5}},"433":{"start":{"line":733,"column":6},"end":{"line":733,"column":103}},"434":{"start":{"line":733,"column":30},"end":{"line":733,"column":103}},"435":{"start":{"line":734,"column":6},"end":{"line":734,"column":38}},"436":{"start":{"line":735,"column":6},"end":{"line":735,"column":32}},"437":{"start":{"line":739,"column":4},"end":{"line":741,"column":5}},"438":{"start":{"line":740,"column":6},"end":{"line":740,"column":85}},"439":{"start":{"line":743,"column":4},"end":{"line":743,"column":67}},"440":{"start":{"line":747,"column":4},"end":{"line":757,"column":5}},"441":{"start":{"line":748,"column":23},"end":{"line":748,"column":52}},"442":{"start":{"line":749,"column":6},"end":{"line":756,"column":7}},"443":{"start":{"line":750,"column":20},"end":{"line":750,"column":32}},"444":{"start":{"line":751,"column":8},"end":{"line":755,"column":9}},"445":{"start":{"line":752,"column":10},"end":{"line":752,"column":60}},"446":{"start":{"line":754,"column":10},"end":{"line":754,"column":68}},"447":{"start":{"line":760,"column":2},"end":{"line":762,"column":3}},"448":{"start":{"line":761,"column":4},"end":{"line":761,"column":80}},"449":{"start":{"line":764,"column":2},"end":{"line":764,"column":54}},"450":{"start":{"line":766,"column":2},"end":{"line":766,"column":32}},"451":{"start":{"line":769,"column":0},"end":{"line":779,"column":2}},"452":{"start":{"line":770,"column":2},"end":{"line":776,"column":3}},"453":{"start":{"line":771,"column":4},"end":{"line":771,"column":62}},"454":{"start":{"line":771,"column":44},"end":{"line":771,"column":62}},"455":{"start":{"line":772,"column":4},"end":{"line":772,"column":16}},"456":{"start":{"line":773,"column":4},"end":{"line":773,"column":41}},"457":{"start":{"line":775,"column":4},"end":{"line":775,"column":22}},"458":{"start":{"line":777,"column":2},"end":{"line":777,"column":19}},"459":{"start":{"line":778,"column":2},"end":{"line":778,"column":48}},"460":{"start":{"line":781,"column":0},"end":{"line":784,"column":2}},"461":{"start":{"line":782,"column":2},"end":{"line":782,"column":49}},"462":{"start":{"line":783,"column":2},"end":{"line":783,"column":62}},"463":{"start":{"line":786,"column":0},"end":{"line":796,"column":2}},"464":{"start":{"line":787,"column":2},"end":{"line":795,"column":3}},"465":{"start":{"line":788,"column":4},"end":{"line":788,"column":37}},"466":{"start":{"line":790,"column":4},"end":{"line":794,"column":5}},"467":{"start":{"line":791,"column":6},"end":{"line":791,"column":21}},"468":{"start":{"line":793,"column":6},"end":{"line":793,"column":24}},"469":{"start":{"line":798,"column":0},"end":{"line":800,"column":2}},"470":{"start":{"line":799,"column":2},"end":{"line":799,"column":78}},"471":{"start":{"line":804,"column":0},"end":{"line":860,"column":2}},"472":{"start":{"line":805,"column":2},"end":{"line":805,"column":14}},"473":{"start":{"line":807,"column":2},"end":{"line":857,"column":3}},"474":{"start":{"line":808,"column":20},"end":{"line":808,"column":36}},"475":{"start":{"line":809,"column":4},"end":{"line":809,"column":16}},"476":{"start":{"line":810,"column":4},"end":{"line":818,"column":5}},"477":{"start":{"line":811,"column":6},"end":{"line":811,"column":50}},"478":{"start":{"line":812,"column":6},"end":{"line":812,"column":81}},"479":{"start":{"line":813,"column":6},"end":{"line":813,"column":44}},"480":{"start":{"line":814,"column":6},"end":{"line":814,"column":39}},"481":{"start":{"line":816,"column":6},"end":{"line":816,"column":39}},"482":{"start":{"line":817,"column":6},"end":{"line":817,"column":59}},"483":{"start":{"line":819,"column":9},"end":{"line":857,"column":3}},"484":{"start":{"line":820,"column":20},"end":{"line":820,"column":36}},"485":{"start":{"line":821,"column":4},"end":{"line":821,"column":52}},"486":{"start":{"line":822,"column":4},"end":{"line":822,"column":77}},"487":{"start":{"line":823,"column":4},"end":{"line":832,"column":5}},"488":{"start":{"line":824,"column":6},"end":{"line":824,"column":28}},"489":{"start":{"line":825,"column":22},"end":{"line":825,"column":38}},"490":{"start":{"line":826,"column":6},"end":{"line":826,"column":27}},"491":{"start":{"line":827,"column":6},"end":{"line":827,"column":34}},"492":{"start":{"line":828,"column":6},"end":{"line":828,"column":50}},"493":{"start":{"line":829,"column":6},"end":{"line":829,"column":83}},"494":{"start":{"line":831,"column":6},"end":{"line":831,"column":44}},"495":{"start":{"line":833,"column":4},"end":{"line":833,"column":37}},"496":{"start":{"line":834,"column":9},"end":{"line":857,"column":3}},"497":{"start":{"line":835,"column":15},"end":{"line":835,"column":31}},"498":{"start":{"line":836,"column":20},"end":{"line":836,"column":25}},"499":{"start":{"line":837,"column":4},"end":{"line":844,"column":5}},"500":{"start":{"line":838,"column":6},"end":{"line":838,"column":64}},"501":{"start":{"line":839,"column":11},"end":{"line":844,"column":5}},"502":{"start":{"line":840,"column":6},"end":{"line":840,"column":47}},"503":{"start":{"line":842,"column":6},"end":{"line":842,"column":23}},"504":{"start":{"line":843,"column":6},"end":{"line":843,"column":37}},"505":{"start":{"line":845,"column":4},"end":{"line":845,"column":28}},"506":{"start":{"line":846,"column":4},"end":{"line":846,"column":36}},"507":{"start":{"line":846,"column":19},"end":{"line":846,"column":36}},"508":{"start":{"line":847,"column":4},"end":{"line":847,"column":27}},"509":{"start":{"line":848,"column":4},"end":{"line":848,"column":61}},"510":{"start":{"line":849,"column":9},"end":{"line":857,"column":3}},"511":{"start":{"line":850,"column":4},"end":{"line":850,"column":25}},"512":{"start":{"line":851,"column":4},"end":{"line":851,"column":23}},"513":{"start":{"line":852,"column":4},"end":{"line":852,"column":57}},"514":{"start":{"line":854,"column":4},"end":{"line":854,"column":28}},"515":{"start":{"line":855,"column":4},"end":{"line":855,"column":51}},"516":{"start":{"line":856,"column":4},"end":{"line":856,"column":31}},"517":{"start":{"line":858,"column":2},"end":{"line":858,"column":25}},"518":{"start":{"line":859,"column":2},"end":{"line":859,"column":57}},"519":{"start":{"line":862,"column":0},"end":{"line":864,"column":2}},"520":{"start":{"line":863,"column":2},"end":{"line":863,"column":35}},"521":{"start":{"line":866,"column":0},"end":{"line":879,"column":2}},"522":{"start":{"line":867,"column":2},"end":{"line":871,"column":3}},"523":{"start":{"line":868,"column":4},"end":{"line":870,"column":44}},"524":{"start":{"line":873,"column":2},"end":{"line":875,"column":3}},"525":{"start":{"line":874,"column":4},"end":{"line":874,"column":17}},"526":{"start":{"line":877,"column":18},"end":{"line":877,"column":34}},"527":{"start":{"line":878,"column":2},"end":{"line":878,"column":99}},"528":{"start":{"line":881,"column":0},"end":{"line":885,"column":2}},"529":{"start":{"line":882,"column":2},"end":{"line":884,"column":3}},"530":{"start":{"line":883,"column":4},"end":{"line":883,"column":75}},"531":{"start":{"line":887,"column":0},"end":{"line":900,"column":2}},"532":{"start":{"line":888,"column":2},"end":{"line":897,"column":3}},"533":{"start":{"line":889,"column":4},"end":{"line":889,"column":83}},"534":{"start":{"line":890,"column":4},"end":{"line":890,"column":27}},"535":{"start":{"line":892,"column":4},"end":{"line":896,"column":5}},"536":{"start":{"line":893,"column":6},"end":{"line":893,"column":24}},"537":{"start":{"line":895,"column":6},"end":{"line":895,"column":25}},"538":{"start":{"line":899,"column":2},"end":{"line":899,"column":19}},"539":{"start":{"line":902,"column":0},"end":{"line":904,"column":2}},"540":{"start":{"line":903,"column":2},"end":{"line":903,"column":36}},"541":{"start":{"line":906,"column":0},"end":{"line":914,"column":2}},"542":{"start":{"line":907,"column":2},"end":{"line":913,"column":3}},"543":{"start":{"line":908,"column":18},"end":{"line":908,"column":131}},"544":{"start":{"line":909,"column":4},"end":{"line":911,"column":5}},"545":{"start":{"line":910,"column":6},"end":{"line":910,"column":96}},"546":{"start":{"line":912,"column":4},"end":{"line":912,"column":42}},"547":{"start":{"line":918,"column":0},"end":{"line":949,"column":2}},"548":{"start":{"line":919,"column":14},"end":{"line":919,"column":16}},"549":{"start":{"line":920,"column":14},"end":{"line":920,"column":18}},"550":{"start":{"line":924,"column":2},"end":{"line":924,"column":25}},"551":{"start":{"line":926,"column":2},"end":{"line":941,"column":3}},"552":{"start":{"line":927,"column":4},"end":{"line":932,"column":5}},"553":{"start":{"line":928,"column":6},"end":{"line":928,"column":20}},"554":{"start":{"line":930,"column":6},"end":{"line":930,"column":28}},"555":{"start":{"line":931,"column":6},"end":{"line":931,"column":37}},"556":{"start":{"line":931,"column":31},"end":{"line":931,"column":37}},"557":{"start":{"line":934,"column":20},"end":{"line":934,"column":43}},"558":{"start":{"line":935,"column":4},"end":{"line":935,"column":50}},"559":{"start":{"line":935,"column":33},"end":{"line":935,"column":50}},"560":{"start":{"line":937,"column":15},"end":{"line":937,"column":31}},"561":{"start":{"line":938,"column":4},"end":{"line":938,"column":49}},"562":{"start":{"line":939,"column":4},"end":{"line":939,"column":97}},"563":{"start":{"line":940,"column":4},"end":{"line":940,"column":57}},"564":{"start":{"line":944,"column":2},"end":{"line":946,"column":3}},"565":{"start":{"line":945,"column":4},"end":{"line":945,"column":22}},"566":{"start":{"line":948,"column":2},"end":{"line":948,"column":15}},"567":{"start":{"line":953,"column":0},"end":{"line":968,"column":2}},"568":{"start":{"line":954,"column":2},"end":{"line":954,"column":14}},"569":{"start":{"line":957,"column":2},"end":{"line":965,"column":3}},"570":{"start":{"line":958,"column":4},"end":{"line":958,"column":25}},"571":{"start":{"line":959,"column":4},"end":{"line":959,"column":39}},"572":{"start":{"line":961,"column":4},"end":{"line":961,"column":25}},"573":{"start":{"line":962,"column":4},"end":{"line":962,"column":37}},"574":{"start":{"line":963,"column":4},"end":{"line":963,"column":34}},"575":{"start":{"line":964,"column":4},"end":{"line":964,"column":83}},"576":{"start":{"line":966,"column":2},"end":{"line":966,"column":19}},"577":{"start":{"line":967,"column":2},"end":{"line":967,"column":52}},"578":{"start":{"line":972,"column":0},"end":{"line":1006,"column":2}},"579":{"start":{"line":973,"column":14},"end":{"line":973,"column":18}},"580":{"start":{"line":974,"column":2},"end":{"line":979,"column":3}},"581":{"start":{"line":976,"column":19},"end":{"line":976,"column":35}},"582":{"start":{"line":976,"column":48},"end":{"line":976,"column":67}},"583":{"start":{"line":977,"column":4},"end":{"line":977,"column":103}},"584":{"start":{"line":978,"column":4},"end":{"line":978,"column":36}},"585":{"start":{"line":978,"column":29},"end":{"line":978,"column":36}},"586":{"start":{"line":981,"column":2},"end":{"line":989,"column":3}},"587":{"start":{"line":982,"column":20},"end":{"line":982,"column":36}},"588":{"start":{"line":983,"column":4},"end":{"line":983,"column":16}},"589":{"start":{"line":984,"column":4},"end":{"line":984,"column":32}},"590":{"start":{"line":985,"column":4},"end":{"line":985,"column":45}},"591":{"start":{"line":986,"column":4},"end":{"line":986,"column":42}},"592":{"start":{"line":987,"column":4},"end":{"line":987,"column":81}},"593":{"start":{"line":988,"column":4},"end":{"line":988,"column":11}},"594":{"start":{"line":991,"column":2},"end":{"line":991,"column":25}},"595":{"start":{"line":992,"column":2},"end":{"line":1005,"column":3}},"596":{"start":{"line":993,"column":4},"end":{"line":998,"column":5}},"597":{"start":{"line":994,"column":6},"end":{"line":994,"column":20}},"598":{"start":{"line":996,"column":6},"end":{"line":996,"column":28}},"599":{"start":{"line":997,"column":6},"end":{"line":997,"column":37}},"600":{"start":{"line":997,"column":31},"end":{"line":997,"column":37}},"601":{"start":{"line":1000,"column":20},"end":{"line":1000,"column":36}},"602":{"start":{"line":1001,"column":4},"end":{"line":1001,"column":52}},"603":{"start":{"line":1002,"column":4},"end":{"line":1002,"column":103}},"604":{"start":{"line":1003,"column":4},"end":{"line":1003,"column":42}},"605":{"start":{"line":1004,"column":4},"end":{"line":1004,"column":72}},"606":{"start":{"line":1008,"column":0},"end":{"line":1013,"column":2}},"607":{"start":{"line":1009,"column":13},"end":{"line":1009,"column":49}},"608":{"start":{"line":1010,"column":2},"end":{"line":1010,"column":18}},"609":{"start":{"line":1011,"column":2},"end":{"line":1011,"column":35}},"610":{"start":{"line":1012,"column":2},"end":{"line":1012,"column":57}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":17,"column":19},"end":{"line":17,"column":20}},"loc":{"start":{"line":17,"column":44},"end":{"line":27,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":33,"column":21},"end":{"line":33,"column":22}},"loc":{"start":{"line":33,"column":37},"end":{"line":48,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":57,"column":20},"end":{"line":57,"column":21}},"loc":{"start":{"line":57,"column":53},"end":{"line":139,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":141,"column":20},"end":{"line":141,"column":21}},"loc":{"start":{"line":141,"column":36},"end":{"line":146,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":148,"column":21},"end":{"line":148,"column":22}},"loc":{"start":{"line":148,"column":44},"end":{"line":160,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":162,"column":20},"end":{"line":162,"column":21}},"loc":{"start":{"line":162,"column":32},"end":{"line":170,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":172,"column":33},"end":{"line":172,"column":34}},"loc":{"start":{"line":172,"column":58},"end":{"line":197,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":199,"column":28},"end":{"line":199,"column":29}},"loc":{"start":{"line":199,"column":44},"end":{"line":203,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":205,"column":22},"end":{"line":205,"column":23}},"loc":{"start":{"line":205,"column":38},"end":{"line":214,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":224,"column":23},"end":{"line":224,"column":24}},"loc":{"start":{"line":224,"column":39},"end":{"line":272,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":274,"column":28},"end":{"line":274,"column":29}},"loc":{"start":{"line":274,"column":44},"end":{"line":277,"column":1}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":279,"column":22},"end":{"line":279,"column":23}},"loc":{"start":{"line":279,"column":38},"end":{"line":285,"column":1}}},"13":{"name":"(anonymous_13)","decl":{"start":{"line":287,"column":26},"end":{"line":287,"column":27}},"loc":{"start":{"line":287,"column":42},"end":{"line":306,"column":1}}},"14":{"name":"(anonymous_14)","decl":{"start":{"line":308,"column":26},"end":{"line":308,"column":27}},"loc":{"start":{"line":308,"column":42},"end":{"line":347,"column":1}}},"15":{"name":"(anonymous_15)","decl":{"start":{"line":349,"column":25},"end":{"line":349,"column":26}},"loc":{"start":{"line":349,"column":41},"end":{"line":356,"column":1}}},"16":{"name":"(anonymous_16)","decl":{"start":{"line":362,"column":23},"end":{"line":362,"column":24}},"loc":{"start":{"line":362,"column":39},"end":{"line":389,"column":1}}},"17":{"name":"(anonymous_17)","decl":{"start":{"line":391,"column":23},"end":{"line":391,"column":24}},"loc":{"start":{"line":391,"column":45},"end":{"line":396,"column":1}}},"18":{"name":"(anonymous_18)","decl":{"start":{"line":398,"column":25},"end":{"line":398,"column":26}},"loc":{"start":{"line":398,"column":41},"end":{"line":405,"column":1}}},"19":{"name":"(anonymous_19)","decl":{"start":{"line":407,"column":24},"end":{"line":407,"column":25}},"loc":{"start":{"line":407,"column":40},"end":{"line":413,"column":1}}},"20":{"name":"(anonymous_20)","decl":{"start":{"line":415,"column":25},"end":{"line":415,"column":26}},"loc":{"start":{"line":415,"column":41},"end":{"line":418,"column":1}}},"21":{"name":"(anonymous_21)","decl":{"start":{"line":420,"column":27},"end":{"line":420,"column":28}},"loc":{"start":{"line":420,"column":60},"end":{"line":443,"column":1}}},"22":{"name":"(anonymous_22)","decl":{"start":{"line":445,"column":30},"end":{"line":445,"column":31}},"loc":{"start":{"line":445,"column":52},"end":{"line":449,"column":1}}},"23":{"name":"(anonymous_23)","decl":{"start":{"line":455,"column":16},"end":{"line":455,"column":17}},"loc":{"start":{"line":455,"column":44},"end":{"line":460,"column":1}}},"24":{"name":"(anonymous_24)","decl":{"start":{"line":464,"column":20},"end":{"line":464,"column":21}},"loc":{"start":{"line":464,"column":68},"end":{"line":504,"column":1}}},"25":{"name":"(anonymous_25)","decl":{"start":{"line":510,"column":14},"end":{"line":510,"column":15}},"loc":{"start":{"line":510,"column":36},"end":{"line":520,"column":1}}},"26":{"name":"(anonymous_26)","decl":{"start":{"line":525,"column":16},"end":{"line":525,"column":17}},"loc":{"start":{"line":525,"column":48},"end":{"line":540,"column":1}}},"27":{"name":"(anonymous_27)","decl":{"start":{"line":544,"column":14},"end":{"line":544,"column":15}},"loc":{"start":{"line":544,"column":43},"end":{"line":563,"column":1}}},"28":{"name":"(anonymous_28)","decl":{"start":{"line":565,"column":18},"end":{"line":565,"column":19}},"loc":{"start":{"line":565,"column":34},"end":{"line":568,"column":1}}},"29":{"name":"(anonymous_29)","decl":{"start":{"line":573,"column":19},"end":{"line":573,"column":20}},"loc":{"start":{"line":573,"column":90},"end":{"line":602,"column":1}}},"30":{"name":"(anonymous_30)","decl":{"start":{"line":604,"column":25},"end":{"line":604,"column":26}},"loc":{"start":{"line":604,"column":41},"end":{"line":607,"column":1}}},"31":{"name":"(anonymous_31)","decl":{"start":{"line":612,"column":16},"end":{"line":612,"column":17}},"loc":{"start":{"line":612,"column":57},"end":{"line":618,"column":1}}},"32":{"name":"(anonymous_32)","decl":{"start":{"line":620,"column":21},"end":{"line":620,"column":22}},"loc":{"start":{"line":620,"column":33},"end":{"line":622,"column":1}}},"33":{"name":"(anonymous_33)","decl":{"start":{"line":624,"column":27},"end":{"line":624,"column":28}},"loc":{"start":{"line":624,"column":39},"end":{"line":626,"column":1}}},"34":{"name":"(anonymous_34)","decl":{"start":{"line":628,"column":20},"end":{"line":628,"column":21}},"loc":{"start":{"line":628,"column":36},"end":{"line":767,"column":1}}},"35":{"name":"(anonymous_35)","decl":{"start":{"line":769,"column":24},"end":{"line":769,"column":25}},"loc":{"start":{"line":769,"column":40},"end":{"line":779,"column":1}}},"36":{"name":"(anonymous_36)","decl":{"start":{"line":781,"column":22},"end":{"line":781,"column":23}},"loc":{"start":{"line":781,"column":73},"end":{"line":784,"column":1}}},"37":{"name":"(anonymous_37)","decl":{"start":{"line":786,"column":18},"end":{"line":786,"column":19}},"loc":{"start":{"line":786,"column":59},"end":{"line":796,"column":1}}},"38":{"name":"(anonymous_38)","decl":{"start":{"line":798,"column":21},"end":{"line":798,"column":22}},"loc":{"start":{"line":798,"column":37},"end":{"line":800,"column":1}}},"39":{"name":"(anonymous_39)","decl":{"start":{"line":804,"column":17},"end":{"line":804,"column":18}},"loc":{"start":{"line":804,"column":33},"end":{"line":860,"column":1}}},"40":{"name":"(anonymous_40)","decl":{"start":{"line":862,"column":28},"end":{"line":862,"column":29}},"loc":{"start":{"line":862,"column":40},"end":{"line":864,"column":1}}},"41":{"name":"(anonymous_41)","decl":{"start":{"line":866,"column":30},"end":{"line":866,"column":31}},"loc":{"start":{"line":866,"column":42},"end":{"line":879,"column":1}}},"42":{"name":"(anonymous_42)","decl":{"start":{"line":881,"column":32},"end":{"line":881,"column":33}},"loc":{"start":{"line":881,"column":48},"end":{"line":885,"column":1}}},"43":{"name":"(anonymous_43)","decl":{"start":{"line":887,"column":21},"end":{"line":887,"column":22}},"loc":{"start":{"line":887,"column":46},"end":{"line":900,"column":1}}},"44":{"name":"(anonymous_44)","decl":{"start":{"line":902,"column":34},"end":{"line":902,"column":35}},"loc":{"start":{"line":902,"column":46},"end":{"line":904,"column":1}}},"45":{"name":"(anonymous_45)","decl":{"start":{"line":906,"column":17},"end":{"line":906,"column":18}},"loc":{"start":{"line":906,"column":33},"end":{"line":914,"column":1}}},"46":{"name":"(anonymous_46)","decl":{"start":{"line":918,"column":27},"end":{"line":918,"column":28}},"loc":{"start":{"line":918,"column":39},"end":{"line":949,"column":1}}},"47":{"name":"(anonymous_47)","decl":{"start":{"line":953,"column":17},"end":{"line":953,"column":18}},"loc":{"start":{"line":953,"column":33},"end":{"line":968,"column":1}}},"48":{"name":"(anonymous_48)","decl":{"start":{"line":972,"column":27},"end":{"line":972,"column":28}},"loc":{"start":{"line":972,"column":43},"end":{"line":1006,"column":1}}},"49":{"name":"(anonymous_49)","decl":{"start":{"line":1008,"column":33},"end":{"line":1008,"column":34}},"loc":{"start":{"line":1008,"column":67},"end":{"line":1013,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":58,"column":2},"end":{"line":60,"column":3}},"type":"if","locations":[{"start":{"line":58,"column":2},"end":{"line":60,"column":3}},{"start":{"line":58,"column":2},"end":{"line":60,"column":3}}]},"2":{"loc":{"start":{"line":68,"column":2},"end":{"line":124,"column":3}},"type":"switch","locations":[{"start":{"line":69,"column":4},"end":{"line":69,"column":19}},{"start":{"line":69,"column":20},"end":{"line":69,"column":104}},{"start":{"line":70,"column":4},"end":{"line":70,"column":64}},{"start":{"line":71,"column":4},"end":{"line":71,"column":52}},{"start":{"line":72,"column":4},"end":{"line":72,"column":54}},{"start":{"line":73,"column":4},"end":{"line":75,"column":47}},{"start":{"line":77,"column":4},"end":{"line":80,"column":41}},{"start":{"line":82,"column":4},"end":{"line":82,"column":52}},{"start":{"line":83,"column":4},"end":{"line":83,"column":60}},{"start":{"line":84,"column":4},"end":{"line":84,"column":60}},{"start":{"line":85,"column":4},"end":{"line":85,"column":58}},{"start":{"line":86,"column":4},"end":{"line":86,"column":54}},{"start":{"line":88,"column":4},"end":{"line":88,"column":17}},{"start":{"line":89,"column":4},"end":{"line":90,"column":42}},{"start":{"line":92,"column":4},"end":{"line":93,"column":53}},{"start":{"line":95,"column":4},"end":{"line":95,"column":58}},{"start":{"line":96,"column":4},"end":{"line":96,"column":56}},{"start":{"line":97,"column":4},"end":{"line":97,"column":45}},{"start":{"line":98,"column":4},"end":{"line":98,"column":56}},{"start":{"line":99,"column":4},"end":{"line":99,"column":20}},{"start":{"line":100,"column":4},"end":{"line":110,"column":88}},{"start":{"line":112,"column":4},"end":{"line":123,"column":7}}]},"3":{"loc":{"start":{"line":74,"column":6},"end":{"line":74,"column":42}},"type":"if","locations":[{"start":{"line":74,"column":6},"end":{"line":74,"column":42}},{"start":{"line":74,"column":6},"end":{"line":74,"column":42}}]},"4":{"loc":{"start":{"line":78,"column":6},"end":{"line":78,"column":42}},"type":"if","locations":[{"start":{"line":78,"column":6},"end":{"line":78,"column":42}},{"start":{"line":78,"column":6},"end":{"line":78,"column":42}}]},"5":{"loc":{"start":{"line":90,"column":6},"end":{"line":90,"column":42}},"type":"if","locations":[{"start":{"line":90,"column":6},"end":{"line":90,"column":42}},{"start":{"line":90,"column":6},"end":{"line":90,"column":42}}]},"6":{"loc":{"start":{"line":101,"column":6},"end":{"line":109,"column":7}},"type":"if","locations":[{"start":{"line":101,"column":6},"end":{"line":109,"column":7}},{"start":{"line":101,"column":6},"end":{"line":109,"column":7}}]},"7":{"loc":{"start":{"line":102,"column":8},"end":{"line":104,"column":9}},"type":"if","locations":[{"start":{"line":102,"column":8},"end":{"line":104,"column":9}},{"start":{"line":102,"column":8},"end":{"line":104,"column":9}}]},"8":{"loc":{"start":{"line":106,"column":8},"end":{"line":108,"column":9}},"type":"if","locations":[{"start":{"line":106,"column":8},"end":{"line":108,"column":9}},{"start":{"line":106,"column":8},"end":{"line":108,"column":9}}]},"9":{"loc":{"start":{"line":110,"column":13},"end":{"line":110,"column":87}},"type":"cond-expr","locations":[{"start":{"line":110,"column":40},"end":{"line":110,"column":62}},{"start":{"line":110,"column":65},"end":{"line":110,"column":87}}]},"10":{"loc":{"start":{"line":113,"column":6},"end":{"line":123,"column":7}},"type":"if","locations":[{"start":{"line":113,"column":6},"end":{"line":123,"column":7}},{"start":{"line":113,"column":6},"end":{"line":123,"column":7}}]},"11":{"loc":{"start":{"line":117,"column":8},"end":{"line":122,"column":9}},"type":"if","locations":[{"start":{"line":117,"column":8},"end":{"line":122,"column":9}},{"start":{"line":117,"column":8},"end":{"line":122,"column":9}}]},"12":{"loc":{"start":{"line":117,"column":12},"end":{"line":117,"column":66}},"type":"binary-expr","locations":[{"start":{"line":117,"column":12},"end":{"line":117,"column":36}},{"start":{"line":117,"column":40},"end":{"line":117,"column":66}}]},"13":{"loc":{"start":{"line":134,"column":2},"end":{"line":138,"column":3}},"type":"if","locations":[{"start":{"line":134,"column":2},"end":{"line":138,"column":3}},{"start":{"line":134,"column":2},"end":{"line":138,"column":3}}]},"14":{"loc":{"start":{"line":134,"column":6},"end":{"line":134,"column":79}},"type":"binary-expr","locations":[{"start":{"line":134,"column":6},"end":{"line":134,"column":27}},{"start":{"line":134,"column":31},"end":{"line":134,"column":57}},{"start":{"line":134,"column":61},"end":{"line":134,"column":79}}]},"15":{"loc":{"start":{"line":142,"column":2},"end":{"line":145,"column":3}},"type":"if","locations":[{"start":{"line":142,"column":2},"end":{"line":145,"column":3}},{"start":{"line":142,"column":2},"end":{"line":145,"column":3}}]},"16":{"loc":{"start":{"line":153,"column":2},"end":{"line":155,"column":3}},"type":"if","locations":[{"start":{"line":153,"column":2},"end":{"line":155,"column":3}},{"start":{"line":153,"column":2},"end":{"line":155,"column":3}}]},"17":{"loc":{"start":{"line":153,"column":6},"end":{"line":153,"column":43}},"type":"binary-expr","locations":[{"start":{"line":153,"column":6},"end":{"line":153,"column":17}},{"start":{"line":153,"column":21},"end":{"line":153,"column":43}}]},"18":{"loc":{"start":{"line":157,"column":2},"end":{"line":159,"column":3}},"type":"if","locations":[{"start":{"line":157,"column":2},"end":{"line":159,"column":3}},{"start":{"line":157,"column":2},"end":{"line":159,"column":3}}]},"19":{"loc":{"start":{"line":163,"column":2},"end":{"line":165,"column":3}},"type":"if","locations":[{"start":{"line":163,"column":2},"end":{"line":165,"column":3}},{"start":{"line":163,"column":2},"end":{"line":165,"column":3}}]},"20":{"loc":{"start":{"line":176,"column":2},"end":{"line":183,"column":3}},"type":"if","locations":[{"start":{"line":176,"column":2},"end":{"line":183,"column":3}},{"start":{"line":176,"column":2},"end":{"line":183,"column":3}}]},"21":{"loc":{"start":{"line":178,"column":9},"end":{"line":183,"column":3}},"type":"if","locations":[{"start":{"line":178,"column":9},"end":{"line":183,"column":3}},{"start":{"line":178,"column":9},"end":{"line":183,"column":3}}]},"22":{"loc":{"start":{"line":190,"column":4},"end":{"line":193,"column":5}},"type":"if","locations":[{"start":{"line":190,"column":4},"end":{"line":193,"column":5}},{"start":{"line":190,"column":4},"end":{"line":193,"column":5}}]},"23":{"loc":{"start":{"line":190,"column":8},"end":{"line":190,"column":58}},"type":"binary-expr","locations":[{"start":{"line":190,"column":8},"end":{"line":190,"column":26}},{"start":{"line":190,"column":30},"end":{"line":190,"column":58}}]},"24":{"loc":{"start":{"line":191,"column":6},"end":{"line":191,"column":70}},"type":"if","locations":[{"start":{"line":191,"column":6},"end":{"line":191,"column":70}},{"start":{"line":191,"column":6},"end":{"line":191,"column":70}}]},"25":{"loc":{"start":{"line":191,"column":10},"end":{"line":191,"column":62}},"type":"binary-expr","locations":[{"start":{"line":191,"column":10},"end":{"line":191,"column":26}},{"start":{"line":191,"column":31},"end":{"line":191,"column":38}},{"start":{"line":191,"column":42},"end":{"line":191,"column":61}}]},"26":{"loc":{"start":{"line":192,"column":6},"end":{"line":192,"column":39}},"type":"if","locations":[{"start":{"line":192,"column":6},"end":{"line":192,"column":39}},{"start":{"line":192,"column":6},"end":{"line":192,"column":39}}]},"27":{"loc":{"start":{"line":192,"column":10},"end":{"line":192,"column":31}},"type":"binary-expr","locations":[{"start":{"line":192,"column":10},"end":{"line":192,"column":20}},{"start":{"line":192,"column":24},"end":{"line":192,"column":31}}]},"28":{"loc":{"start":{"line":195,"column":2},"end":{"line":195,"column":87}},"type":"if","locations":[{"start":{"line":195,"column":2},"end":{"line":195,"column":87}},{"start":{"line":195,"column":2},"end":{"line":195,"column":87}}]},"29":{"loc":{"start":{"line":196,"column":31},"end":{"line":196,"column":79}},"type":"cond-expr","locations":[{"start":{"line":196,"column":41},"end":{"line":196,"column":57}},{"start":{"line":196,"column":60},"end":{"line":196,"column":79}}]},"30":{"loc":{"start":{"line":229,"column":2},"end":{"line":232,"column":3}},"type":"if","locations":[{"start":{"line":229,"column":2},"end":{"line":232,"column":3}},{"start":{"line":229,"column":2},"end":{"line":232,"column":3}}]},"31":{"loc":{"start":{"line":229,"column":6},"end":{"line":229,"column":91}},"type":"binary-expr","locations":[{"start":{"line":229,"column":6},"end":{"line":229,"column":39}},{"start":{"line":229,"column":43},"end":{"line":229,"column":61}},{"start":{"line":229,"column":65},"end":{"line":229,"column":91}}]},"32":{"loc":{"start":{"line":235,"column":2},"end":{"line":240,"column":3}},"type":"if","locations":[{"start":{"line":235,"column":2},"end":{"line":240,"column":3}},{"start":{"line":235,"column":2},"end":{"line":240,"column":3}}]},"33":{"loc":{"start":{"line":236,"column":4},"end":{"line":238,"column":5}},"type":"if","locations":[{"start":{"line":236,"column":4},"end":{"line":238,"column":5}},{"start":{"line":236,"column":4},"end":{"line":238,"column":5}}]},"34":{"loc":{"start":{"line":242,"column":2},"end":{"line":257,"column":3}},"type":"if","locations":[{"start":{"line":242,"column":2},"end":{"line":257,"column":3}},{"start":{"line":242,"column":2},"end":{"line":257,"column":3}}]},"35":{"loc":{"start":{"line":242,"column":6},"end":{"line":242,"column":73}},"type":"binary-expr","locations":[{"start":{"line":242,"column":6},"end":{"line":242,"column":25}},{"start":{"line":242,"column":29},"end":{"line":242,"column":48}},{"start":{"line":242,"column":52},"end":{"line":242,"column":73}}]},"36":{"loc":{"start":{"line":248,"column":4},"end":{"line":252,"column":5}},"type":"if","locations":[{"start":{"line":248,"column":4},"end":{"line":252,"column":5}},{"start":{"line":248,"column":4},"end":{"line":252,"column":5}}]},"37":{"loc":{"start":{"line":248,"column":8},"end":{"line":248,"column":53}},"type":"binary-expr","locations":[{"start":{"line":248,"column":8},"end":{"line":248,"column":26}},{"start":{"line":248,"column":30},"end":{"line":248,"column":53}}]},"38":{"loc":{"start":{"line":249,"column":6},"end":{"line":251,"column":7}},"type":"if","locations":[{"start":{"line":249,"column":6},"end":{"line":251,"column":7}},{"start":{"line":249,"column":6},"end":{"line":251,"column":7}}]},"39":{"loc":{"start":{"line":249,"column":10},"end":{"line":249,"column":70}},"type":"binary-expr","locations":[{"start":{"line":249,"column":10},"end":{"line":249,"column":40}},{"start":{"line":249,"column":44},"end":{"line":249,"column":70}}]},"40":{"loc":{"start":{"line":253,"column":4},"end":{"line":255,"column":5}},"type":"if","locations":[{"start":{"line":253,"column":4},"end":{"line":255,"column":5}},{"start":{"line":253,"column":4},"end":{"line":255,"column":5}}]},"41":{"loc":{"start":{"line":261,"column":2},"end":{"line":267,"column":3}},"type":"if","locations":[{"start":{"line":261,"column":2},"end":{"line":267,"column":3}},{"start":{"line":261,"column":2},"end":{"line":267,"column":3}}]},"42":{"loc":{"start":{"line":261,"column":6},"end":{"line":261,"column":51}},"type":"binary-expr","locations":[{"start":{"line":261,"column":6},"end":{"line":261,"column":24}},{"start":{"line":261,"column":28},"end":{"line":261,"column":51}}]},"43":{"loc":{"start":{"line":265,"column":9},"end":{"line":267,"column":3}},"type":"if","locations":[{"start":{"line":265,"column":9},"end":{"line":267,"column":3}},{"start":{"line":265,"column":9},"end":{"line":267,"column":3}}]},"44":{"loc":{"start":{"line":268,"column":2},"end":{"line":270,"column":3}},"type":"if","locations":[{"start":{"line":268,"column":2},"end":{"line":270,"column":3}},{"start":{"line":268,"column":2},"end":{"line":270,"column":3}}]},"45":{"loc":{"start":{"line":283,"column":19},"end":{"line":283,"column":73}},"type":"cond-expr","locations":[{"start":{"line":283,"column":40},"end":{"line":283,"column":66}},{"start":{"line":283,"column":69},"end":{"line":283,"column":73}}]},"46":{"loc":{"start":{"line":288,"column":2},"end":{"line":290,"column":3}},"type":"if","locations":[{"start":{"line":288,"column":2},"end":{"line":290,"column":3}},{"start":{"line":288,"column":2},"end":{"line":290,"column":3}}]},"47":{"loc":{"start":{"line":288,"column":6},"end":{"line":288,"column":72}},"type":"binary-expr","locations":[{"start":{"line":288,"column":6},"end":{"line":288,"column":28}},{"start":{"line":288,"column":32},"end":{"line":288,"column":72}}]},"48":{"loc":{"start":{"line":298,"column":2},"end":{"line":303,"column":3}},"type":"if","locations":[{"start":{"line":298,"column":2},"end":{"line":303,"column":3}},{"start":{"line":298,"column":2},"end":{"line":303,"column":3}}]},"49":{"loc":{"start":{"line":321,"column":4},"end":{"line":341,"column":5}},"type":"if","locations":[{"start":{"line":321,"column":4},"end":{"line":341,"column":5}},{"start":{"line":321,"column":4},"end":{"line":341,"column":5}}]},"50":{"loc":{"start":{"line":321,"column":8},"end":{"line":321,"column":55}},"type":"binary-expr","locations":[{"start":{"line":321,"column":8},"end":{"line":321,"column":28}},{"start":{"line":321,"column":32},"end":{"line":321,"column":55}}]},"51":{"loc":{"start":{"line":323,"column":6},"end":{"line":323,"column":50}},"type":"if","locations":[{"start":{"line":323,"column":6},"end":{"line":323,"column":50}},{"start":{"line":323,"column":6},"end":{"line":323,"column":50}}]},"52":{"loc":{"start":{"line":327,"column":6},"end":{"line":333,"column":7}},"type":"if","locations":[{"start":{"line":327,"column":6},"end":{"line":333,"column":7}},{"start":{"line":327,"column":6},"end":{"line":333,"column":7}}]},"53":{"loc":{"start":{"line":330,"column":8},"end":{"line":330,"column":88}},"type":"if","locations":[{"start":{"line":330,"column":8},"end":{"line":330,"column":88}},{"start":{"line":330,"column":8},"end":{"line":330,"column":88}}]},"54":{"loc":{"start":{"line":336,"column":6},"end":{"line":340,"column":7}},"type":"if","locations":[{"start":{"line":336,"column":6},"end":{"line":340,"column":7}},{"start":{"line":336,"column":6},"end":{"line":340,"column":7}}]},"55":{"loc":{"start":{"line":343,"column":2},"end":{"line":343,"column":46}},"type":"if","locations":[{"start":{"line":343,"column":2},"end":{"line":343,"column":46}},{"start":{"line":343,"column":2},"end":{"line":343,"column":46}}]},"56":{"loc":{"start":{"line":351,"column":2},"end":{"line":352,"column":69}},"type":"if","locations":[{"start":{"line":351,"column":2},"end":{"line":352,"column":69}},{"start":{"line":351,"column":2},"end":{"line":352,"column":69}}]},"57":{"loc":{"start":{"line":368,"column":2},"end":{"line":379,"column":3}},"type":"if","locations":[{"start":{"line":368,"column":2},"end":{"line":379,"column":3}},{"start":{"line":368,"column":2},"end":{"line":379,"column":3}}]},"58":{"loc":{"start":{"line":382,"column":19},"end":{"line":382,"column":67}},"type":"cond-expr","locations":[{"start":{"line":382,"column":43},"end":{"line":382,"column":60}},{"start":{"line":382,"column":63},"end":{"line":382,"column":67}}]},"59":{"loc":{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},"type":"if","locations":[{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},{"start":{"line":384,"column":2},"end":{"line":386,"column":3}}]},"60":{"loc":{"start":{"line":384,"column":6},"end":{"line":384,"column":38}},"type":"binary-expr","locations":[{"start":{"line":384,"column":6},"end":{"line":384,"column":19}},{"start":{"line":384,"column":23},"end":{"line":384,"column":38}}]},"61":{"loc":{"start":{"line":408,"column":2},"end":{"line":408,"column":79}},"type":"if","locations":[{"start":{"line":408,"column":2},"end":{"line":408,"column":79}},{"start":{"line":408,"column":2},"end":{"line":408,"column":79}}]},"62":{"loc":{"start":{"line":422,"column":4},"end":{"line":424,"column":5}},"type":"if","locations":[{"start":{"line":422,"column":4},"end":{"line":424,"column":5}},{"start":{"line":422,"column":4},"end":{"line":424,"column":5}}]},"63":{"loc":{"start":{"line":427,"column":13},"end":{"line":427,"column":87}},"type":"cond-expr","locations":[{"start":{"line":427,"column":38},"end":{"line":427,"column":44}},{"start":{"line":427,"column":47},"end":{"line":427,"column":87}}]},"64":{"loc":{"start":{"line":427,"column":47},"end":{"line":427,"column":87}},"type":"cond-expr","locations":[{"start":{"line":427,"column":72},"end":{"line":427,"column":80}},{"start":{"line":427,"column":83},"end":{"line":427,"column":87}}]},"65":{"loc":{"start":{"line":430,"column":4},"end":{"line":435,"column":5}},"type":"if","locations":[{"start":{"line":430,"column":4},"end":{"line":435,"column":5}},{"start":{"line":430,"column":4},"end":{"line":435,"column":5}}]},"66":{"loc":{"start":{"line":473,"column":4},"end":{"line":475,"column":5}},"type":"if","locations":[{"start":{"line":473,"column":4},"end":{"line":475,"column":5}},{"start":{"line":473,"column":4},"end":{"line":475,"column":5}}]},"67":{"loc":{"start":{"line":473,"column":8},"end":{"line":473,"column":73}},"type":"binary-expr","locations":[{"start":{"line":473,"column":8},"end":{"line":473,"column":27}},{"start":{"line":473,"column":31},"end":{"line":473,"column":55}},{"start":{"line":473,"column":59},"end":{"line":473,"column":73}}]},"68":{"loc":{"start":{"line":479,"column":4},"end":{"line":495,"column":5}},"type":"if","locations":[{"start":{"line":479,"column":4},"end":{"line":495,"column":5}},{"start":{"line":479,"column":4},"end":{"line":495,"column":5}}]},"69":{"loc":{"start":{"line":479,"column":8},"end":{"line":481,"column":44}},"type":"binary-expr","locations":[{"start":{"line":479,"column":8},"end":{"line":479,"column":23}},{"start":{"line":479,"column":27},"end":{"line":479,"column":46}},{"start":{"line":480,"column":8},"end":{"line":480,"column":43}},{"start":{"line":480,"column":47},"end":{"line":480,"column":87}},{"start":{"line":481,"column":8},"end":{"line":481,"column":44}}]},"70":{"loc":{"start":{"line":485,"column":6},"end":{"line":492,"column":7}},"type":"if","locations":[{"start":{"line":485,"column":6},"end":{"line":492,"column":7}},{"start":{"line":485,"column":6},"end":{"line":492,"column":7}}]},"71":{"loc":{"start":{"line":485,"column":10},"end":{"line":485,"column":75}},"type":"binary-expr","locations":[{"start":{"line":485,"column":10},"end":{"line":485,"column":33}},{"start":{"line":485,"column":37},"end":{"line":485,"column":75}}]},"72":{"loc":{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},"type":"if","locations":[{"start":{"line":489,"column":8},"end":{"line":491,"column":9}},{"start":{"line":489,"column":8},"end":{"line":491,"column":9}}]},"73":{"loc":{"start":{"line":501,"column":2},"end":{"line":503,"column":3}},"type":"if","locations":[{"start":{"line":501,"column":2},"end":{"line":503,"column":3}},{"start":{"line":501,"column":2},"end":{"line":503,"column":3}}]},"74":{"loc":{"start":{"line":513,"column":14},"end":{"line":513,"column":65}},"type":"cond-expr","locations":[{"start":{"line":513,"column":36},"end":{"line":513,"column":40}},{"start":{"line":513,"column":43},"end":{"line":513,"column":65}}]},"75":{"loc":{"start":{"line":515,"column":16},"end":{"line":515,"column":69}},"type":"cond-expr","locations":[{"start":{"line":515,"column":40},"end":{"line":515,"column":44}},{"start":{"line":515,"column":47},"end":{"line":515,"column":69}}]},"76":{"loc":{"start":{"line":527,"column":2},"end":{"line":533,"column":3}},"type":"if","locations":[{"start":{"line":527,"column":2},"end":{"line":533,"column":3}},{"start":{"line":527,"column":2},"end":{"line":533,"column":3}}]},"77":{"loc":{"start":{"line":531,"column":11},"end":{"line":531,"column":67}},"type":"cond-expr","locations":[{"start":{"line":531,"column":32},"end":{"line":531,"column":48}},{"start":{"line":531,"column":51},"end":{"line":531,"column":67}}]},"78":{"loc":{"start":{"line":550,"column":4},"end":{"line":558,"column":5}},"type":"if","locations":[{"start":{"line":550,"column":4},"end":{"line":558,"column":5}},{"start":{"line":550,"column":4},"end":{"line":558,"column":5}}]},"79":{"loc":{"start":{"line":552,"column":11},"end":{"line":558,"column":5}},"type":"if","locations":[{"start":{"line":552,"column":11},"end":{"line":558,"column":5}},{"start":{"line":552,"column":11},"end":{"line":558,"column":5}}]},"80":{"loc":{"start":{"line":552,"column":15},"end":{"line":552,"column":85}},"type":"binary-expr","locations":[{"start":{"line":552,"column":15},"end":{"line":552,"column":33}},{"start":{"line":552,"column":37},"end":{"line":552,"column":85}}]},"81":{"loc":{"start":{"line":552,"column":39},"end":{"line":552,"column":84}},"type":"binary-expr","locations":[{"start":{"line":552,"column":39},"end":{"line":552,"column":57}},{"start":{"line":552,"column":61},"end":{"line":552,"column":84}}]},"82":{"loc":{"start":{"line":554,"column":11},"end":{"line":558,"column":5}},"type":"if","locations":[{"start":{"line":554,"column":11},"end":{"line":558,"column":5}},{"start":{"line":554,"column":11},"end":{"line":558,"column":5}}]},"83":{"loc":{"start":{"line":554,"column":15},"end":{"line":554,"column":107}},"type":"binary-expr","locations":[{"start":{"line":554,"column":15},"end":{"line":554,"column":44}},{"start":{"line":554,"column":48},"end":{"line":554,"column":107}}]},"84":{"loc":{"start":{"line":554,"column":50},"end":{"line":554,"column":106}},"type":"binary-expr","locations":[{"start":{"line":554,"column":50},"end":{"line":554,"column":55}},{"start":{"line":554,"column":60},"end":{"line":554,"column":78}},{"start":{"line":554,"column":82},"end":{"line":554,"column":105}}]},"85":{"loc":{"start":{"line":560,"column":4},"end":{"line":560,"column":35}},"type":"if","locations":[{"start":{"line":560,"column":4},"end":{"line":560,"column":35}},{"start":{"line":560,"column":4},"end":{"line":560,"column":35}}]},"86":{"loc":{"start":{"line":579,"column":2},"end":{"line":586,"column":3}},"type":"if","locations":[{"start":{"line":579,"column":2},"end":{"line":586,"column":3}},{"start":{"line":579,"column":2},"end":{"line":586,"column":3}}]},"87":{"loc":{"start":{"line":580,"column":4},"end":{"line":585,"column":5}},"type":"if","locations":[{"start":{"line":580,"column":4},"end":{"line":585,"column":5}},{"start":{"line":580,"column":4},"end":{"line":585,"column":5}}]},"88":{"loc":{"start":{"line":580,"column":8},"end":{"line":580,"column":56}},"type":"binary-expr","locations":[{"start":{"line":580,"column":8},"end":{"line":580,"column":18}},{"start":{"line":580,"column":22},"end":{"line":580,"column":56}}]},"89":{"loc":{"start":{"line":588,"column":2},"end":{"line":590,"column":3}},"type":"if","locations":[{"start":{"line":588,"column":2},"end":{"line":590,"column":3}},{"start":{"line":588,"column":2},"end":{"line":590,"column":3}}]},"90":{"loc":{"start":{"line":588,"column":6},"end":{"line":588,"column":82}},"type":"binary-expr","locations":[{"start":{"line":588,"column":6},"end":{"line":588,"column":17}},{"start":{"line":588,"column":21},"end":{"line":588,"column":32}},{"start":{"line":588,"column":36},"end":{"line":588,"column":56}},{"start":{"line":588,"column":60},"end":{"line":588,"column":82}}]},"91":{"loc":{"start":{"line":592,"column":2},"end":{"line":594,"column":3}},"type":"if","locations":[{"start":{"line":592,"column":2},"end":{"line":594,"column":3}},{"start":{"line":592,"column":2},"end":{"line":594,"column":3}}]},"92":{"loc":{"start":{"line":592,"column":6},"end":{"line":592,"column":50}},"type":"binary-expr","locations":[{"start":{"line":592,"column":6},"end":{"line":592,"column":25}},{"start":{"line":592,"column":29},"end":{"line":592,"column":50}}]},"93":{"loc":{"start":{"line":601,"column":31},"end":{"line":601,"column":89}},"type":"cond-expr","locations":[{"start":{"line":601,"column":45},"end":{"line":601,"column":66}},{"start":{"line":601,"column":69},"end":{"line":601,"column":89}}]},"94":{"loc":{"start":{"line":617,"column":31},"end":{"line":617,"column":83}},"type":"cond-expr","locations":[{"start":{"line":617,"column":45},"end":{"line":617,"column":63}},{"start":{"line":617,"column":66},"end":{"line":617,"column":83}}]},"95":{"loc":{"start":{"line":621,"column":9},"end":{"line":621,"column":53}},"type":"binary-expr","locations":[{"start":{"line":621,"column":9},"end":{"line":621,"column":26}},{"start":{"line":621,"column":30},"end":{"line":621,"column":53}}]},"96":{"loc":{"start":{"line":643,"column":4},"end":{"line":645,"column":5}},"type":"if","locations":[{"start":{"line":643,"column":4},"end":{"line":645,"column":5}},{"start":{"line":643,"column":4},"end":{"line":645,"column":5}}]},"97":{"loc":{"start":{"line":647,"column":4},"end":{"line":650,"column":5}},"type":"if","locations":[{"start":{"line":647,"column":4},"end":{"line":650,"column":5}},{"start":{"line":647,"column":4},"end":{"line":650,"column":5}}]},"98":{"loc":{"start":{"line":655,"column":4},"end":{"line":658,"column":5}},"type":"if","locations":[{"start":{"line":655,"column":4},"end":{"line":658,"column":5}},{"start":{"line":655,"column":4},"end":{"line":658,"column":5}}]},"99":{"loc":{"start":{"line":661,"column":24},"end":{"line":661,"column":76}},"type":"binary-expr","locations":[{"start":{"line":661,"column":24},"end":{"line":661,"column":43}},{"start":{"line":661,"column":47},"end":{"line":661,"column":76}}]},"100":{"loc":{"start":{"line":668,"column":20},"end":{"line":668,"column":59}},"type":"binary-expr","locations":[{"start":{"line":668,"column":20},"end":{"line":668,"column":33}},{"start":{"line":668,"column":37},"end":{"line":668,"column":59}}]},"101":{"loc":{"start":{"line":669,"column":4},"end":{"line":673,"column":5}},"type":"if","locations":[{"start":{"line":669,"column":4},"end":{"line":673,"column":5}},{"start":{"line":669,"column":4},"end":{"line":673,"column":5}}]},"102":{"loc":{"start":{"line":670,"column":6},"end":{"line":670,"column":41}},"type":"if","locations":[{"start":{"line":670,"column":6},"end":{"line":670,"column":41}},{"start":{"line":670,"column":6},"end":{"line":670,"column":41}}]},"103":{"loc":{"start":{"line":675,"column":4},"end":{"line":685,"column":5}},"type":"if","locations":[{"start":{"line":675,"column":4},"end":{"line":685,"column":5}},{"start":{"line":675,"column":4},"end":{"line":685,"column":5}}]},"104":{"loc":{"start":{"line":675,"column":8},"end":{"line":675,"column":76}},"type":"binary-expr","locations":[{"start":{"line":675,"column":8},"end":{"line":675,"column":20}},{"start":{"line":675,"column":24},"end":{"line":675,"column":56}},{"start":{"line":675,"column":60},"end":{"line":675,"column":76}}]},"105":{"loc":{"start":{"line":676,"column":6},"end":{"line":679,"column":7}},"type":"if","locations":[{"start":{"line":676,"column":6},"end":{"line":679,"column":7}},{"start":{"line":676,"column":6},"end":{"line":679,"column":7}}]},"106":{"loc":{"start":{"line":681,"column":6},"end":{"line":684,"column":7}},"type":"if","locations":[{"start":{"line":681,"column":6},"end":{"line":684,"column":7}},{"start":{"line":681,"column":6},"end":{"line":684,"column":7}}]},"107":{"loc":{"start":{"line":681,"column":10},"end":{"line":681,"column":139}},"type":"binary-expr","locations":[{"start":{"line":681,"column":10},"end":{"line":681,"column":48}},{"start":{"line":681,"column":52},"end":{"line":681,"column":78}},{"start":{"line":681,"column":82},"end":{"line":681,"column":101}},{"start":{"line":681,"column":105},"end":{"line":681,"column":139}}]},"108":{"loc":{"start":{"line":687,"column":24},"end":{"line":687,"column":133}},"type":"binary-expr","locations":[{"start":{"line":687,"column":24},"end":{"line":687,"column":46}},{"start":{"line":687,"column":50},"end":{"line":687,"column":66}},{"start":{"line":687,"column":70},"end":{"line":687,"column":102}},{"start":{"line":687,"column":106},"end":{"line":687,"column":133}}]},"109":{"loc":{"start":{"line":688,"column":4},"end":{"line":692,"column":5}},"type":"if","locations":[{"start":{"line":688,"column":4},"end":{"line":692,"column":5}},{"start":{"line":688,"column":4},"end":{"line":692,"column":5}}]},"110":{"loc":{"start":{"line":689,"column":6},"end":{"line":689,"column":85}},"type":"if","locations":[{"start":{"line":689,"column":6},"end":{"line":689,"column":85}},{"start":{"line":689,"column":6},"end":{"line":689,"column":85}}]},"111":{"loc":{"start":{"line":689,"column":10},"end":{"line":689,"column":64}},"type":"binary-expr","locations":[{"start":{"line":689,"column":10},"end":{"line":689,"column":43}},{"start":{"line":689,"column":47},"end":{"line":689,"column":64}}]},"112":{"loc":{"start":{"line":696,"column":4},"end":{"line":729,"column":5}},"type":"if","locations":[{"start":{"line":696,"column":4},"end":{"line":729,"column":5}},{"start":{"line":696,"column":4},"end":{"line":729,"column":5}}]},"113":{"loc":{"start":{"line":701,"column":6},"end":{"line":705,"column":7}},"type":"if","locations":[{"start":{"line":701,"column":6},"end":{"line":705,"column":7}},{"start":{"line":701,"column":6},"end":{"line":705,"column":7}}]},"114":{"loc":{"start":{"line":701,"column":10},"end":{"line":701,"column":168}},"type":"binary-expr","locations":[{"start":{"line":701,"column":10},"end":{"line":701,"column":18}},{"start":{"line":701,"column":22},"end":{"line":701,"column":34}},{"start":{"line":701,"column":38},"end":{"line":701,"column":67}},{"start":{"line":701,"column":71},"end":{"line":701,"column":96}},{"start":{"line":701,"column":100},"end":{"line":701,"column":122}},{"start":{"line":701,"column":127},"end":{"line":701,"column":145}},{"start":{"line":701,"column":149},"end":{"line":701,"column":167}}]},"115":{"loc":{"start":{"line":708,"column":26},"end":{"line":711,"column":7}},"type":"binary-expr","locations":[{"start":{"line":708,"column":26},"end":{"line":708,"column":44}},{"start":{"line":708,"column":48},"end":{"line":708,"column":62}},{"start":{"line":709,"column":9},"end":{"line":709,"column":34}},{"start":{"line":709,"column":38},"end":{"line":709,"column":64}},{"start":{"line":710,"column":9},"end":{"line":710,"column":37}},{"start":{"line":710,"column":41},"end":{"line":710,"column":68}}]},"116":{"loc":{"start":{"line":712,"column":6},"end":{"line":719,"column":7}},"type":"if","locations":[{"start":{"line":712,"column":6},"end":{"line":719,"column":7}},{"start":{"line":712,"column":6},"end":{"line":719,"column":7}}]},"117":{"loc":{"start":{"line":713,"column":8},"end":{"line":713,"column":93}},"type":"if","locations":[{"start":{"line":713,"column":8},"end":{"line":713,"column":93}},{"start":{"line":713,"column":8},"end":{"line":713,"column":93}}]},"118":{"loc":{"start":{"line":714,"column":8},"end":{"line":714,"column":87}},"type":"if","locations":[{"start":{"line":714,"column":8},"end":{"line":714,"column":87}},{"start":{"line":714,"column":8},"end":{"line":714,"column":87}}]},"119":{"loc":{"start":{"line":715,"column":8},"end":{"line":715,"column":83}},"type":"if","locations":[{"start":{"line":715,"column":8},"end":{"line":715,"column":83}},{"start":{"line":715,"column":8},"end":{"line":715,"column":83}}]},"120":{"loc":{"start":{"line":716,"column":8},"end":{"line":716,"column":85}},"type":"if","locations":[{"start":{"line":716,"column":8},"end":{"line":716,"column":85}},{"start":{"line":716,"column":8},"end":{"line":716,"column":85}}]},"121":{"loc":{"start":{"line":722,"column":30},"end":{"line":725,"column":7}},"type":"binary-expr","locations":[{"start":{"line":722,"column":30},"end":{"line":722,"column":43}},{"start":{"line":723,"column":9},"end":{"line":723,"column":34}},{"start":{"line":723,"column":38},"end":{"line":723,"column":62}},{"start":{"line":724,"column":9},"end":{"line":724,"column":37}},{"start":{"line":724,"column":41},"end":{"line":724,"column":66}}]},"122":{"loc":{"start":{"line":726,"column":6},"end":{"line":728,"column":7}},"type":"if","locations":[{"start":{"line":726,"column":6},"end":{"line":728,"column":7}},{"start":{"line":726,"column":6},"end":{"line":728,"column":7}}]},"123":{"loc":{"start":{"line":732,"column":4},"end":{"line":736,"column":5}},"type":"if","locations":[{"start":{"line":732,"column":4},"end":{"line":736,"column":5}},{"start":{"line":732,"column":4},"end":{"line":736,"column":5}}]},"124":{"loc":{"start":{"line":733,"column":6},"end":{"line":733,"column":103}},"type":"if","locations":[{"start":{"line":733,"column":6},"end":{"line":733,"column":103}},{"start":{"line":733,"column":6},"end":{"line":733,"column":103}}]},"125":{"loc":{"start":{"line":739,"column":4},"end":{"line":741,"column":5}},"type":"if","locations":[{"start":{"line":739,"column":4},"end":{"line":741,"column":5}},{"start":{"line":739,"column":4},"end":{"line":741,"column":5}}]},"126":{"loc":{"start":{"line":739,"column":8},"end":{"line":739,"column":97}},"type":"binary-expr","locations":[{"start":{"line":739,"column":9},"end":{"line":739,"column":38}},{"start":{"line":739,"column":42},"end":{"line":739,"column":75}},{"start":{"line":739,"column":80},"end":{"line":739,"column":97}}]},"127":{"loc":{"start":{"line":747,"column":4},"end":{"line":757,"column":5}},"type":"if","locations":[{"start":{"line":747,"column":4},"end":{"line":757,"column":5}},{"start":{"line":747,"column":4},"end":{"line":757,"column":5}}]},"128":{"loc":{"start":{"line":748,"column":23},"end":{"line":748,"column":52}},"type":"cond-expr","locations":[{"start":{"line":748,"column":47},"end":{"line":748,"column":48}},{"start":{"line":748,"column":51},"end":{"line":748,"column":52}}]},"129":{"loc":{"start":{"line":749,"column":6},"end":{"line":756,"column":7}},"type":"if","locations":[{"start":{"line":749,"column":6},"end":{"line":756,"column":7}},{"start":{"line":749,"column":6},"end":{"line":756,"column":7}}]},"130":{"loc":{"start":{"line":751,"column":8},"end":{"line":755,"column":9}},"type":"if","locations":[{"start":{"line":751,"column":8},"end":{"line":755,"column":9}},{"start":{"line":751,"column":8},"end":{"line":755,"column":9}}]},"131":{"loc":{"start":{"line":760,"column":2},"end":{"line":762,"column":3}},"type":"if","locations":[{"start":{"line":760,"column":2},"end":{"line":762,"column":3}},{"start":{"line":760,"column":2},"end":{"line":762,"column":3}}]},"132":{"loc":{"start":{"line":770,"column":2},"end":{"line":776,"column":3}},"type":"if","locations":[{"start":{"line":770,"column":2},"end":{"line":776,"column":3}},{"start":{"line":770,"column":2},"end":{"line":776,"column":3}}]},"133":{"loc":{"start":{"line":771,"column":4},"end":{"line":771,"column":62}},"type":"if","locations":[{"start":{"line":771,"column":4},"end":{"line":771,"column":62}},{"start":{"line":771,"column":4},"end":{"line":771,"column":62}}]},"134":{"loc":{"start":{"line":787,"column":2},"end":{"line":795,"column":3}},"type":"if","locations":[{"start":{"line":787,"column":2},"end":{"line":795,"column":3}},{"start":{"line":787,"column":2},"end":{"line":795,"column":3}}]},"135":{"loc":{"start":{"line":790,"column":4},"end":{"line":794,"column":5}},"type":"if","locations":[{"start":{"line":790,"column":4},"end":{"line":794,"column":5}},{"start":{"line":790,"column":4},"end":{"line":794,"column":5}}]},"136":{"loc":{"start":{"line":790,"column":8},"end":{"line":790,"column":34}},"type":"binary-expr","locations":[{"start":{"line":790,"column":8},"end":{"line":790,"column":18}},{"start":{"line":790,"column":22},"end":{"line":790,"column":34}}]},"137":{"loc":{"start":{"line":799,"column":20},"end":{"line":799,"column":77}},"type":"cond-expr","locations":[{"start":{"line":799,"column":44},"end":{"line":799,"column":70}},{"start":{"line":799,"column":73},"end":{"line":799,"column":77}}]},"138":{"loc":{"start":{"line":807,"column":2},"end":{"line":857,"column":3}},"type":"if","locations":[{"start":{"line":807,"column":2},"end":{"line":857,"column":3}},{"start":{"line":807,"column":2},"end":{"line":857,"column":3}}]},"139":{"loc":{"start":{"line":810,"column":4},"end":{"line":818,"column":5}},"type":"if","locations":[{"start":{"line":810,"column":4},"end":{"line":818,"column":5}},{"start":{"line":810,"column":4},"end":{"line":818,"column":5}}]},"140":{"loc":{"start":{"line":810,"column":8},"end":{"line":810,"column":70}},"type":"binary-expr","locations":[{"start":{"line":810,"column":8},"end":{"line":810,"column":42}},{"start":{"line":810,"column":46},"end":{"line":810,"column":70}}]},"141":{"loc":{"start":{"line":819,"column":9},"end":{"line":857,"column":3}},"type":"if","locations":[{"start":{"line":819,"column":9},"end":{"line":857,"column":3}},{"start":{"line":819,"column":9},"end":{"line":857,"column":3}}]},"142":{"loc":{"start":{"line":819,"column":13},"end":{"line":819,"column":82}},"type":"binary-expr","locations":[{"start":{"line":819,"column":13},"end":{"line":819,"column":47}},{"start":{"line":819,"column":51},"end":{"line":819,"column":82}}]},"143":{"loc":{"start":{"line":823,"column":4},"end":{"line":832,"column":5}},"type":"if","locations":[{"start":{"line":823,"column":4},"end":{"line":832,"column":5}},{"start":{"line":823,"column":4},"end":{"line":832,"column":5}}]},"144":{"loc":{"start":{"line":823,"column":8},"end":{"line":823,"column":65}},"type":"binary-expr","locations":[{"start":{"line":823,"column":8},"end":{"line":823,"column":28}},{"start":{"line":823,"column":32},"end":{"line":823,"column":65}}]},"145":{"loc":{"start":{"line":834,"column":9},"end":{"line":857,"column":3}},"type":"if","locations":[{"start":{"line":834,"column":9},"end":{"line":857,"column":3}},{"start":{"line":834,"column":9},"end":{"line":857,"column":3}}]},"146":{"loc":{"start":{"line":837,"column":4},"end":{"line":844,"column":5}},"type":"if","locations":[{"start":{"line":837,"column":4},"end":{"line":844,"column":5}},{"start":{"line":837,"column":4},"end":{"line":844,"column":5}}]},"147":{"loc":{"start":{"line":839,"column":11},"end":{"line":844,"column":5}},"type":"if","locations":[{"start":{"line":839,"column":11},"end":{"line":844,"column":5}},{"start":{"line":839,"column":11},"end":{"line":844,"column":5}}]},"148":{"loc":{"start":{"line":846,"column":4},"end":{"line":846,"column":36}},"type":"if","locations":[{"start":{"line":846,"column":4},"end":{"line":846,"column":36}},{"start":{"line":846,"column":4},"end":{"line":846,"column":36}}]},"149":{"loc":{"start":{"line":849,"column":9},"end":{"line":857,"column":3}},"type":"if","locations":[{"start":{"line":849,"column":9},"end":{"line":857,"column":3}},{"start":{"line":849,"column":9},"end":{"line":857,"column":3}}]},"150":{"loc":{"start":{"line":849,"column":13},"end":{"line":849,"column":75}},"type":"binary-expr","locations":[{"start":{"line":849,"column":13},"end":{"line":849,"column":36}},{"start":{"line":849,"column":40},"end":{"line":849,"column":75}}]},"151":{"loc":{"start":{"line":867,"column":2},"end":{"line":871,"column":3}},"type":"if","locations":[{"start":{"line":867,"column":2},"end":{"line":871,"column":3}},{"start":{"line":867,"column":2},"end":{"line":871,"column":3}}]},"152":{"loc":{"start":{"line":868,"column":11},"end":{"line":870,"column":43}},"type":"binary-expr","locations":[{"start":{"line":868,"column":11},"end":{"line":868,"column":38}},{"start":{"line":869,"column":11},"end":{"line":869,"column":39}},{"start":{"line":870,"column":11},"end":{"line":870,"column":43}}]},"153":{"loc":{"start":{"line":873,"column":2},"end":{"line":875,"column":3}},"type":"if","locations":[{"start":{"line":873,"column":2},"end":{"line":875,"column":3}},{"start":{"line":873,"column":2},"end":{"line":875,"column":3}}]},"154":{"loc":{"start":{"line":878,"column":9},"end":{"line":878,"column":98}},"type":"binary-expr","locations":[{"start":{"line":878,"column":9},"end":{"line":878,"column":36}},{"start":{"line":878,"column":41},"end":{"line":878,"column":67}},{"start":{"line":878,"column":71},"end":{"line":878,"column":97}}]},"155":{"loc":{"start":{"line":882,"column":2},"end":{"line":884,"column":3}},"type":"if","locations":[{"start":{"line":882,"column":2},"end":{"line":884,"column":3}},{"start":{"line":882,"column":2},"end":{"line":884,"column":3}}]},"156":{"loc":{"start":{"line":888,"column":2},"end":{"line":897,"column":3}},"type":"if","locations":[{"start":{"line":888,"column":2},"end":{"line":897,"column":3}},{"start":{"line":888,"column":2},"end":{"line":897,"column":3}}]},"157":{"loc":{"start":{"line":889,"column":18},"end":{"line":889,"column":82}},"type":"cond-expr","locations":[{"start":{"line":889,"column":42},"end":{"line":889,"column":62}},{"start":{"line":889,"column":65},"end":{"line":889,"column":82}}]},"158":{"loc":{"start":{"line":892,"column":4},"end":{"line":896,"column":5}},"type":"if","locations":[{"start":{"line":892,"column":4},"end":{"line":896,"column":5}},{"start":{"line":892,"column":4},"end":{"line":896,"column":5}}]},"159":{"loc":{"start":{"line":907,"column":2},"end":{"line":913,"column":3}},"type":"if","locations":[{"start":{"line":907,"column":2},"end":{"line":913,"column":3}},{"start":{"line":907,"column":2},"end":{"line":913,"column":3}}]},"160":{"loc":{"start":{"line":908,"column":18},"end":{"line":908,"column":131}},"type":"binary-expr","locations":[{"start":{"line":908,"column":18},"end":{"line":908,"column":34}},{"start":{"line":908,"column":39},"end":{"line":908,"column":83}},{"start":{"line":908,"column":87},"end":{"line":908,"column":130}}]},"161":{"loc":{"start":{"line":909,"column":4},"end":{"line":911,"column":5}},"type":"if","locations":[{"start":{"line":909,"column":4},"end":{"line":911,"column":5}},{"start":{"line":909,"column":4},"end":{"line":911,"column":5}}]},"162":{"loc":{"start":{"line":909,"column":8},"end":{"line":909,"column":37}},"type":"binary-expr","locations":[{"start":{"line":909,"column":8},"end":{"line":909,"column":25}},{"start":{"line":909,"column":29},"end":{"line":909,"column":37}}]},"163":{"loc":{"start":{"line":927,"column":4},"end":{"line":932,"column":5}},"type":"if","locations":[{"start":{"line":927,"column":4},"end":{"line":932,"column":5}},{"start":{"line":927,"column":4},"end":{"line":932,"column":5}}]},"164":{"loc":{"start":{"line":931,"column":6},"end":{"line":931,"column":37}},"type":"if","locations":[{"start":{"line":931,"column":6},"end":{"line":931,"column":37}},{"start":{"line":931,"column":6},"end":{"line":931,"column":37}}]},"165":{"loc":{"start":{"line":935,"column":4},"end":{"line":935,"column":50}},"type":"if","locations":[{"start":{"line":935,"column":4},"end":{"line":935,"column":50}},{"start":{"line":935,"column":4},"end":{"line":935,"column":50}}]},"166":{"loc":{"start":{"line":935,"column":8},"end":{"line":935,"column":31}},"type":"binary-expr","locations":[{"start":{"line":935,"column":8},"end":{"line":935,"column":17}},{"start":{"line":935,"column":21},"end":{"line":935,"column":31}}]},"167":{"loc":{"start":{"line":939,"column":20},"end":{"line":939,"column":96}},"type":"cond-expr","locations":[{"start":{"line":939,"column":47},"end":{"line":939,"column":73}},{"start":{"line":939,"column":76},"end":{"line":939,"column":96}}]},"168":{"loc":{"start":{"line":944,"column":2},"end":{"line":946,"column":3}},"type":"if","locations":[{"start":{"line":944,"column":2},"end":{"line":946,"column":3}},{"start":{"line":944,"column":2},"end":{"line":946,"column":3}}]},"169":{"loc":{"start":{"line":944,"column":6},"end":{"line":944,"column":45}},"type":"binary-expr","locations":[{"start":{"line":944,"column":6},"end":{"line":944,"column":15}},{"start":{"line":944,"column":19},"end":{"line":944,"column":45}}]},"170":{"loc":{"start":{"line":957,"column":2},"end":{"line":965,"column":3}},"type":"if","locations":[{"start":{"line":957,"column":2},"end":{"line":965,"column":3}},{"start":{"line":957,"column":2},"end":{"line":965,"column":3}}]},"171":{"loc":{"start":{"line":964,"column":18},"end":{"line":964,"column":82}},"type":"cond-expr","locations":[{"start":{"line":964,"column":42},"end":{"line":964,"column":62}},{"start":{"line":964,"column":65},"end":{"line":964,"column":82}}]},"172":{"loc":{"start":{"line":974,"column":2},"end":{"line":979,"column":3}},"type":"if","locations":[{"start":{"line":974,"column":2},"end":{"line":979,"column":3}},{"start":{"line":974,"column":2},"end":{"line":979,"column":3}}]},"173":{"loc":{"start":{"line":978,"column":4},"end":{"line":978,"column":36}},"type":"if","locations":[{"start":{"line":978,"column":4},"end":{"line":978,"column":36}},{"start":{"line":978,"column":4},"end":{"line":978,"column":36}}]},"174":{"loc":{"start":{"line":981,"column":2},"end":{"line":989,"column":3}},"type":"if","locations":[{"start":{"line":981,"column":2},"end":{"line":989,"column":3}},{"start":{"line":981,"column":2},"end":{"line":989,"column":3}}]},"175":{"loc":{"start":{"line":993,"column":4},"end":{"line":998,"column":5}},"type":"if","locations":[{"start":{"line":993,"column":4},"end":{"line":998,"column":5}},{"start":{"line":993,"column":4},"end":{"line":998,"column":5}}]},"176":{"loc":{"start":{"line":997,"column":6},"end":{"line":997,"column":37}},"type":"if","locations":[{"start":{"line":997,"column":6},"end":{"line":997,"column":37}},{"start":{"line":997,"column":6},"end":{"line":997,"column":37}}]},"177":{"loc":{"start":{"line":1002,"column":22},"end":{"line":1002,"column":102}},"type":"cond-expr","locations":[{"start":{"line":1002,"column":49},"end":{"line":1002,"column":71}},{"start":{"line":1002,"column":74},"end":{"line":1002,"column":102}}]}},"s":{"1":1,"2":1,"3":2026,"4":2026,"5":1404,"6":1404,"7":1404,"8":1404,"9":1,"10":1,"11":1,"12":212,"13":212,"14":212,"15":212,"16":212,"17":212,"18":212,"19":212,"20":212,"21":1,"22":2906,"23":5,"24":2905,"25":2905,"26":2905,"27":72,"28":3,"29":15,"30":80,"31":293,"32":1,"33":292,"34":134,"35":1,"36":133,"37":133,"38":27,"39":32,"40":24,"41":19,"42":34,"43":78,"44":4,"45":315,"46":55,"47":14,"48":109,"49":46,"50":127,"51":127,"52":1,"53":126,"54":3,"55":123,"56":590,"57":13,"58":13,"59":13,"60":9,"61":9,"62":4,"63":1493,"64":1493,"65":1194,"66":39,"67":1155,"68":1,"69":152,"70":6,"71":6,"72":1,"73":7,"74":8,"75":7,"76":0,"77":7,"78":1,"79":1,"80":33,"81":0,"82":33,"83":33,"84":33,"85":33,"86":1,"87":72,"88":72,"89":72,"90":43,"91":29,"92":4,"93":25,"94":25,"95":68,"96":52,"97":52,"98":48,"99":45,"100":3,"101":1,"102":68,"103":22,"104":46,"105":1,"106":3,"107":3,"108":3,"109":1,"110":15,"111":15,"112":15,"113":13,"114":13,"115":11,"116":11,"117":11,"118":1,"119":80,"120":80,"121":80,"122":80,"123":5,"124":5,"125":80,"126":79,"127":10,"128":1,"129":9,"130":69,"131":40,"132":40,"133":40,"134":40,"135":39,"136":39,"137":30,"138":20,"139":19,"140":1,"141":18,"142":29,"143":29,"144":29,"145":17,"146":10,"147":10,"148":12,"149":0,"150":12,"151":1,"152":11,"153":1,"154":292,"155":292,"156":1,"157":27,"158":27,"159":24,"160":18,"161":15,"162":1,"163":32,"164":2,"165":30,"166":30,"167":12,"168":18,"169":17,"170":28,"171":1,"172":24,"173":24,"174":22,"175":22,"176":22,"177":22,"178":53,"179":26,"180":26,"181":7,"182":26,"183":26,"184":26,"185":26,"186":18,"187":8,"188":2,"189":6,"190":6,"191":24,"192":27,"193":27,"194":0,"195":17,"196":14,"197":17,"198":17,"199":17,"200":1,"201":19,"202":19,"203":1,"204":18,"205":13,"206":13,"207":1,"208":1,"209":34,"210":34,"211":34,"212":34,"213":30,"214":30,"215":30,"216":30,"217":25,"218":20,"219":18,"220":18,"221":22,"222":22,"223":22,"224":2,"225":20,"226":1,"227":315,"228":312,"229":248,"230":247,"231":1,"232":55,"233":55,"234":55,"235":55,"236":38,"237":38,"238":1,"239":14,"240":2,"241":12,"242":12,"243":12,"244":10,"245":1,"246":46,"247":46,"248":1,"249":39,"250":7,"251":3,"252":36,"253":36,"254":4,"255":4,"256":4,"257":4,"258":0,"259":36,"260":36,"261":25,"262":25,"263":25,"264":1,"265":1109,"266":1109,"267":1077,"268":1,"269":772,"270":772,"271":766,"272":615,"273":1,"274":2792,"275":2792,"276":2792,"277":2792,"278":2670,"279":11,"280":2670,"281":1904,"282":212,"283":212,"284":212,"285":182,"286":182,"287":178,"288":3,"289":205,"290":1692,"291":1692,"292":2019,"293":53,"294":1,"295":38,"296":38,"297":26,"298":26,"299":26,"300":26,"301":26,"302":23,"303":23,"304":1,"305":30,"306":2,"307":2,"308":28,"309":28,"310":30,"311":30,"312":29,"313":29,"314":29,"315":29,"316":1,"317":352,"318":352,"319":352,"320":389,"321":389,"322":350,"323":208,"324":136,"325":5,"326":131,"327":4,"328":127,"329":324,"330":324,"331":287,"332":287,"333":1,"334":389,"335":369,"336":1,"337":432,"338":432,"339":432,"340":432,"341":50,"342":0,"343":50,"344":50,"345":432,"346":15,"347":417,"348":329,"349":412,"350":387,"351":196,"352":196,"353":1,"354":412,"355":411,"356":1,"357":156,"358":156,"359":152,"360":152,"361":127,"362":1,"363":102,"364":1,"365":91,"366":1,"367":152,"368":152,"369":152,"370":152,"371":152,"372":152,"373":152,"374":152,"375":148,"376":158,"377":19,"378":139,"379":8,"380":8,"381":131,"382":131,"383":6,"384":6,"385":131,"386":131,"387":131,"388":131,"389":131,"390":131,"391":128,"392":128,"393":35,"394":0,"395":35,"396":35,"397":127,"398":106,"399":15,"400":13,"401":91,"402":1,"403":1,"404":112,"405":112,"406":2,"407":1,"408":2,"409":2,"410":112,"411":112,"412":103,"413":103,"414":31,"415":31,"416":31,"417":103,"418":103,"419":15,"420":2,"421":13,"422":3,"423":10,"424":1,"425":9,"426":0,"427":9,"428":9,"429":97,"430":97,"431":2,"432":104,"433":1,"434":0,"435":1,"436":1,"437":104,"438":0,"439":104,"440":99,"441":28,"442":28,"443":1,"444":1,"445":0,"446":1,"447":128,"448":1,"449":127,"450":127,"451":1,"452":15,"453":6,"454":0,"455":6,"456":6,"457":9,"458":15,"459":13,"460":1,"461":93,"462":88,"463":1,"464":156,"465":145,"466":11,"467":7,"468":4,"469":1,"470":152,"471":1,"472":75,"473":75,"474":7,"475":7,"476":7,"477":2,"478":2,"479":2,"480":2,"481":5,"482":2,"483":68,"484":3,"485":3,"486":3,"487":3,"488":0,"489":0,"490":0,"491":0,"492":0,"493":0,"494":3,"495":3,"496":65,"497":23,"498":23,"499":23,"500":8,"501":15,"502":4,"503":11,"504":11,"505":22,"506":22,"507":10,"508":21,"509":21,"510":42,"511":21,"512":21,"513":21,"514":21,"515":21,"516":19,"517":44,"518":44,"519":1,"520":15,"521":1,"522":3,"523":2,"524":1,"525":0,"526":1,"527":1,"528":1,"529":5,"530":2,"531":1,"532":31,"533":16,"534":15,"535":15,"536":3,"537":12,"538":27,"539":1,"540":22,"541":1,"542":80,"543":0,"544":0,"545":0,"546":0,"547":1,"548":25,"549":25,"550":25,"551":25,"552":32,"553":24,"554":8,"555":8,"556":1,"557":31,"558":31,"559":4,"560":31,"561":31,"562":31,"563":31,"564":25,"565":2,"566":23,"567":1,"568":48,"569":48,"570":2,"571":2,"572":46,"573":46,"574":42,"575":31,"576":31,"577":31,"578":1,"579":46,"580":46,"581":18,"582":18,"583":18,"584":18,"585":11,"586":35,"587":8,"588":8,"589":8,"590":7,"591":7,"592":7,"593":7,"594":27,"595":24,"596":32,"597":23,"598":9,"599":9,"600":1,"601":31,"602":31,"603":31,"604":31,"605":31,"606":1,"607":18,"608":18,"609":18,"610":18},"f":{"1":2026,"2":212,"3":2906,"4":152,"5":7,"6":33,"7":72,"8":3,"9":15,"10":80,"11":292,"12":27,"13":32,"14":24,"15":19,"16":34,"17":315,"18":55,"19":14,"20":46,"21":39,"22":1109,"23":772,"24":2792,"25":38,"26":30,"27":352,"28":389,"29":432,"30":412,"31":156,"32":102,"33":91,"34":152,"35":15,"36":93,"37":156,"38":152,"39":75,"40":15,"41":3,"42":5,"43":31,"44":22,"45":80,"46":25,"47":48,"48":46,"49":18},"b":{"1":[5,2901],"2":[41,72,3,15,80,293,134,27,32,24,19,34,49,78,315,55,14,109,46,78,127,590],"3":[1,292],"4":[1,133],"5":[4,74],"6":[127,0],"7":[1,126],"8":[3,123],"9":[48,75],"10":[13,577],"11":[9,4],"12":[13,10],"13":[39,1155],"14":[1194,543,145],"15":[6,146],"16":[0,7],"17":[7,5],"18":[1,6],"19":[0,33],"20":[43,29],"21":[4,25],"22":[48,4],"23":[52,21],"24":[45,3],"25":[48,47,19],"26":[1,2],"27":[3,1],"28":[22,46],"29":[29,17],"30":[5,75],"31":[80,8,7],"32":[10,69],"33":[1,9],"34":[40,29],"35":[69,56,39],"36":[30,9],"37":[39,26],"38":[20,10],"39":[30,28],"40":[1,18],"41":[17,12],"42":[29,17],"43":[0,12],"44":[1,11],"45":[7,11],"46":[2,30],"47":[32,4],"48":[12,18],"49":[26,27],"50":[53,35],"51":[7,19],"52":[18,8],"53":[2,6],"54":[27,0],"55":[14,3],"56":[1,18],"57":[30,4],"58":[4,18],"59":[2,20],"60":[22,4],"61":[2,12],"62":[3,4],"63":[26,10],"64":[0,10],"65":[4,0],"66":[11,2659],"67":[2670,2559,12],"68":[212,1692],"69":[1904,1788,1716,919,213],"70":[182,30],"71":[212,210],"72":[3,175],"73":[53,1966],"74":[20,6],"75":[22,4],"76":[2,28],"77":[13,15],"78":[208,136],"79":[5,131],"80":[136,11],"81":[11,10],"82":[4,127],"83":[131,8],"84":[8,4,2],"85":[287,37],"86":[50,382],"87":[0,50],"88":[50,0],"89":[15,417],"90":[432,309,301,20],"91":[329,88],"92":[417,96],"93":[125,71],"94":[108,19],"95":[102,96],"96":[19,139],"97":[8,131],"98":[6,125],"99":[131,116],"100":[128,37],"101":[35,93],"102":[0,35],"103":[106,21],"104":[127,120,112],"105":[15,91],"106":[1,90],"107":[91,2,2,2],"108":[112,42,42,41],"109":[2,110],"110":[1,1],"111":[2,1],"112":[103,9],"113":[31,72],"114":[103,101,95,91,86,35,17],"115":[103,102,78,73,67,5],"116":[15,88],"117":[2,13],"118":[3,10],"119":[1,9],"120":[0,9],"121":[97,24,23,23,1],"122":[2,95],"123":[1,103],"124":[0,1],"125":[0,104],"126":[104,95,10],"127":[28,71],"128":[16,12],"129":[1,27],"130":[0,1],"131":[1,127],"132":[6,9],"133":[0,6],"134":[145,11],"135":[7,4],"136":[11,8],"137":[24,128],"138":[7,68],"139":[2,5],"140":[7,2],"141":[3,65],"142":[68,3],"143":[0,3],"144":[3,1],"145":[23,42],"146":[8,15],"147":[4,11],"148":[10,12],"149":[21,21],"150":[42,28],"151":[2,1],"152":[2,2,2],"153":[0,1],"154":[1,1,1],"155":[2,3],"156":[16,15],"157":[15,1],"158":[3,12],"159":[0,80],"160":[0,0,0],"161":[0,0],"162":[0,0],"163":[24,8],"164":[1,7],"165":[4,27],"166":[31,4],"167":[8,23],"168":[2,23],"169":[25,4],"170":[2,46],"171":[29,2],"172":[18,28],"173":[11,7],"174":[8,27],"175":[23,9],"176":[1,8],"177":[9,22]},"hash":"5b810cc6fbdb56d9f2a5aa99bb233c83f7ed8b6b"}
+,"/home/travis/build/babel/babylon/src/parser/util.js": {"path":"/home/travis/build/babel/babylon/src/parser/util.js","statementMap":{"1":{"start":{"line":5,"column":11},"end":{"line":5,"column":27}},"2":{"start":{"line":11,"column":0},"end":{"line":16,"column":2}},"3":{"start":{"line":12,"column":2},"end":{"line":12,"column":20}},"4":{"start":{"line":12,"column":13},"end":{"line":12,"column":20}},"5":{"start":{"line":14,"column":14},"end":{"line":14,"column":43}},"6":{"start":{"line":15,"column":2},"end":{"line":15,"column":19}},"7":{"start":{"line":20,"column":0},"end":{"line":22,"column":2}},"8":{"start":{"line":21,"column":2},"end":{"line":21,"column":62}},"9":{"start":{"line":26,"column":0},"end":{"line":32,"column":2}},"10":{"start":{"line":27,"column":2},"end":{"line":31,"column":3}},"11":{"start":{"line":28,"column":4},"end":{"line":28,"column":16}},"12":{"start":{"line":30,"column":4},"end":{"line":30,"column":22}},"13":{"start":{"line":36,"column":0},"end":{"line":38,"column":2}},"14":{"start":{"line":37,"column":2},"end":{"line":37,"column":58}},"15":{"start":{"line":42,"column":0},"end":{"line":44,"column":2}},"16":{"start":{"line":43,"column":2},"end":{"line":43,"column":56}},"17":{"start":{"line":48,"column":0},"end":{"line":50,"column":2}},"18":{"start":{"line":49,"column":2},"end":{"line":49,"column":64}},"19":{"start":{"line":49,"column":33},"end":{"line":49,"column":64}},"20":{"start":{"line":54,"column":0},"end":{"line":58,"column":2}},"21":{"start":{"line":55,"column":2},"end":{"line":57,"column":78}},"22":{"start":{"line":62,"column":0},"end":{"line":64,"column":2}},"23":{"start":{"line":63,"column":2},"end":{"line":63,"column":56}},"24":{"start":{"line":69,"column":0},"end":{"line":71,"column":2}},"25":{"start":{"line":70,"column":2},"end":{"line":70,"column":50}},"26":{"start":{"line":70,"column":32},"end":{"line":70,"column":50}},"27":{"start":{"line":76,"column":0},"end":{"line":78,"column":2}},"28":{"start":{"line":77,"column":2},"end":{"line":77,"column":48}},"29":{"start":{"line":82,"column":0},"end":{"line":84,"column":2}},"30":{"start":{"line":83,"column":2},"end":{"line":83,"column":60}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":11,"column":14},"end":{"line":11,"column":15}},"loc":{"start":{"line":11,"column":40},"end":{"line":16,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":20,"column":18},"end":{"line":20,"column":19}},"loc":{"start":{"line":20,"column":32},"end":{"line":22,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":26,"column":22},"end":{"line":26,"column":23}},"loc":{"start":{"line":26,"column":36},"end":{"line":32,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":36,"column":18},"end":{"line":36,"column":19}},"loc":{"start":{"line":36,"column":34},"end":{"line":38,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":42,"column":19},"end":{"line":42,"column":20}},"loc":{"start":{"line":42,"column":35},"end":{"line":44,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":48,"column":22},"end":{"line":48,"column":23}},"loc":{"start":{"line":48,"column":47},"end":{"line":50,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":54,"column":24},"end":{"line":54,"column":25}},"loc":{"start":{"line":54,"column":36},"end":{"line":58,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":62,"column":22},"end":{"line":62,"column":23}},"loc":{"start":{"line":62,"column":34},"end":{"line":64,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":69,"column":15},"end":{"line":69,"column":16}},"loc":{"start":{"line":69,"column":27},"end":{"line":71,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":76,"column":12},"end":{"line":76,"column":13}},"loc":{"start":{"line":76,"column":33},"end":{"line":78,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":82,"column":16},"end":{"line":82,"column":17}},"loc":{"start":{"line":82,"column":61},"end":{"line":84,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":12,"column":2},"end":{"line":12,"column":20}},"type":"if","locations":[{"start":{"line":12,"column":2},"end":{"line":12,"column":20}},{"start":{"line":12,"column":2},"end":{"line":12,"column":20}}]},"2":{"loc":{"start":{"line":14,"column":27},"end":{"line":14,"column":43}},"type":"binary-expr","locations":[{"start":{"line":14,"column":27},"end":{"line":14,"column":37}},{"start":{"line":14,"column":41},"end":{"line":14,"column":43}}]},"3":{"loc":{"start":{"line":21,"column":9},"end":{"line":21,"column":61}},"type":"binary-expr","locations":[{"start":{"line":21,"column":9},"end":{"line":21,"column":34}},{"start":{"line":21,"column":38},"end":{"line":21,"column":61}}]},"4":{"loc":{"start":{"line":27,"column":2},"end":{"line":31,"column":3}},"type":"if","locations":[{"start":{"line":27,"column":2},"end":{"line":31,"column":3}},{"start":{"line":27,"column":2},"end":{"line":31,"column":3}}]},"5":{"loc":{"start":{"line":37,"column":9},"end":{"line":37,"column":57}},"type":"binary-expr","locations":[{"start":{"line":37,"column":9},"end":{"line":37,"column":28}},{"start":{"line":37,"column":32},"end":{"line":37,"column":57}}]},"6":{"loc":{"start":{"line":43,"column":9},"end":{"line":43,"column":55}},"type":"binary-expr","locations":[{"start":{"line":43,"column":9},"end":{"line":43,"column":34}},{"start":{"line":43,"column":38},"end":{"line":43,"column":55}}]},"7":{"loc":{"start":{"line":49,"column":2},"end":{"line":49,"column":64}},"type":"if","locations":[{"start":{"line":49,"column":2},"end":{"line":49,"column":64}},{"start":{"line":49,"column":2},"end":{"line":49,"column":64}}]},"8":{"loc":{"start":{"line":55,"column":9},"end":{"line":57,"column":77}},"type":"binary-expr","locations":[{"start":{"line":55,"column":9},"end":{"line":55,"column":27}},{"start":{"line":56,"column":4},"end":{"line":56,"column":25}},{"start":{"line":57,"column":4},"end":{"line":57,"column":77}}]},"9":{"loc":{"start":{"line":63,"column":9},"end":{"line":63,"column":55}},"type":"binary-expr","locations":[{"start":{"line":63,"column":9},"end":{"line":63,"column":26}},{"start":{"line":63,"column":30},"end":{"line":63,"column":55}}]},"10":{"loc":{"start":{"line":70,"column":2},"end":{"line":70,"column":50}},"type":"if","locations":[{"start":{"line":70,"column":2},"end":{"line":70,"column":50}},{"start":{"line":70,"column":2},"end":{"line":70,"column":50}}]},"11":{"loc":{"start":{"line":77,"column":9},"end":{"line":77,"column":47}},"type":"binary-expr","locations":[{"start":{"line":77,"column":9},"end":{"line":77,"column":23}},{"start":{"line":77,"column":27},"end":{"line":77,"column":47}}]},"12":{"loc":{"start":{"line":82,"column":31},"end":{"line":82,"column":59}},"type":"default-arg","locations":[{"start":{"line":82,"column":41},"end":{"line":82,"column":59}}]},"13":{"loc":{"start":{"line":83,"column":13},"end":{"line":83,"column":49}},"type":"cond-expr","locations":[{"start":{"line":83,"column":27},"end":{"line":83,"column":30}},{"start":{"line":83,"column":33},"end":{"line":83,"column":49}}]}},"s":{"1":1,"2":1,"3":2801,"4":0,"5":2801,"6":2801,"7":1,"8":1089,"9":1,"10":126,"11":126,"12":0,"13":1,"14":432,"15":1,"16":157,"17":1,"18":60,"19":14,"20":1,"21":2706,"22":1,"23":1721,"24":1,"25":1523,"26":37,"27":1,"28":3953,"29":1,"30":349},"f":{"1":2801,"2":1089,"3":126,"4":432,"5":157,"6":60,"7":2706,"8":1721,"9":1523,"10":3953,"11":349},"b":{"1":[0,2801],"2":[2801,1460],"3":[1089,494],"4":[126,0],"5":[432,288],"6":[157,82],"7":[14,46],"8":[2706,1781,1627],"9":[1721,1122],"10":[37,1486],"11":[3953,72],"12":[348],"13":[24,325]},"hash":"b42cf3707be28a330ce42b4890cc4441eef02c27"}
+,"/home/travis/build/babel/babylon/src/plugins/flow.js": {"path":"/home/travis/build/babel/babylon/src/plugins/flow.js","statementMap":{"1":{"start":{"line":8,"column":9},"end":{"line":8,"column":25}},"2":{"start":{"line":10,"column":0},"end":{"line":22,"column":2}},"3":{"start":{"line":11,"column":18},"end":{"line":11,"column":35}},"4":{"start":{"line":12,"column":2},"end":{"line":12,"column":27}},"5":{"start":{"line":13,"column":2},"end":{"line":13,"column":31}},"6":{"start":{"line":14,"column":2},"end":{"line":18,"column":3}},"7":{"start":{"line":15,"column":4},"end":{"line":17,"column":5}},"8":{"start":{"line":16,"column":6},"end":{"line":16,"column":18}},"9":{"start":{"line":19,"column":13},"end":{"line":19,"column":33}},"10":{"start":{"line":20,"column":2},"end":{"line":20,"column":32}},"11":{"start":{"line":21,"column":2},"end":{"line":21,"column":14}},"12":{"start":{"line":24,"column":0},"end":{"line":28,"column":2}},"13":{"start":{"line":25,"column":2},"end":{"line":25,"column":14}},"14":{"start":{"line":26,"column":2},"end":{"line":26,"column":41}},"15":{"start":{"line":27,"column":2},"end":{"line":27,"column":47}},"16":{"start":{"line":30,"column":0},"end":{"line":59,"column":2}},"17":{"start":{"line":31,"column":2},"end":{"line":31,"column":14}},"18":{"start":{"line":33,"column":11},"end":{"line":33,"column":43}},"19":{"start":{"line":35,"column":17},"end":{"line":35,"column":33}},"20":{"start":{"line":36,"column":22},"end":{"line":36,"column":38}},"21":{"start":{"line":38,"column":2},"end":{"line":42,"column":3}},"22":{"start":{"line":39,"column":4},"end":{"line":39,"column":71}},"23":{"start":{"line":41,"column":4},"end":{"line":41,"column":35}},"24":{"start":{"line":44,"column":2},"end":{"line":44,"column":25}},"25":{"start":{"line":45,"column":12},"end":{"line":45,"column":46}},"26":{"start":{"line":46,"column":2},"end":{"line":46,"column":31}},"27":{"start":{"line":47,"column":2},"end":{"line":47,"column":27}},"28":{"start":{"line":48,"column":2},"end":{"line":48,"column":25}},"29":{"start":{"line":49,"column":2},"end":{"line":49,"column":56}},"30":{"start":{"line":51,"column":2},"end":{"line":51,"column":85}},"31":{"start":{"line":52,"column":2},"end":{"line":52,"column":71}},"32":{"start":{"line":54,"column":2},"end":{"line":54,"column":31}},"33":{"start":{"line":56,"column":2},"end":{"line":56,"column":19}},"34":{"start":{"line":58,"column":2},"end":{"line":58,"column":50}},"35":{"start":{"line":61,"column":0},"end":{"line":81,"column":2}},"36":{"start":{"line":62,"column":2},"end":{"line":80,"column":3}},"37":{"start":{"line":63,"column":4},"end":{"line":63,"column":44}},"38":{"start":{"line":64,"column":9},"end":{"line":80,"column":3}},"39":{"start":{"line":65,"column":4},"end":{"line":65,"column":47}},"40":{"start":{"line":66,"column":9},"end":{"line":80,"column":3}},"41":{"start":{"line":67,"column":4},"end":{"line":67,"column":47}},"42":{"start":{"line":68,"column":9},"end":{"line":80,"column":3}},"43":{"start":{"line":69,"column":4},"end":{"line":73,"column":5}},"44":{"start":{"line":70,"column":6},"end":{"line":70,"column":54}},"45":{"start":{"line":72,"column":6},"end":{"line":72,"column":47}},"46":{"start":{"line":74,"column":9},"end":{"line":80,"column":3}},"47":{"start":{"line":75,"column":4},"end":{"line":75,"column":48}},"48":{"start":{"line":76,"column":9},"end":{"line":80,"column":3}},"49":{"start":{"line":77,"column":4},"end":{"line":77,"column":48}},"50":{"start":{"line":79,"column":4},"end":{"line":79,"column":22}},"51":{"start":{"line":83,"column":0},"end":{"line":88,"column":2}},"52":{"start":{"line":84,"column":2},"end":{"line":84,"column":14}},"53":{"start":{"line":85,"column":2},"end":{"line":85,"column":54}},"54":{"start":{"line":86,"column":2},"end":{"line":86,"column":19}},"55":{"start":{"line":87,"column":2},"end":{"line":87,"column":50}},"56":{"start":{"line":90,"column":0},"end":{"line":113,"column":2}},"57":{"start":{"line":91,"column":2},"end":{"line":91,"column":14}},"58":{"start":{"line":93,"column":2},"end":{"line":97,"column":3}},"59":{"start":{"line":94,"column":4},"end":{"line":94,"column":35}},"60":{"start":{"line":96,"column":4},"end":{"line":96,"column":37}},"61":{"start":{"line":99,"column":17},"end":{"line":99,"column":45}},"62":{"start":{"line":100,"column":13},"end":{"line":100,"column":31}},"63":{"start":{"line":101,"column":2},"end":{"line":101,"column":25}},"64":{"start":{"line":102,"column":2},"end":{"line":108,"column":3}},"65":{"start":{"line":103,"column":16},"end":{"line":103,"column":32}},"66":{"start":{"line":105,"column":4},"end":{"line":105,"column":106}},"67":{"start":{"line":107,"column":4},"end":{"line":107,"column":44}},"68":{"start":{"line":109,"column":2},"end":{"line":109,"column":25}},"69":{"start":{"line":111,"column":2},"end":{"line":111,"column":46}},"70":{"start":{"line":112,"column":2},"end":{"line":112,"column":48}},"71":{"start":{"line":115,"column":0},"end":{"line":121,"column":2}},"72":{"start":{"line":116,"column":2},"end":{"line":116,"column":34}},"73":{"start":{"line":117,"column":2},"end":{"line":117,"column":22}},"74":{"start":{"line":118,"column":2},"end":{"line":118,"column":35}},"75":{"start":{"line":119,"column":2},"end":{"line":119,"column":55}},"76":{"start":{"line":120,"column":2},"end":{"line":120,"column":55}},"77":{"start":{"line":123,"column":0},"end":{"line":127,"column":2}},"78":{"start":{"line":124,"column":2},"end":{"line":124,"column":14}},"79":{"start":{"line":125,"column":2},"end":{"line":125,"column":32}},"80":{"start":{"line":126,"column":2},"end":{"line":126,"column":51}},"81":{"start":{"line":129,"column":0},"end":{"line":133,"column":2}},"82":{"start":{"line":130,"column":2},"end":{"line":130,"column":14}},"83":{"start":{"line":131,"column":2},"end":{"line":131,"column":35}},"84":{"start":{"line":132,"column":2},"end":{"line":132,"column":51}},"85":{"start":{"line":137,"column":0},"end":{"line":163,"column":2}},"86":{"start":{"line":138,"column":2},"end":{"line":138,"column":35}},"87":{"start":{"line":140,"column":2},"end":{"line":144,"column":3}},"88":{"start":{"line":141,"column":4},"end":{"line":141,"column":67}},"89":{"start":{"line":143,"column":4},"end":{"line":143,"column":31}},"90":{"start":{"line":146,"column":2},"end":{"line":146,"column":20}},"91":{"start":{"line":147,"column":2},"end":{"line":147,"column":19}},"92":{"start":{"line":149,"column":2},"end":{"line":153,"column":3}},"93":{"start":{"line":150,"column":4},"end":{"line":152,"column":33}},"94":{"start":{"line":151,"column":6},"end":{"line":151,"column":58}},"95":{"start":{"line":155,"column":2},"end":{"line":160,"column":3}},"96":{"start":{"line":156,"column":4},"end":{"line":156,"column":16}},"97":{"start":{"line":157,"column":4},"end":{"line":159,"column":33}},"98":{"start":{"line":158,"column":6},"end":{"line":158,"column":57}},"99":{"start":{"line":162,"column":2},"end":{"line":162,"column":52}},"100":{"start":{"line":165,"column":0},"end":{"line":176,"column":2}},"101":{"start":{"line":166,"column":13},"end":{"line":166,"column":29}},"102":{"start":{"line":168,"column":2},"end":{"line":168,"column":52}},"103":{"start":{"line":169,"column":2},"end":{"line":173,"column":3}},"104":{"start":{"line":170,"column":4},"end":{"line":170,"column":69}},"105":{"start":{"line":172,"column":4},"end":{"line":172,"column":31}},"106":{"start":{"line":175,"column":2},"end":{"line":175,"column":51}},"107":{"start":{"line":178,"column":0},"end":{"line":181,"column":2}},"108":{"start":{"line":179,"column":2},"end":{"line":179,"column":42}},"109":{"start":{"line":180,"column":2},"end":{"line":180,"column":55}},"110":{"start":{"line":185,"column":0},"end":{"line":201,"column":2}},"111":{"start":{"line":186,"column":2},"end":{"line":186,"column":35}},"112":{"start":{"line":188,"column":2},"end":{"line":192,"column":3}},"113":{"start":{"line":189,"column":4},"end":{"line":189,"column":67}},"114":{"start":{"line":191,"column":4},"end":{"line":191,"column":31}},"115":{"start":{"line":194,"column":2},"end":{"line":197,"column":4}},"116":{"start":{"line":198,"column":2},"end":{"line":198,"column":19}},"117":{"start":{"line":200,"column":2},"end":{"line":200,"column":44}},"118":{"start":{"line":205,"column":0},"end":{"line":229,"column":2}},"119":{"start":{"line":206,"column":13},"end":{"line":206,"column":29}},"120":{"start":{"line":209,"column":2},"end":{"line":216,"column":3}},"121":{"start":{"line":210,"column":4},"end":{"line":214,"column":5}},"122":{"start":{"line":211,"column":6},"end":{"line":211,"column":24}},"123":{"start":{"line":212,"column":11},"end":{"line":214,"column":5}},"124":{"start":{"line":213,"column":6},"end":{"line":213,"column":25}},"125":{"start":{"line":215,"column":4},"end":{"line":215,"column":25}},"126":{"start":{"line":218,"column":14},"end":{"line":218,"column":67}},"127":{"start":{"line":219,"column":2},"end":{"line":219,"column":25}},"128":{"start":{"line":220,"column":2},"end":{"line":220,"column":27}},"129":{"start":{"line":221,"column":2},"end":{"line":221,"column":36}},"130":{"start":{"line":223,"column":2},"end":{"line":226,"column":3}},"131":{"start":{"line":224,"column":4},"end":{"line":224,"column":20}},"132":{"start":{"line":225,"column":4},"end":{"line":225,"column":41}},"133":{"start":{"line":228,"column":2},"end":{"line":228,"column":48}},"134":{"start":{"line":231,"column":0},"end":{"line":255,"column":2}},"135":{"start":{"line":232,"column":20},"end":{"line":232,"column":37}},"136":{"start":{"line":233,"column":13},"end":{"line":233,"column":29}},"137":{"start":{"line":234,"column":2},"end":{"line":234,"column":19}},"138":{"start":{"line":236,"column":2},"end":{"line":236,"column":27}},"139":{"start":{"line":238,"column":2},"end":{"line":242,"column":3}},"140":{"start":{"line":239,"column":4},"end":{"line":239,"column":16}},"141":{"start":{"line":241,"column":4},"end":{"line":241,"column":22}},"142":{"start":{"line":244,"column":2},"end":{"line":249,"column":36}},"143":{"start":{"line":245,"column":4},"end":{"line":245,"column":52}},"144":{"start":{"line":246,"column":4},"end":{"line":248,"column":5}},"145":{"start":{"line":247,"column":6},"end":{"line":247,"column":28}},"146":{"start":{"line":250,"column":2},"end":{"line":250,"column":29}},"147":{"start":{"line":252,"column":2},"end":{"line":252,"column":32}},"148":{"start":{"line":254,"column":2},"end":{"line":254,"column":59}},"149":{"start":{"line":257,"column":0},"end":{"line":275,"column":2}},"150":{"start":{"line":258,"column":13},"end":{"line":258,"column":29}},"151":{"start":{"line":258,"column":43},"end":{"line":258,"column":60}},"152":{"start":{"line":259,"column":2},"end":{"line":259,"column":19}},"153":{"start":{"line":261,"column":2},"end":{"line":261,"column":27}},"154":{"start":{"line":263,"column":2},"end":{"line":263,"column":29}},"155":{"start":{"line":264,"column":2},"end":{"line":269,"column":3}},"156":{"start":{"line":265,"column":4},"end":{"line":265,"column":43}},"157":{"start":{"line":266,"column":4},"end":{"line":268,"column":5}},"158":{"start":{"line":267,"column":6},"end":{"line":267,"column":28}},"159":{"start":{"line":270,"column":2},"end":{"line":270,"column":29}},"160":{"start":{"line":272,"column":2},"end":{"line":272,"column":32}},"161":{"start":{"line":274,"column":2},"end":{"line":274,"column":61}},"162":{"start":{"line":277,"column":0},"end":{"line":279,"column":2}},"163":{"start":{"line":278,"column":2},"end":{"line":278,"column":107}},"164":{"start":{"line":281,"column":0},"end":{"line":292,"column":2}},"165":{"start":{"line":282,"column":2},"end":{"line":282,"column":25}},"166":{"start":{"line":284,"column":2},"end":{"line":284,"column":27}},"167":{"start":{"line":285,"column":2},"end":{"line":285,"column":46}},"168":{"start":{"line":286,"column":2},"end":{"line":286,"column":45}},"169":{"start":{"line":287,"column":2},"end":{"line":287,"column":27}},"170":{"start":{"line":288,"column":2},"end":{"line":288,"column":47}},"171":{"start":{"line":290,"column":2},"end":{"line":290,"column":33}},"172":{"start":{"line":291,"column":2},"end":{"line":291,"column":52}},"173":{"start":{"line":294,"column":0},"end":{"line":318,"column":2}},"174":{"start":{"line":295,"column":2},"end":{"line":295,"column":19}},"175":{"start":{"line":296,"column":2},"end":{"line":296,"column":19}},"176":{"start":{"line":297,"column":2},"end":{"line":297,"column":29}},"177":{"start":{"line":299,"column":2},"end":{"line":301,"column":3}},"178":{"start":{"line":300,"column":4},"end":{"line":300,"column":67}},"179":{"start":{"line":303,"column":2},"end":{"line":303,"column":25}},"180":{"start":{"line":304,"column":2},"end":{"line":309,"column":3}},"181":{"start":{"line":305,"column":4},"end":{"line":305,"column":56}},"182":{"start":{"line":306,"column":4},"end":{"line":308,"column":5}},"183":{"start":{"line":307,"column":6},"end":{"line":307,"column":28}},"184":{"start":{"line":311,"column":2},"end":{"line":313,"column":3}},"185":{"start":{"line":312,"column":4},"end":{"line":312,"column":50}},"186":{"start":{"line":314,"column":2},"end":{"line":314,"column":25}},"187":{"start":{"line":315,"column":2},"end":{"line":315,"column":52}},"188":{"start":{"line":317,"column":2},"end":{"line":317,"column":57}},"189":{"start":{"line":320,"column":0},"end":{"line":328,"column":2}},"190":{"start":{"line":321,"column":13},"end":{"line":321,"column":49}},"191":{"start":{"line":322,"column":2},"end":{"line":322,"column":87}},"192":{"start":{"line":323,"column":2},"end":{"line":323,"column":25}},"193":{"start":{"line":324,"column":2},"end":{"line":324,"column":17}},"194":{"start":{"line":325,"column":2},"end":{"line":325,"column":24}},"195":{"start":{"line":326,"column":2},"end":{"line":326,"column":33}},"196":{"start":{"line":327,"column":2},"end":{"line":327,"column":53}},"197":{"start":{"line":330,"column":0},"end":{"line":336,"column":2}},"198":{"start":{"line":331,"column":18},"end":{"line":331,"column":34}},"199":{"start":{"line":332,"column":2},"end":{"line":332,"column":25}},"200":{"start":{"line":333,"column":2},"end":{"line":333,"column":60}},"201":{"start":{"line":334,"column":2},"end":{"line":334,"column":33}},"202":{"start":{"line":335,"column":2},"end":{"line":335,"column":57}},"203":{"start":{"line":338,"column":0},"end":{"line":389,"column":2}},"204":{"start":{"line":339,"column":18},"end":{"line":339,"column":34}},"205":{"start":{"line":344,"column":2},"end":{"line":344,"column":32}},"206":{"start":{"line":345,"column":2},"end":{"line":345,"column":28}},"207":{"start":{"line":346,"column":2},"end":{"line":346,"column":26}},"208":{"start":{"line":348,"column":2},"end":{"line":348,"column":25}},"209":{"start":{"line":350,"column":2},"end":{"line":384,"column":3}},"210":{"start":{"line":351,"column":19},"end":{"line":351,"column":24}},"211":{"start":{"line":352,"column":19},"end":{"line":352,"column":35}},"212":{"start":{"line":352,"column":48},"end":{"line":352,"column":67}},"213":{"start":{"line":353,"column":4},"end":{"line":353,"column":28}},"214":{"start":{"line":354,"column":4},"end":{"line":357,"column":5}},"215":{"start":{"line":355,"column":6},"end":{"line":355,"column":18}},"216":{"start":{"line":356,"column":6},"end":{"line":356,"column":22}},"217":{"start":{"line":359,"column":4},"end":{"line":383,"column":5}},"218":{"start":{"line":360,"column":6},"end":{"line":360,"column":79}},"219":{"start":{"line":361,"column":11},"end":{"line":383,"column":5}},"220":{"start":{"line":362,"column":6},"end":{"line":362,"column":93}},"221":{"start":{"line":364,"column":6},"end":{"line":368,"column":7}},"222":{"start":{"line":365,"column":8},"end":{"line":365,"column":45}},"223":{"start":{"line":367,"column":8},"end":{"line":367,"column":56}},"224":{"start":{"line":369,"column":6},"end":{"line":382,"column":7}},"225":{"start":{"line":371,"column":8},"end":{"line":371,"column":109}},"226":{"start":{"line":373,"column":8},"end":{"line":375,"column":9}},"227":{"start":{"line":374,"column":10},"end":{"line":374,"column":26}},"228":{"start":{"line":376,"column":8},"end":{"line":376,"column":31}},"229":{"start":{"line":377,"column":8},"end":{"line":377,"column":53}},"230":{"start":{"line":378,"column":8},"end":{"line":378,"column":33}},"231":{"start":{"line":379,"column":8},"end":{"line":379,"column":31}},"232":{"start":{"line":380,"column":8},"end":{"line":380,"column":39}},"233":{"start":{"line":381,"column":8},"end":{"line":381,"column":79}},"234":{"start":{"line":386,"column":2},"end":{"line":386,"column":25}},"235":{"start":{"line":388,"column":2},"end":{"line":388,"column":60}},"236":{"start":{"line":391,"column":0},"end":{"line":395,"column":2}},"237":{"start":{"line":392,"column":2},"end":{"line":394,"column":3}},"238":{"start":{"line":393,"column":4},"end":{"line":393,"column":22}},"239":{"start":{"line":397,"column":0},"end":{"line":410,"column":2}},"240":{"start":{"line":398,"column":2},"end":{"line":398,"column":42}},"241":{"start":{"line":399,"column":2},"end":{"line":399,"column":45}},"242":{"start":{"line":400,"column":13},"end":{"line":400,"column":41}},"243":{"start":{"line":402,"column":2},"end":{"line":407,"column":3}},"244":{"start":{"line":403,"column":16},"end":{"line":403,"column":52}},"245":{"start":{"line":404,"column":4},"end":{"line":404,"column":31}},"246":{"start":{"line":405,"column":4},"end":{"line":405,"column":38}},"247":{"start":{"line":406,"column":4},"end":{"line":406,"column":61}},"248":{"start":{"line":409,"column":2},"end":{"line":409,"column":14}},"249":{"start":{"line":412,"column":0},"end":{"line":423,"column":2}},"250":{"start":{"line":413,"column":13},"end":{"line":413,"column":49}},"251":{"start":{"line":415,"column":2},"end":{"line":415,"column":29}},"252":{"start":{"line":416,"column":2},"end":{"line":416,"column":74}},"253":{"start":{"line":418,"column":2},"end":{"line":420,"column":3}},"254":{"start":{"line":419,"column":4},"end":{"line":419,"column":69}},"255":{"start":{"line":422,"column":2},"end":{"line":422,"column":56}},"256":{"start":{"line":425,"column":0},"end":{"line":430,"column":2}},"257":{"start":{"line":426,"column":13},"end":{"line":426,"column":29}},"258":{"start":{"line":427,"column":2},"end":{"line":427,"column":26}},"259":{"start":{"line":428,"column":2},"end":{"line":428,"column":46}},"260":{"start":{"line":429,"column":2},"end":{"line":429,"column":55}},"261":{"start":{"line":432,"column":0},"end":{"line":444,"column":2}},"262":{"start":{"line":433,"column":13},"end":{"line":433,"column":29}},"263":{"start":{"line":434,"column":2},"end":{"line":434,"column":18}},"264":{"start":{"line":435,"column":2},"end":{"line":435,"column":27}},"265":{"start":{"line":437,"column":2},"end":{"line":441,"column":3}},"266":{"start":{"line":438,"column":4},"end":{"line":438,"column":42}},"267":{"start":{"line":439,"column":4},"end":{"line":439,"column":39}},"268":{"start":{"line":439,"column":33},"end":{"line":439,"column":39}},"269":{"start":{"line":440,"column":4},"end":{"line":440,"column":26}},"270":{"start":{"line":442,"column":2},"end":{"line":442,"column":27}},"271":{"start":{"line":443,"column":2},"end":{"line":443,"column":54}},"272":{"start":{"line":446,"column":0},"end":{"line":456,"column":2}},"273":{"start":{"line":447,"column":17},"end":{"line":447,"column":22}},"274":{"start":{"line":448,"column":13},"end":{"line":448,"column":29}},"275":{"start":{"line":449,"column":2},"end":{"line":449,"column":37}},"276":{"start":{"line":450,"column":2},"end":{"line":452,"column":3}},"277":{"start":{"line":451,"column":4},"end":{"line":451,"column":20}},"278":{"start":{"line":453,"column":2},"end":{"line":453,"column":27}},"279":{"start":{"line":454,"column":2},"end":{"line":454,"column":56}},"280":{"start":{"line":455,"column":2},"end":{"line":455,"column":52}},"281":{"start":{"line":458,"column":0},"end":{"line":470,"column":2}},"282":{"start":{"line":459,"column":12},"end":{"line":459,"column":38}},"283":{"start":{"line":460,"column":2},"end":{"line":465,"column":3}},"284":{"start":{"line":461,"column":4},"end":{"line":461,"column":55}},"285":{"start":{"line":462,"column":4},"end":{"line":464,"column":5}},"286":{"start":{"line":463,"column":6},"end":{"line":463,"column":28}},"287":{"start":{"line":466,"column":2},"end":{"line":468,"column":3}},"288":{"start":{"line":467,"column":4},"end":{"line":467,"column":49}},"289":{"start":{"line":469,"column":2},"end":{"line":469,"column":13}},"290":{"start":{"line":472,"column":0},"end":{"line":496,"column":2}},"291":{"start":{"line":473,"column":2},"end":{"line":495,"column":3}},"292":{"start":{"line":475,"column":6},"end":{"line":475,"column":56}},"293":{"start":{"line":478,"column":6},"end":{"line":478,"column":57}},"294":{"start":{"line":482,"column":6},"end":{"line":482,"column":60}},"295":{"start":{"line":485,"column":6},"end":{"line":485,"column":58}},"296":{"start":{"line":488,"column":6},"end":{"line":488,"column":59}},"297":{"start":{"line":491,"column":6},"end":{"line":491,"column":59}},"298":{"start":{"line":494,"column":6},"end":{"line":494,"column":63}},"299":{"start":{"line":501,"column":0},"end":{"line":619,"column":2}},"300":{"start":{"line":502,"column":17},"end":{"line":502,"column":33}},"301":{"start":{"line":502,"column":46},"end":{"line":502,"column":65}},"302":{"start":{"line":503,"column":13},"end":{"line":503,"column":29}},"303":{"start":{"line":506,"column":22},"end":{"line":506,"column":27}},"304":{"start":{"line":508,"column":2},"end":{"line":616,"column":3}},"305":{"start":{"line":510,"column":6},"end":{"line":510,"column":94}},"306":{"start":{"line":513,"column":6},"end":{"line":513,"column":40}},"307":{"start":{"line":516,"column":6},"end":{"line":516,"column":39}},"308":{"start":{"line":519,"column":6},"end":{"line":532,"column":7}},"309":{"start":{"line":520,"column":8},"end":{"line":520,"column":71}},"310":{"start":{"line":521,"column":8},"end":{"line":521,"column":31}},"311":{"start":{"line":522,"column":8},"end":{"line":522,"column":49}},"312":{"start":{"line":523,"column":8},"end":{"line":523,"column":33}},"313":{"start":{"line":524,"column":8},"end":{"line":524,"column":29}},"314":{"start":{"line":525,"column":8},"end":{"line":525,"column":31}},"315":{"start":{"line":527,"column":8},"end":{"line":527,"column":30}},"316":{"start":{"line":529,"column":8},"end":{"line":529,"column":47}},"317":{"start":{"line":531,"column":8},"end":{"line":531,"column":63}},"318":{"start":{"line":533,"column":6},"end":{"line":533,"column":12}},"319":{"start":{"line":536,"column":6},"end":{"line":536,"column":18}},"320":{"start":{"line":539,"column":6},"end":{"line":546,"column":7}},"321":{"start":{"line":540,"column":8},"end":{"line":545,"column":9}},"322":{"start":{"line":541,"column":22},"end":{"line":541,"column":43}},"323":{"start":{"line":542,"column":10},"end":{"line":542,"column":70}},"324":{"start":{"line":544,"column":10},"end":{"line":544,"column":31}},"325":{"start":{"line":548,"column":6},"end":{"line":552,"column":7}},"326":{"start":{"line":549,"column":8},"end":{"line":549,"column":36}},"327":{"start":{"line":550,"column":8},"end":{"line":550,"column":31}},"328":{"start":{"line":551,"column":8},"end":{"line":551,"column":20}},"329":{"start":{"line":554,"column":6},"end":{"line":554,"column":47}},"330":{"start":{"line":555,"column":6},"end":{"line":555,"column":31}},"331":{"start":{"line":556,"column":6},"end":{"line":556,"column":27}},"332":{"start":{"line":558,"column":6},"end":{"line":558,"column":29}},"333":{"start":{"line":560,"column":6},"end":{"line":560,"column":28}},"334":{"start":{"line":562,"column":6},"end":{"line":562,"column":45}},"335":{"start":{"line":563,"column":6},"end":{"line":563,"column":33}},"336":{"start":{"line":565,"column":6},"end":{"line":565,"column":61}},"337":{"start":{"line":568,"column":6},"end":{"line":568,"column":36}},"338":{"start":{"line":569,"column":6},"end":{"line":569,"column":50}},"339":{"start":{"line":570,"column":6},"end":{"line":570,"column":85}},"340":{"start":{"line":571,"column":6},"end":{"line":571,"column":18}},"341":{"start":{"line":572,"column":6},"end":{"line":572,"column":66}},"342":{"start":{"line":575,"column":6},"end":{"line":575,"column":40}},"343":{"start":{"line":576,"column":6},"end":{"line":576,"column":18}},"344":{"start":{"line":577,"column":6},"end":{"line":577,"column":67}},"345":{"start":{"line":580,"column":6},"end":{"line":589,"column":7}},"346":{"start":{"line":581,"column":8},"end":{"line":581,"column":20}},"347":{"start":{"line":582,"column":8},"end":{"line":582,"column":51}},"348":{"start":{"line":582,"column":33},"end":{"line":582,"column":51}},"349":{"start":{"line":584,"column":8},"end":{"line":584,"column":39}},"350":{"start":{"line":585,"column":8},"end":{"line":585,"column":52}},"351":{"start":{"line":586,"column":8},"end":{"line":586,"column":87}},"352":{"start":{"line":587,"column":8},"end":{"line":587,"column":20}},"353":{"start":{"line":588,"column":8},"end":{"line":588,"column":69}},"354":{"start":{"line":592,"column":6},"end":{"line":592,"column":36}},"355":{"start":{"line":593,"column":6},"end":{"line":593,"column":50}},"356":{"start":{"line":594,"column":6},"end":{"line":594,"column":85}},"357":{"start":{"line":595,"column":6},"end":{"line":595,"column":18}},"358":{"start":{"line":596,"column":6},"end":{"line":596,"column":67}},"359":{"start":{"line":599,"column":6},"end":{"line":599,"column":40}},"360":{"start":{"line":600,"column":6},"end":{"line":600,"column":18}},"361":{"start":{"line":601,"column":6},"end":{"line":601,"column":64}},"362":{"start":{"line":604,"column":6},"end":{"line":604,"column":40}},"363":{"start":{"line":605,"column":6},"end":{"line":605,"column":18}},"364":{"start":{"line":606,"column":6},"end":{"line":606,"column":57}},"365":{"start":{"line":609,"column":6},"end":{"line":609,"column":18}},"366":{"start":{"line":610,"column":6},"end":{"line":610,"column":59}},"367":{"start":{"line":613,"column":6},"end":{"line":615,"column":7}},"368":{"start":{"line":614,"column":8},"end":{"line":614,"column":42}},"369":{"start":{"line":618,"column":2},"end":{"line":618,"column":20}},"370":{"start":{"line":621,"column":0},"end":{"line":631,"column":2}},"371":{"start":{"line":622,"column":13},"end":{"line":622,"column":29}},"372":{"start":{"line":623,"column":13},"end":{"line":623,"column":59}},"373":{"start":{"line":624,"column":2},"end":{"line":630,"column":3}},"374":{"start":{"line":625,"column":4},"end":{"line":625,"column":29}},"375":{"start":{"line":626,"column":4},"end":{"line":626,"column":29}},"376":{"start":{"line":627,"column":4},"end":{"line":627,"column":56}},"377":{"start":{"line":629,"column":4},"end":{"line":629,"column":16}},"378":{"start":{"line":633,"column":0},"end":{"line":641,"column":2}},"379":{"start":{"line":634,"column":13},"end":{"line":634,"column":29}},"380":{"start":{"line":635,"column":2},"end":{"line":640,"column":3}},"381":{"start":{"line":636,"column":4},"end":{"line":636,"column":53}},"382":{"start":{"line":637,"column":4},"end":{"line":637,"column":59}},"383":{"start":{"line":639,"column":4},"end":{"line":639,"column":39}},"384":{"start":{"line":643,"column":0},"end":{"line":651,"column":2}},"385":{"start":{"line":644,"column":13},"end":{"line":644,"column":29}},"386":{"start":{"line":645,"column":13},"end":{"line":645,"column":39}},"387":{"start":{"line":646,"column":2},"end":{"line":646,"column":22}},"388":{"start":{"line":647,"column":2},"end":{"line":649,"column":3}},"389":{"start":{"line":648,"column":4},"end":{"line":648,"column":48}},"390":{"start":{"line":650,"column":2},"end":{"line":650,"column":94}},"391":{"start":{"line":653,"column":0},"end":{"line":661,"column":2}},"392":{"start":{"line":654,"column":13},"end":{"line":654,"column":29}},"393":{"start":{"line":655,"column":13},"end":{"line":655,"column":45}},"394":{"start":{"line":656,"column":2},"end":{"line":656,"column":22}},"395":{"start":{"line":657,"column":2},"end":{"line":659,"column":3}},"396":{"start":{"line":658,"column":4},"end":{"line":658,"column":54}},"397":{"start":{"line":660,"column":2},"end":{"line":660,"column":87}},"398":{"start":{"line":663,"column":0},"end":{"line":669,"column":2}},"399":{"start":{"line":664,"column":18},"end":{"line":664,"column":35}},"400":{"start":{"line":665,"column":2},"end":{"line":665,"column":27}},"401":{"start":{"line":666,"column":13},"end":{"line":666,"column":38}},"402":{"start":{"line":667,"column":2},"end":{"line":667,"column":32}},"403":{"start":{"line":668,"column":2},"end":{"line":668,"column":14}},"404":{"start":{"line":671,"column":0},"end":{"line":675,"column":2}},"405":{"start":{"line":672,"column":13},"end":{"line":672,"column":29}},"406":{"start":{"line":673,"column":2},"end":{"line":673,"column":56}},"407":{"start":{"line":674,"column":2},"end":{"line":674,"column":49}},"408":{"start":{"line":677,"column":0},"end":{"line":698,"column":2}},"409":{"start":{"line":679,"column":14},"end":{"line":679,"column":36}},"410":{"start":{"line":680,"column":24},"end":{"line":680,"column":29}},"411":{"start":{"line":682,"column":2},"end":{"line":685,"column":3}},"412":{"start":{"line":683,"column":4},"end":{"line":683,"column":29}},"413":{"start":{"line":684,"column":4},"end":{"line":684,"column":27}},"414":{"start":{"line":687,"column":2},"end":{"line":690,"column":3}},"415":{"start":{"line":688,"column":4},"end":{"line":688,"column":58}},"416":{"start":{"line":689,"column":4},"end":{"line":689,"column":39}},"417":{"start":{"line":692,"column":2},"end":{"line":695,"column":3}},"418":{"start":{"line":693,"column":4},"end":{"line":693,"column":26}},"419":{"start":{"line":694,"column":4},"end":{"line":694,"column":39}},"420":{"start":{"line":697,"column":2},"end":{"line":697,"column":15}},"421":{"start":{"line":700,"column":0},"end":{"line":709,"column":2}},"422":{"start":{"line":701,"column":2},"end":{"line":701,"column":55}},"423":{"start":{"line":703,"column":2},"end":{"line":708,"column":4}},"424":{"start":{"line":713,"column":2},"end":{"line":723,"column":5}},"425":{"start":{"line":714,"column":4},"end":{"line":722,"column":6}},"426":{"start":{"line":715,"column":6},"end":{"line":719,"column":7}},"427":{"start":{"line":718,"column":8},"end":{"line":718,"column":57}},"428":{"start":{"line":721,"column":6},"end":{"line":721,"column":53}},"429":{"start":{"line":726,"column":2},"end":{"line":737,"column":5}},"430":{"start":{"line":727,"column":4},"end":{"line":736,"column":6}},"431":{"start":{"line":729,"column":6},"end":{"line":735,"column":7}},"432":{"start":{"line":730,"column":19},"end":{"line":730,"column":35}},"433":{"start":{"line":731,"column":8},"end":{"line":731,"column":20}},"434":{"start":{"line":732,"column":8},"end":{"line":732,"column":45}},"435":{"start":{"line":734,"column":8},"end":{"line":734,"column":55}},"436":{"start":{"line":740,"column":2},"end":{"line":758,"column":5}},"437":{"start":{"line":741,"column":4},"end":{"line":757,"column":6}},"438":{"start":{"line":742,"column":6},"end":{"line":754,"column":7}},"439":{"start":{"line":743,"column":8},"end":{"line":753,"column":9}},"440":{"start":{"line":744,"column":10},"end":{"line":746,"column":11}},"441":{"start":{"line":745,"column":12},"end":{"line":745,"column":47}},"442":{"start":{"line":747,"column":15},"end":{"line":753,"column":9}},"443":{"start":{"line":748,"column":10},"end":{"line":752,"column":11}},"444":{"start":{"line":749,"column":12},"end":{"line":749,"column":49}},"445":{"start":{"line":750,"column":17},"end":{"line":752,"column":11}},"446":{"start":{"line":751,"column":12},"end":{"line":751,"column":49}},"447":{"start":{"line":756,"column":6},"end":{"line":756,"column":42}},"448":{"start":{"line":761,"column":2},"end":{"line":767,"column":5}},"449":{"start":{"line":762,"column":4},"end":{"line":766,"column":6}},"450":{"start":{"line":763,"column":6},"end":{"line":765,"column":30}},"451":{"start":{"line":769,"column":2},"end":{"line":790,"column":5}},"452":{"start":{"line":770,"column":4},"end":{"line":789,"column":6}},"453":{"start":{"line":773,"column":6},"end":{"line":786,"column":7}},"454":{"start":{"line":774,"column":22},"end":{"line":774,"column":40}},"455":{"start":{"line":775,"column":8},"end":{"line":785,"column":9}},"456":{"start":{"line":776,"column":10},"end":{"line":776,"column":66}},"457":{"start":{"line":778,"column":10},"end":{"line":784,"column":11}},"458":{"start":{"line":779,"column":12},"end":{"line":779,"column":31}},"459":{"start":{"line":780,"column":12},"end":{"line":780,"column":65}},"460":{"start":{"line":781,"column":12},"end":{"line":781,"column":24}},"461":{"start":{"line":783,"column":12},"end":{"line":783,"column":22}},"462":{"start":{"line":788,"column":6},"end":{"line":788,"column":62}},"463":{"start":{"line":792,"column":2},"end":{"line":809,"column":5}},"464":{"start":{"line":793,"column":4},"end":{"line":808,"column":6}},"465":{"start":{"line":794,"column":6},"end":{"line":794,"column":56}},"466":{"start":{"line":795,"column":6},"end":{"line":797,"column":7}},"467":{"start":{"line":796,"column":8},"end":{"line":796,"column":29}},"468":{"start":{"line":799,"column":6},"end":{"line":805,"column":7}},"469":{"start":{"line":800,"column":27},"end":{"line":800,"column":63}},"470":{"start":{"line":801,"column":8},"end":{"line":801,"column":39}},"471":{"start":{"line":802,"column":8},"end":{"line":802,"column":69}},"472":{"start":{"line":804,"column":8},"end":{"line":804,"column":67}},"473":{"start":{"line":807,"column":6},"end":{"line":807,"column":18}},"474":{"start":{"line":811,"column":2},"end":{"line":819,"column":5}},"475":{"start":{"line":812,"column":4},"end":{"line":818,"column":6}},"476":{"start":{"line":813,"column":6},"end":{"line":813,"column":36}},"477":{"start":{"line":814,"column":6},"end":{"line":816,"column":7}},"478":{"start":{"line":815,"column":8},"end":{"line":815,"column":53}},"479":{"start":{"line":817,"column":6},"end":{"line":817,"column":18}},"480":{"start":{"line":821,"column":2},"end":{"line":847,"column":5}},"481":{"start":{"line":822,"column":4},"end":{"line":846,"column":6}},"482":{"start":{"line":823,"column":6},"end":{"line":845,"column":7}},"483":{"start":{"line":824,"column":8},"end":{"line":824,"column":33}},"484":{"start":{"line":826,"column":30},"end":{"line":826,"column":46}},"485":{"start":{"line":827,"column":8},"end":{"line":827,"column":20}},"486":{"start":{"line":829,"column":8},"end":{"line":837,"column":9}},"487":{"start":{"line":831,"column":10},"end":{"line":831,"column":57}},"488":{"start":{"line":832,"column":10},"end":{"line":832,"column":37}},"489":{"start":{"line":833,"column":10},"end":{"line":833,"column":22}},"490":{"start":{"line":836,"column":10},"end":{"line":836,"column":58}},"491":{"start":{"line":838,"column":13},"end":{"line":845,"column":7}},"492":{"start":{"line":839,"column":8},"end":{"line":839,"column":33}},"493":{"start":{"line":840,"column":30},"end":{"line":840,"column":46}},"494":{"start":{"line":841,"column":8},"end":{"line":841,"column":20}},"495":{"start":{"line":842,"column":8},"end":{"line":842,"column":56}},"496":{"start":{"line":844,"column":8},"end":{"line":844,"column":38}},"497":{"start":{"line":849,"column":2},"end":{"line":856,"column":5}},"498":{"start":{"line":850,"column":4},"end":{"line":855,"column":6}},"499":{"start":{"line":851,"column":6},"end":{"line":851,"column":35}},"500":{"start":{"line":852,"column":6},"end":{"line":854,"column":7}},"501":{"start":{"line":853,"column":8},"end":{"line":853,"column":71}},"502":{"start":{"line":860,"column":2},"end":{"line":868,"column":5}},"503":{"start":{"line":861,"column":4},"end":{"line":867,"column":6}},"504":{"start":{"line":862,"column":6},"end":{"line":866,"column":7}},"505":{"start":{"line":863,"column":8},"end":{"line":863,"column":21}},"506":{"start":{"line":865,"column":8},"end":{"line":865,"column":38}},"507":{"start":{"line":871,"column":2},"end":{"line":879,"column":5}},"508":{"start":{"line":872,"column":4},"end":{"line":878,"column":6}},"509":{"start":{"line":873,"column":6},"end":{"line":877,"column":7}},"510":{"start":{"line":874,"column":8},"end":{"line":874,"column":47}},"511":{"start":{"line":876,"column":8},"end":{"line":876,"column":38}},"512":{"start":{"line":882,"column":2},"end":{"line":886,"column":5}},"513":{"start":{"line":883,"column":4},"end":{"line":885,"column":6}},"514":{"start":{"line":884,"column":6},"end":{"line":884,"column":54}},"515":{"start":{"line":884,"column":30},"end":{"line":884,"column":54}},"516":{"start":{"line":888,"column":2},"end":{"line":896,"column":5}},"517":{"start":{"line":889,"column":4},"end":{"line":895,"column":6}},"518":{"start":{"line":890,"column":6},"end":{"line":894,"column":7}},"519":{"start":{"line":891,"column":8},"end":{"line":891,"column":75}},"520":{"start":{"line":893,"column":8},"end":{"line":893,"column":49}},"521":{"start":{"line":899,"column":2},"end":{"line":909,"column":5}},"522":{"start":{"line":900,"column":4},"end":{"line":908,"column":6}},"523":{"start":{"line":901,"column":6},"end":{"line":906,"column":7}},"524":{"start":{"line":902,"column":19},"end":{"line":902,"column":30}},"525":{"start":{"line":903,"column":8},"end":{"line":905,"column":9}},"526":{"start":{"line":904,"column":10},"end":{"line":904,"column":55}},"527":{"start":{"line":907,"column":6},"end":{"line":907,"column":51}},"528":{"start":{"line":913,"column":2},"end":{"line":924,"column":5}},"529":{"start":{"line":914,"column":4},"end":{"line":923,"column":6}},"530":{"start":{"line":915,"column":6},"end":{"line":920,"column":7}},"531":{"start":{"line":916,"column":19},"end":{"line":916,"column":30}},"532":{"start":{"line":917,"column":8},"end":{"line":919,"column":9}},"533":{"start":{"line":918,"column":10},"end":{"line":918,"column":57}},"534":{"start":{"line":922,"column":6},"end":{"line":922,"column":22}},"535":{"start":{"line":928,"column":2},"end":{"line":941,"column":5}},"536":{"start":{"line":929,"column":4},"end":{"line":940,"column":6}},"537":{"start":{"line":930,"column":22},"end":{"line":930,"column":38}},"538":{"start":{"line":931,"column":17},"end":{"line":931,"column":69}},"539":{"start":{"line":932,"column":6},"end":{"line":939,"column":7}},"540":{"start":{"line":933,"column":8},"end":{"line":933,"column":39}},"541":{"start":{"line":934,"column":8},"end":{"line":934,"column":36}},"542":{"start":{"line":935,"column":8},"end":{"line":935,"column":66}},"543":{"start":{"line":936,"column":8},"end":{"line":936,"column":64}},"544":{"start":{"line":938,"column":8},"end":{"line":938,"column":20}},"545":{"start":{"line":943,"column":2},"end":{"line":949,"column":5}},"546":{"start":{"line":944,"column":4},"end":{"line":948,"column":6}},"547":{"start":{"line":945,"column":6},"end":{"line":947,"column":7}},"548":{"start":{"line":946,"column":8},"end":{"line":946,"column":44}},"549":{"start":{"line":952,"column":2},"end":{"line":959,"column":5}},"550":{"start":{"line":953,"column":4},"end":{"line":958,"column":6}},"551":{"start":{"line":954,"column":6},"end":{"line":956,"column":7}},"552":{"start":{"line":955,"column":8},"end":{"line":955,"column":61}},"553":{"start":{"line":957,"column":6},"end":{"line":957,"column":36}},"554":{"start":{"line":962,"column":2},"end":{"line":966,"column":5}},"555":{"start":{"line":963,"column":4},"end":{"line":965,"column":6}},"556":{"start":{"line":964,"column":6},"end":{"line":964,"column":54}},"557":{"start":{"line":969,"column":2},"end":{"line":977,"column":5}},"558":{"start":{"line":970,"column":4},"end":{"line":976,"column":6}},"559":{"start":{"line":971,"column":6},"end":{"line":973,"column":7}},"560":{"start":{"line":972,"column":8},"end":{"line":972,"column":73}},"561":{"start":{"line":974,"column":6},"end":{"line":974,"column":53}},"562":{"start":{"line":975,"column":6},"end":{"line":975,"column":66}},"563":{"start":{"line":980,"column":2},"end":{"line":1001,"column":5}},"564":{"start":{"line":981,"column":4},"end":{"line":1000,"column":6}},"565":{"start":{"line":982,"column":6},"end":{"line":982,"column":42}},"566":{"start":{"line":983,"column":6},"end":{"line":985,"column":7}},"567":{"start":{"line":984,"column":8},"end":{"line":984,"column":78}},"568":{"start":{"line":986,"column":6},"end":{"line":999,"column":7}},"569":{"start":{"line":987,"column":8},"end":{"line":987,"column":20}},"570":{"start":{"line":988,"column":26},"end":{"line":988,"column":46}},"571":{"start":{"line":989,"column":8},"end":{"line":998,"column":37}},"572":{"start":{"line":990,"column":21},"end":{"line":990,"column":37}},"573":{"start":{"line":991,"column":10},"end":{"line":991,"column":43}},"574":{"start":{"line":992,"column":10},"end":{"line":996,"column":11}},"575":{"start":{"line":993,"column":14},"end":{"line":993,"column":79}},"576":{"start":{"line":995,"column":14},"end":{"line":995,"column":41}},"577":{"start":{"line":997,"column":10},"end":{"line":997,"column":69}},"578":{"start":{"line":1004,"column":2},"end":{"line":1021,"column":5}},"579":{"start":{"line":1005,"column":4},"end":{"line":1020,"column":6}},"580":{"start":{"line":1009,"column":6},"end":{"line":1012,"column":7}},"581":{"start":{"line":1010,"column":8},"end":{"line":1010,"column":66}},"582":{"start":{"line":1011,"column":8},"end":{"line":1011,"column":54}},"583":{"start":{"line":1011,"column":36},"end":{"line":1011,"column":54}},"584":{"start":{"line":1014,"column":6},"end":{"line":1014,"column":35}},"585":{"start":{"line":1017,"column":6},"end":{"line":1019,"column":7}},"586":{"start":{"line":1018,"column":8},"end":{"line":1018,"column":61}},"587":{"start":{"line":1023,"column":2},"end":{"line":1034,"column":5}},"588":{"start":{"line":1024,"column":4},"end":{"line":1033,"column":6}},"589":{"start":{"line":1025,"column":6},"end":{"line":1027,"column":7}},"590":{"start":{"line":1026,"column":8},"end":{"line":1026,"column":30}},"591":{"start":{"line":1028,"column":6},"end":{"line":1030,"column":7}},"592":{"start":{"line":1029,"column":8},"end":{"line":1029,"column":62}},"593":{"start":{"line":1031,"column":6},"end":{"line":1031,"column":41}},"594":{"start":{"line":1032,"column":6},"end":{"line":1032,"column":19}},"595":{"start":{"line":1038,"column":2},"end":{"line":1058,"column":5}},"596":{"start":{"line":1039,"column":4},"end":{"line":1057,"column":6}},"597":{"start":{"line":1040,"column":6},"end":{"line":1040,"column":32}},"598":{"start":{"line":1042,"column":17},"end":{"line":1042,"column":21}},"599":{"start":{"line":1043,"column":6},"end":{"line":1047,"column":7}},"600":{"start":{"line":1044,"column":8},"end":{"line":1044,"column":24}},"601":{"start":{"line":1045,"column":13},"end":{"line":1047,"column":7}},"602":{"start":{"line":1046,"column":8},"end":{"line":1046,"column":22}},"603":{"start":{"line":1048,"column":6},"end":{"line":1054,"column":7}},"604":{"start":{"line":1049,"column":17},"end":{"line":1049,"column":33}},"605":{"start":{"line":1050,"column":8},"end":{"line":1053,"column":9}},"606":{"start":{"line":1051,"column":10},"end":{"line":1051,"column":22}},"607":{"start":{"line":1052,"column":10},"end":{"line":1052,"column":33}},"608":{"start":{"line":1056,"column":6},"end":{"line":1056,"column":29}},"609":{"start":{"line":1061,"column":2},"end":{"line":1068,"column":5}},"610":{"start":{"line":1062,"column":4},"end":{"line":1067,"column":6}},"611":{"start":{"line":1063,"column":6},"end":{"line":1065,"column":7}},"612":{"start":{"line":1064,"column":8},"end":{"line":1064,"column":71}},"613":{"start":{"line":1066,"column":6},"end":{"line":1066,"column":29}},"614":{"start":{"line":1071,"column":2},"end":{"line":1079,"column":5}},"615":{"start":{"line":1072,"column":4},"end":{"line":1078,"column":6}},"616":{"start":{"line":1073,"column":6},"end":{"line":1073,"column":29}},"617":{"start":{"line":1074,"column":6},"end":{"line":1077,"column":7}},"618":{"start":{"line":1075,"column":8},"end":{"line":1075,"column":64}},"619":{"start":{"line":1076,"column":8},"end":{"line":1076,"column":47}},"620":{"start":{"line":1082,"column":2},"end":{"line":1090,"column":5}},"621":{"start":{"line":1083,"column":4},"end":{"line":1089,"column":6}},"622":{"start":{"line":1084,"column":6},"end":{"line":1086,"column":7}},"623":{"start":{"line":1085,"column":8},"end":{"line":1085,"column":57}},"624":{"start":{"line":1088,"column":6},"end":{"line":1088,"column":42}},"625":{"start":{"line":1093,"column":2},"end":{"line":1097,"column":5}},"626":{"start":{"line":1094,"column":4},"end":{"line":1096,"column":6}},"627":{"start":{"line":1095,"column":6},"end":{"line":1095,"column":54}},"628":{"start":{"line":1109,"column":2},"end":{"line":1156,"column":5}},"629":{"start":{"line":1110,"column":4},"end":{"line":1155,"column":6}},"630":{"start":{"line":1111,"column":21},"end":{"line":1111,"column":25}},"631":{"start":{"line":1112,"column":6},"end":{"line":1124,"column":7}},"632":{"start":{"line":1113,"column":22},"end":{"line":1113,"column":40}},"633":{"start":{"line":1114,"column":8},"end":{"line":1123,"column":9}},"634":{"start":{"line":1115,"column":10},"end":{"line":1115,"column":41}},"635":{"start":{"line":1117,"column":10},"end":{"line":1122,"column":11}},"636":{"start":{"line":1118,"column":12},"end":{"line":1118,"column":31}},"637":{"start":{"line":1119,"column":12},"end":{"line":1119,"column":27}},"638":{"start":{"line":1121,"column":12},"end":{"line":1121,"column":22}},"639":{"start":{"line":1128,"column":6},"end":{"line":1128,"column":50}},"640":{"start":{"line":1129,"column":6},"end":{"line":1151,"column":7}},"641":{"start":{"line":1132,"column":8},"end":{"line":1139,"column":9}},"642":{"start":{"line":1133,"column":10},"end":{"line":1133,"column":68}},"643":{"start":{"line":1135,"column":10},"end":{"line":1135,"column":52}},"644":{"start":{"line":1136,"column":10},"end":{"line":1136,"column":58}},"645":{"start":{"line":1138,"column":10},"end":{"line":1138,"column":32}},"646":{"start":{"line":1141,"column":8},"end":{"line":1150,"column":9}},"647":{"start":{"line":1142,"column":10},"end":{"line":1142,"column":33}},"648":{"start":{"line":1143,"column":15},"end":{"line":1150,"column":9}},"649":{"start":{"line":1144,"column":10},"end":{"line":1144,"column":25}},"650":{"start":{"line":1146,"column":10},"end":{"line":1149,"column":12}},"651":{"start":{"line":1152,"column":6},"end":{"line":1152,"column":31}},"652":{"start":{"line":1154,"column":6},"end":{"line":1154,"column":37}},"653":{"start":{"line":1159,"column":2},"end":{"line":1179,"column":5}},"654":{"start":{"line":1160,"column":4},"end":{"line":1178,"column":6}},"655":{"start":{"line":1161,"column":6},"end":{"line":1175,"column":7}},"656":{"start":{"line":1162,"column":20},"end":{"line":1162,"column":38}},"657":{"start":{"line":1163,"column":8},"end":{"line":1174,"column":9}},"658":{"start":{"line":1164,"column":27},"end":{"line":1164,"column":57}},"659":{"start":{"line":1165,"column":10},"end":{"line":1165,"column":55}},"660":{"start":{"line":1165,"column":37},"end":{"line":1165,"column":55}},"661":{"start":{"line":1167,"column":10},"end":{"line":1167,"column":39}},"662":{"start":{"line":1169,"column":10},"end":{"line":1173,"column":11}},"663":{"start":{"line":1170,"column":12},"end":{"line":1170,"column":31}},"664":{"start":{"line":1172,"column":12},"end":{"line":1172,"column":22}},"665":{"start":{"line":1177,"column":6},"end":{"line":1177,"column":36}},"666":{"start":{"line":1181,"column":2},"end":{"line":1189,"column":5}},"667":{"start":{"line":1182,"column":4},"end":{"line":1188,"column":6}},"668":{"start":{"line":1183,"column":6},"end":{"line":1187,"column":7}},"669":{"start":{"line":1184,"column":8},"end":{"line":1184,"column":20}},"670":{"start":{"line":1186,"column":8},"end":{"line":1186,"column":32}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":10,"column":30},"end":{"line":10,"column":31}},"loc":{"start":{"line":10,"column":68},"end":{"line":22,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":24,"column":27},"end":{"line":24,"column":28}},"loc":{"start":{"line":24,"column":43},"end":{"line":28,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":30,"column":30},"end":{"line":30,"column":31}},"loc":{"start":{"line":30,"column":46},"end":{"line":59,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":61,"column":22},"end":{"line":61,"column":23}},"loc":{"start":{"line":61,"column":38},"end":{"line":81,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":83,"column":30},"end":{"line":83,"column":31}},"loc":{"start":{"line":83,"column":46},"end":{"line":88,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":90,"column":28},"end":{"line":90,"column":29}},"loc":{"start":{"line":90,"column":44},"end":{"line":113,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":115,"column":35},"end":{"line":115,"column":36}},"loc":{"start":{"line":115,"column":51},"end":{"line":121,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":123,"column":31},"end":{"line":123,"column":32}},"loc":{"start":{"line":123,"column":47},"end":{"line":127,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":129,"column":31},"end":{"line":129,"column":32}},"loc":{"start":{"line":129,"column":47},"end":{"line":133,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":137,"column":27},"end":{"line":137,"column":28}},"loc":{"start":{"line":137,"column":56},"end":{"line":163,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":165,"column":31},"end":{"line":165,"column":32}},"loc":{"start":{"line":165,"column":43},"end":{"line":176,"column":1}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":178,"column":24},"end":{"line":178,"column":25}},"loc":{"start":{"line":178,"column":40},"end":{"line":181,"column":1}}},"13":{"name":"(anonymous_13)","decl":{"start":{"line":185,"column":24},"end":{"line":185,"column":25}},"loc":{"start":{"line":185,"column":40},"end":{"line":201,"column":1}}},"14":{"name":"(anonymous_14)","decl":{"start":{"line":205,"column":28},"end":{"line":205,"column":29}},"loc":{"start":{"line":205,"column":40},"end":{"line":229,"column":1}}},"15":{"name":"(anonymous_15)","decl":{"start":{"line":231,"column":39},"end":{"line":231,"column":40}},"loc":{"start":{"line":231,"column":51},"end":{"line":255,"column":1}}},"16":{"name":"(anonymous_16)","decl":{"start":{"line":257,"column":41},"end":{"line":257,"column":42}},"loc":{"start":{"line":257,"column":53},"end":{"line":275,"column":1}}},"17":{"name":"(anonymous_17)","decl":{"start":{"line":277,"column":32},"end":{"line":277,"column":33}},"loc":{"start":{"line":277,"column":44},"end":{"line":279,"column":1}}},"18":{"name":"(anonymous_18)","decl":{"start":{"line":281,"column":32},"end":{"line":281,"column":33}},"loc":{"start":{"line":281,"column":58},"end":{"line":292,"column":1}}},"19":{"name":"(anonymous_19)","decl":{"start":{"line":294,"column":34},"end":{"line":294,"column":35}},"loc":{"start":{"line":294,"column":50},"end":{"line":318,"column":1}}},"20":{"name":"(anonymous_20)","decl":{"start":{"line":320,"column":31},"end":{"line":320,"column":32}},"loc":{"start":{"line":320,"column":76},"end":{"line":328,"column":1}}},"21":{"name":"(anonymous_21)","decl":{"start":{"line":330,"column":37},"end":{"line":330,"column":38}},"loc":{"start":{"line":330,"column":63},"end":{"line":336,"column":1}}},"22":{"name":"(anonymous_22)","decl":{"start":{"line":338,"column":25},"end":{"line":338,"column":26}},"loc":{"start":{"line":338,"column":48},"end":{"line":389,"column":1}}},"23":{"name":"(anonymous_23)","decl":{"start":{"line":391,"column":29},"end":{"line":391,"column":30}},"loc":{"start":{"line":391,"column":41},"end":{"line":395,"column":1}}},"24":{"name":"(anonymous_24)","decl":{"start":{"line":397,"column":38},"end":{"line":397,"column":39}},"loc":{"start":{"line":397,"column":72},"end":{"line":410,"column":1}}},"25":{"name":"(anonymous_25)","decl":{"start":{"line":412,"column":26},"end":{"line":412,"column":27}},"loc":{"start":{"line":412,"column":60},"end":{"line":423,"column":1}}},"26":{"name":"(anonymous_26)","decl":{"start":{"line":425,"column":25},"end":{"line":425,"column":26}},"loc":{"start":{"line":425,"column":37},"end":{"line":430,"column":1}}},"27":{"name":"(anonymous_27)","decl":{"start":{"line":432,"column":24},"end":{"line":432,"column":25}},"loc":{"start":{"line":432,"column":36},"end":{"line":444,"column":1}}},"28":{"name":"(anonymous_28)","decl":{"start":{"line":446,"column":32},"end":{"line":446,"column":33}},"loc":{"start":{"line":446,"column":44},"end":{"line":456,"column":1}}},"29":{"name":"(anonymous_29)","decl":{"start":{"line":458,"column":33},"end":{"line":458,"column":34}},"loc":{"start":{"line":458,"column":45},"end":{"line":470,"column":1}}},"30":{"name":"(anonymous_30)","decl":{"start":{"line":472,"column":31},"end":{"line":472,"column":32}},"loc":{"start":{"line":472,"column":71},"end":{"line":496,"column":1}}},"31":{"name":"(anonymous_31)","decl":{"start":{"line":501,"column":26},"end":{"line":501,"column":27}},"loc":{"start":{"line":501,"column":38},"end":{"line":619,"column":1}}},"32":{"name":"(anonymous_32)","decl":{"start":{"line":621,"column":26},"end":{"line":621,"column":27}},"loc":{"start":{"line":621,"column":38},"end":{"line":631,"column":1}}},"33":{"name":"(anonymous_33)","decl":{"start":{"line":633,"column":25},"end":{"line":633,"column":26}},"loc":{"start":{"line":633,"column":37},"end":{"line":641,"column":1}}},"34":{"name":"(anonymous_34)","decl":{"start":{"line":643,"column":31},"end":{"line":643,"column":32}},"loc":{"start":{"line":643,"column":43},"end":{"line":651,"column":1}}},"35":{"name":"(anonymous_35)","decl":{"start":{"line":653,"column":24},"end":{"line":653,"column":25}},"loc":{"start":{"line":653,"column":36},"end":{"line":661,"column":1}}},"36":{"name":"(anonymous_36)","decl":{"start":{"line":663,"column":19},"end":{"line":663,"column":20}},"loc":{"start":{"line":663,"column":31},"end":{"line":669,"column":1}}},"37":{"name":"(anonymous_37)","decl":{"start":{"line":671,"column":29},"end":{"line":671,"column":30}},"loc":{"start":{"line":671,"column":41},"end":{"line":675,"column":1}}},"38":{"name":"(anonymous_38)","decl":{"start":{"line":677,"column":40},"end":{"line":677,"column":41}},"loc":{"start":{"line":677,"column":93},"end":{"line":698,"column":1}}},"39":{"name":"(anonymous_39)","decl":{"start":{"line":700,"column":25},"end":{"line":700,"column":26}},"loc":{"start":{"line":700,"column":41},"end":{"line":709,"column":1}}},"40":{"name":"(anonymous_40)","decl":{"start":{"line":711,"column":15},"end":{"line":711,"column":16}},"loc":{"start":{"line":711,"column":35},"end":{"line":1190,"column":1}}},"41":{"name":"(anonymous_41)","decl":{"start":{"line":713,"column":39},"end":{"line":713,"column":40}},"loc":{"start":{"line":713,"column":56},"end":{"line":723,"column":3}}},"42":{"name":"(anonymous_42)","decl":{"start":{"line":714,"column":11},"end":{"line":714,"column":12}},"loc":{"start":{"line":714,"column":44},"end":{"line":722,"column":5}}},"43":{"name":"(anonymous_43)","decl":{"start":{"line":726,"column":36},"end":{"line":726,"column":37}},"loc":{"start":{"line":726,"column":53},"end":{"line":737,"column":3}}},"44":{"name":"(anonymous_44)","decl":{"start":{"line":727,"column":11},"end":{"line":727,"column":12}},"loc":{"start":{"line":727,"column":44},"end":{"line":736,"column":5}}},"45":{"name":"(anonymous_45)","decl":{"start":{"line":740,"column":46},"end":{"line":740,"column":47}},"loc":{"start":{"line":740,"column":63},"end":{"line":758,"column":3}}},"46":{"name":"(anonymous_46)","decl":{"start":{"line":741,"column":11},"end":{"line":741,"column":12}},"loc":{"start":{"line":741,"column":33},"end":{"line":757,"column":5}}},"47":{"name":"(anonymous_47)","decl":{"start":{"line":761,"column":50},"end":{"line":761,"column":51}},"loc":{"start":{"line":761,"column":67},"end":{"line":767,"column":3}}},"48":{"name":"(anonymous_48)","decl":{"start":{"line":762,"column":11},"end":{"line":762,"column":12}},"loc":{"start":{"line":762,"column":23},"end":{"line":766,"column":5}}},"49":{"name":"(anonymous_49)","decl":{"start":{"line":769,"column":38},"end":{"line":769,"column":39}},"loc":{"start":{"line":769,"column":55},"end":{"line":790,"column":3}}},"50":{"name":"(anonymous_50)","decl":{"start":{"line":770,"column":11},"end":{"line":770,"column":12}},"loc":{"start":{"line":770,"column":71},"end":{"line":789,"column":5}}},"51":{"name":"(anonymous_51)","decl":{"start":{"line":792,"column":36},"end":{"line":792,"column":37}},"loc":{"start":{"line":792,"column":53},"end":{"line":809,"column":3}}},"52":{"name":"(anonymous_52)","decl":{"start":{"line":793,"column":11},"end":{"line":793,"column":12}},"loc":{"start":{"line":793,"column":47},"end":{"line":808,"column":5}}},"53":{"name":"(anonymous_53)","decl":{"start":{"line":811,"column":33},"end":{"line":811,"column":34}},"loc":{"start":{"line":811,"column":50},"end":{"line":819,"column":3}}},"54":{"name":"(anonymous_54)","decl":{"start":{"line":812,"column":11},"end":{"line":812,"column":12}},"loc":{"start":{"line":812,"column":27},"end":{"line":818,"column":5}}},"55":{"name":"(anonymous_55)","decl":{"start":{"line":821,"column":44},"end":{"line":821,"column":45}},"loc":{"start":{"line":821,"column":61},"end":{"line":847,"column":3}}},"56":{"name":"(anonymous_56)","decl":{"start":{"line":822,"column":11},"end":{"line":822,"column":12}},"loc":{"start":{"line":822,"column":27},"end":{"line":846,"column":5}}},"57":{"name":"(anonymous_57)","decl":{"start":{"line":849,"column":34},"end":{"line":849,"column":35}},"loc":{"start":{"line":849,"column":51},"end":{"line":856,"column":3}}},"58":{"name":"(anonymous_58)","decl":{"start":{"line":850,"column":11},"end":{"line":850,"column":12}},"loc":{"start":{"line":850,"column":27},"end":{"line":855,"column":5}}},"59":{"name":"(anonymous_59)","decl":{"start":{"line":860,"column":31},"end":{"line":860,"column":32}},"loc":{"start":{"line":860,"column":48},"end":{"line":868,"column":3}}},"60":{"name":"(anonymous_60)","decl":{"start":{"line":861,"column":11},"end":{"line":861,"column":12}},"loc":{"start":{"line":861,"column":27},"end":{"line":867,"column":5}}},"61":{"name":"(anonymous_61)","decl":{"start":{"line":871,"column":31},"end":{"line":871,"column":32}},"loc":{"start":{"line":871,"column":48},"end":{"line":879,"column":3}}},"62":{"name":"(anonymous_62)","decl":{"start":{"line":872,"column":11},"end":{"line":872,"column":12}},"loc":{"start":{"line":872,"column":27},"end":{"line":878,"column":5}}},"63":{"name":"(anonymous_63)","decl":{"start":{"line":882,"column":35},"end":{"line":882,"column":36}},"loc":{"start":{"line":882,"column":52},"end":{"line":886,"column":3}}},"64":{"name":"(anonymous_64)","decl":{"start":{"line":883,"column":11},"end":{"line":883,"column":12}},"loc":{"start":{"line":883,"column":23},"end":{"line":885,"column":5}}},"65":{"name":"(anonymous_65)","decl":{"start":{"line":888,"column":34},"end":{"line":888,"column":35}},"loc":{"start":{"line":888,"column":51},"end":{"line":896,"column":3}}},"66":{"name":"(anonymous_66)","decl":{"start":{"line":889,"column":11},"end":{"line":889,"column":12}},"loc":{"start":{"line":889,"column":38},"end":{"line":895,"column":5}}},"67":{"name":"(anonymous_67)","decl":{"start":{"line":899,"column":38},"end":{"line":899,"column":39}},"loc":{"start":{"line":899,"column":55},"end":{"line":909,"column":3}}},"68":{"name":"(anonymous_68)","decl":{"start":{"line":900,"column":11},"end":{"line":900,"column":12}},"loc":{"start":{"line":900,"column":42},"end":{"line":908,"column":5}}},"69":{"name":"(anonymous_69)","decl":{"start":{"line":913,"column":38},"end":{"line":913,"column":39}},"loc":{"start":{"line":913,"column":50},"end":{"line":924,"column":3}}},"70":{"name":"(anonymous_70)","decl":{"start":{"line":914,"column":11},"end":{"line":914,"column":12}},"loc":{"start":{"line":914,"column":31},"end":{"line":923,"column":5}}},"71":{"name":"(anonymous_71)","decl":{"start":{"line":928,"column":39},"end":{"line":928,"column":40}},"loc":{"start":{"line":928,"column":56},"end":{"line":941,"column":3}}},"72":{"name":"(anonymous_72)","decl":{"start":{"line":929,"column":11},"end":{"line":929,"column":12}},"loc":{"start":{"line":929,"column":57},"end":{"line":940,"column":5}}},"73":{"name":"(anonymous_73)","decl":{"start":{"line":943,"column":31},"end":{"line":943,"column":32}},"loc":{"start":{"line":943,"column":48},"end":{"line":949,"column":3}}},"74":{"name":"(anonymous_74)","decl":{"start":{"line":944,"column":11},"end":{"line":944,"column":12}},"loc":{"start":{"line":944,"column":27},"end":{"line":948,"column":5}}},"75":{"name":"(anonymous_75)","decl":{"start":{"line":952,"column":40},"end":{"line":952,"column":41}},"loc":{"start":{"line":952,"column":57},"end":{"line":959,"column":3}}},"76":{"name":"(anonymous_76)","decl":{"start":{"line":953,"column":11},"end":{"line":953,"column":12}},"loc":{"start":{"line":953,"column":27},"end":{"line":958,"column":5}}},"77":{"name":"(anonymous_77)","decl":{"start":{"line":962,"column":37},"end":{"line":962,"column":38}},"loc":{"start":{"line":962,"column":54},"end":{"line":966,"column":3}}},"78":{"name":"(anonymous_78)","decl":{"start":{"line":963,"column":11},"end":{"line":963,"column":12}},"loc":{"start":{"line":963,"column":23},"end":{"line":965,"column":5}}},"79":{"name":"(anonymous_79)","decl":{"start":{"line":969,"column":38},"end":{"line":969,"column":39}},"loc":{"start":{"line":969,"column":50},"end":{"line":977,"column":3}}},"80":{"name":"(anonymous_80)","decl":{"start":{"line":970,"column":11},"end":{"line":970,"column":12}},"loc":{"start":{"line":970,"column":62},"end":{"line":976,"column":5}}},"81":{"name":"(anonymous_81)","decl":{"start":{"line":980,"column":37},"end":{"line":980,"column":38}},"loc":{"start":{"line":980,"column":54},"end":{"line":1001,"column":3}}},"82":{"name":"(anonymous_82)","decl":{"start":{"line":981,"column":11},"end":{"line":981,"column":12}},"loc":{"start":{"line":981,"column":40},"end":{"line":1000,"column":5}}},"83":{"name":"(anonymous_83)","decl":{"start":{"line":1004,"column":39},"end":{"line":1004,"column":40}},"loc":{"start":{"line":1004,"column":56},"end":{"line":1021,"column":3}}},"84":{"name":"(anonymous_84)","decl":{"start":{"line":1005,"column":11},"end":{"line":1005,"column":12}},"loc":{"start":{"line":1005,"column":27},"end":{"line":1020,"column":5}}},"85":{"name":"(anonymous_85)","decl":{"start":{"line":1023,"column":50},"end":{"line":1023,"column":51}},"loc":{"start":{"line":1023,"column":62},"end":{"line":1034,"column":3}}},"86":{"name":"(anonymous_86)","decl":{"start":{"line":1024,"column":11},"end":{"line":1024,"column":12}},"loc":{"start":{"line":1024,"column":28},"end":{"line":1033,"column":5}}},"87":{"name":"(anonymous_87)","decl":{"start":{"line":1038,"column":43},"end":{"line":1038,"column":44}},"loc":{"start":{"line":1038,"column":60},"end":{"line":1058,"column":3}}},"88":{"name":"(anonymous_88)","decl":{"start":{"line":1039,"column":11},"end":{"line":1039,"column":12}},"loc":{"start":{"line":1039,"column":27},"end":{"line":1057,"column":5}}},"89":{"name":"(anonymous_89)","decl":{"start":{"line":1061,"column":41},"end":{"line":1061,"column":42}},"loc":{"start":{"line":1061,"column":58},"end":{"line":1068,"column":3}}},"90":{"name":"(anonymous_90)","decl":{"start":{"line":1062,"column":11},"end":{"line":1062,"column":12}},"loc":{"start":{"line":1062,"column":27},"end":{"line":1067,"column":5}}},"91":{"name":"(anonymous_91)","decl":{"start":{"line":1071,"column":34},"end":{"line":1071,"column":35}},"loc":{"start":{"line":1071,"column":51},"end":{"line":1079,"column":3}}},"92":{"name":"(anonymous_92)","decl":{"start":{"line":1072,"column":11},"end":{"line":1072,"column":12}},"loc":{"start":{"line":1072,"column":27},"end":{"line":1078,"column":5}}},"93":{"name":"(anonymous_93)","decl":{"start":{"line":1082,"column":55},"end":{"line":1082,"column":56}},"loc":{"start":{"line":1082,"column":72},"end":{"line":1090,"column":3}}},"94":{"name":"(anonymous_94)","decl":{"start":{"line":1083,"column":11},"end":{"line":1083,"column":12}},"loc":{"start":{"line":1083,"column":33},"end":{"line":1089,"column":5}}},"95":{"name":"(anonymous_95)","decl":{"start":{"line":1093,"column":43},"end":{"line":1093,"column":44}},"loc":{"start":{"line":1093,"column":60},"end":{"line":1097,"column":3}}},"96":{"name":"(anonymous_96)","decl":{"start":{"line":1094,"column":11},"end":{"line":1094,"column":12}},"loc":{"start":{"line":1094,"column":23},"end":{"line":1096,"column":5}}},"97":{"name":"(anonymous_97)","decl":{"start":{"line":1109,"column":38},"end":{"line":1109,"column":39}},"loc":{"start":{"line":1109,"column":55},"end":{"line":1156,"column":3}}},"98":{"name":"(anonymous_98)","decl":{"start":{"line":1110,"column":11},"end":{"line":1110,"column":12}},"loc":{"start":{"line":1110,"column":30},"end":{"line":1155,"column":5}}},"99":{"name":"(anonymous_99)","decl":{"start":{"line":1159,"column":32},"end":{"line":1159,"column":33}},"loc":{"start":{"line":1159,"column":49},"end":{"line":1179,"column":3}}},"100":{"name":"(anonymous_100)","decl":{"start":{"line":1160,"column":11},"end":{"line":1160,"column":12}},"loc":{"start":{"line":1160,"column":27},"end":{"line":1178,"column":5}}},"101":{"name":"(anonymous_101)","decl":{"start":{"line":1181,"column":43},"end":{"line":1181,"column":44}},"loc":{"start":{"line":1181,"column":60},"end":{"line":1189,"column":3}}},"102":{"name":"(anonymous_102)","decl":{"start":{"line":1182,"column":11},"end":{"line":1182,"column":12}},"loc":{"start":{"line":1182,"column":23},"end":{"line":1188,"column":5}}}},"branchMap":{"1":{"loc":{"start":{"line":13,"column":14},"end":{"line":13,"column":29}},"type":"binary-expr","locations":[{"start":{"line":13,"column":14},"end":{"line":13,"column":17}},{"start":{"line":13,"column":21},"end":{"line":13,"column":29}}]},"2":{"loc":{"start":{"line":14,"column":2},"end":{"line":18,"column":3}},"type":"if","locations":[{"start":{"line":14,"column":2},"end":{"line":18,"column":3}},{"start":{"line":14,"column":2},"end":{"line":18,"column":3}}]},"3":{"loc":{"start":{"line":15,"column":4},"end":{"line":17,"column":5}},"type":"if","locations":[{"start":{"line":15,"column":4},"end":{"line":17,"column":5}},{"start":{"line":15,"column":4},"end":{"line":17,"column":5}}]},"4":{"loc":{"start":{"line":15,"column":8},"end":{"line":15,"column":61}},"type":"binary-expr","locations":[{"start":{"line":15,"column":8},"end":{"line":15,"column":33}},{"start":{"line":15,"column":37},"end":{"line":15,"column":61}}]},"5":{"loc":{"start":{"line":38,"column":2},"end":{"line":42,"column":3}},"type":"if","locations":[{"start":{"line":38,"column":2},"end":{"line":42,"column":3}},{"start":{"line":38,"column":2},"end":{"line":42,"column":3}}]},"6":{"loc":{"start":{"line":62,"column":2},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":62,"column":2},"end":{"line":80,"column":3}},{"start":{"line":62,"column":2},"end":{"line":80,"column":3}}]},"7":{"loc":{"start":{"line":64,"column":9},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":64,"column":9},"end":{"line":80,"column":3}},{"start":{"line":64,"column":9},"end":{"line":80,"column":3}}]},"8":{"loc":{"start":{"line":66,"column":9},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":66,"column":9},"end":{"line":80,"column":3}},{"start":{"line":66,"column":9},"end":{"line":80,"column":3}}]},"9":{"loc":{"start":{"line":68,"column":9},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":68,"column":9},"end":{"line":80,"column":3}},{"start":{"line":68,"column":9},"end":{"line":80,"column":3}}]},"10":{"loc":{"start":{"line":69,"column":4},"end":{"line":73,"column":5}},"type":"if","locations":[{"start":{"line":69,"column":4},"end":{"line":73,"column":5}},{"start":{"line":69,"column":4},"end":{"line":73,"column":5}}]},"11":{"loc":{"start":{"line":74,"column":9},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":74,"column":9},"end":{"line":80,"column":3}},{"start":{"line":74,"column":9},"end":{"line":80,"column":3}}]},"12":{"loc":{"start":{"line":76,"column":9},"end":{"line":80,"column":3}},"type":"if","locations":[{"start":{"line":76,"column":9},"end":{"line":80,"column":3}},{"start":{"line":76,"column":9},"end":{"line":80,"column":3}}]},"13":{"loc":{"start":{"line":93,"column":2},"end":{"line":97,"column":3}},"type":"if","locations":[{"start":{"line":93,"column":2},"end":{"line":97,"column":3}},{"start":{"line":93,"column":2},"end":{"line":97,"column":3}}]},"14":{"loc":{"start":{"line":140,"column":2},"end":{"line":144,"column":3}},"type":"if","locations":[{"start":{"line":140,"column":2},"end":{"line":144,"column":3}},{"start":{"line":140,"column":2},"end":{"line":144,"column":3}}]},"15":{"loc":{"start":{"line":149,"column":2},"end":{"line":153,"column":3}},"type":"if","locations":[{"start":{"line":149,"column":2},"end":{"line":153,"column":3}},{"start":{"line":149,"column":2},"end":{"line":153,"column":3}}]},"16":{"loc":{"start":{"line":155,"column":2},"end":{"line":160,"column":3}},"type":"if","locations":[{"start":{"line":155,"column":2},"end":{"line":160,"column":3}},{"start":{"line":155,"column":2},"end":{"line":160,"column":3}}]},"17":{"loc":{"start":{"line":169,"column":2},"end":{"line":173,"column":3}},"type":"if","locations":[{"start":{"line":169,"column":2},"end":{"line":173,"column":3}},{"start":{"line":169,"column":2},"end":{"line":173,"column":3}}]},"18":{"loc":{"start":{"line":188,"column":2},"end":{"line":192,"column":3}},"type":"if","locations":[{"start":{"line":188,"column":2},"end":{"line":192,"column":3}},{"start":{"line":188,"column":2},"end":{"line":192,"column":3}}]},"19":{"loc":{"start":{"line":209,"column":2},"end":{"line":216,"column":3}},"type":"if","locations":[{"start":{"line":209,"column":2},"end":{"line":216,"column":3}},{"start":{"line":209,"column":2},"end":{"line":216,"column":3}}]},"20":{"loc":{"start":{"line":210,"column":4},"end":{"line":214,"column":5}},"type":"if","locations":[{"start":{"line":210,"column":4},"end":{"line":214,"column":5}},{"start":{"line":210,"column":4},"end":{"line":214,"column":5}}]},"21":{"loc":{"start":{"line":212,"column":11},"end":{"line":214,"column":5}},"type":"if","locations":[{"start":{"line":212,"column":11},"end":{"line":214,"column":5}},{"start":{"line":212,"column":11},"end":{"line":214,"column":5}}]},"22":{"loc":{"start":{"line":223,"column":2},"end":{"line":226,"column":3}},"type":"if","locations":[{"start":{"line":223,"column":2},"end":{"line":226,"column":3}},{"start":{"line":223,"column":2},"end":{"line":226,"column":3}}]},"23":{"loc":{"start":{"line":238,"column":2},"end":{"line":242,"column":3}},"type":"if","locations":[{"start":{"line":238,"column":2},"end":{"line":242,"column":3}},{"start":{"line":238,"column":2},"end":{"line":242,"column":3}}]},"24":{"loc":{"start":{"line":238,"column":6},"end":{"line":238,"column":58}},"type":"binary-expr","locations":[{"start":{"line":238,"column":6},"end":{"line":238,"column":28}},{"start":{"line":238,"column":32},"end":{"line":238,"column":58}}]},"25":{"loc":{"start":{"line":246,"column":4},"end":{"line":248,"column":5}},"type":"if","locations":[{"start":{"line":246,"column":4},"end":{"line":248,"column":5}},{"start":{"line":246,"column":4},"end":{"line":248,"column":5}}]},"26":{"loc":{"start":{"line":266,"column":4},"end":{"line":268,"column":5}},"type":"if","locations":[{"start":{"line":266,"column":4},"end":{"line":268,"column":5}},{"start":{"line":266,"column":4},"end":{"line":268,"column":5}}]},"27":{"loc":{"start":{"line":278,"column":9},"end":{"line":278,"column":106}},"type":"cond-expr","locations":[{"start":{"line":278,"column":57},"end":{"line":278,"column":77}},{"start":{"line":278,"column":80},"end":{"line":278,"column":106}}]},"28":{"loc":{"start":{"line":278,"column":10},"end":{"line":278,"column":53}},"type":"binary-expr","locations":[{"start":{"line":278,"column":10},"end":{"line":278,"column":28}},{"start":{"line":278,"column":32},"end":{"line":278,"column":53}}]},"29":{"loc":{"start":{"line":299,"column":2},"end":{"line":301,"column":3}},"type":"if","locations":[{"start":{"line":299,"column":2},"end":{"line":301,"column":3}},{"start":{"line":299,"column":2},"end":{"line":301,"column":3}}]},"30":{"loc":{"start":{"line":306,"column":4},"end":{"line":308,"column":5}},"type":"if","locations":[{"start":{"line":306,"column":4},"end":{"line":308,"column":5}},{"start":{"line":306,"column":4},"end":{"line":308,"column":5}}]},"31":{"loc":{"start":{"line":311,"column":2},"end":{"line":313,"column":3}},"type":"if","locations":[{"start":{"line":311,"column":2},"end":{"line":313,"column":3}},{"start":{"line":311,"column":2},"end":{"line":313,"column":3}}]},"32":{"loc":{"start":{"line":354,"column":4},"end":{"line":357,"column":5}},"type":"if","locations":[{"start":{"line":354,"column":4},"end":{"line":357,"column":5}},{"start":{"line":354,"column":4},"end":{"line":357,"column":5}}]},"33":{"loc":{"start":{"line":354,"column":8},"end":{"line":354,"column":50}},"type":"binary-expr","locations":[{"start":{"line":354,"column":8},"end":{"line":354,"column":19}},{"start":{"line":354,"column":23},"end":{"line":354,"column":50}}]},"34":{"loc":{"start":{"line":359,"column":4},"end":{"line":383,"column":5}},"type":"if","locations":[{"start":{"line":359,"column":4},"end":{"line":383,"column":5}},{"start":{"line":359,"column":4},"end":{"line":383,"column":5}}]},"35":{"loc":{"start":{"line":361,"column":11},"end":{"line":383,"column":5}},"type":"if","locations":[{"start":{"line":361,"column":11},"end":{"line":383,"column":5}},{"start":{"line":361,"column":11},"end":{"line":383,"column":5}}]},"36":{"loc":{"start":{"line":361,"column":15},"end":{"line":361,"column":62}},"type":"binary-expr","locations":[{"start":{"line":361,"column":15},"end":{"line":361,"column":36}},{"start":{"line":361,"column":40},"end":{"line":361,"column":62}}]},"37":{"loc":{"start":{"line":364,"column":6},"end":{"line":368,"column":7}},"type":"if","locations":[{"start":{"line":364,"column":6},"end":{"line":368,"column":7}},{"start":{"line":364,"column":6},"end":{"line":368,"column":7}}]},"38":{"loc":{"start":{"line":364,"column":10},"end":{"line":364,"column":42}},"type":"binary-expr","locations":[{"start":{"line":364,"column":10},"end":{"line":364,"column":18}},{"start":{"line":364,"column":22},"end":{"line":364,"column":42}}]},"39":{"loc":{"start":{"line":369,"column":6},"end":{"line":382,"column":7}},"type":"if","locations":[{"start":{"line":369,"column":6},"end":{"line":382,"column":7}},{"start":{"line":369,"column":6},"end":{"line":382,"column":7}}]},"40":{"loc":{"start":{"line":369,"column":10},"end":{"line":369,"column":57}},"type":"binary-expr","locations":[{"start":{"line":369,"column":10},"end":{"line":369,"column":32}},{"start":{"line":369,"column":36},"end":{"line":369,"column":57}}]},"41":{"loc":{"start":{"line":373,"column":8},"end":{"line":375,"column":9}},"type":"if","locations":[{"start":{"line":373,"column":8},"end":{"line":375,"column":9}},{"start":{"line":373,"column":8},"end":{"line":375,"column":9}}]},"42":{"loc":{"start":{"line":392,"column":2},"end":{"line":394,"column":3}},"type":"if","locations":[{"start":{"line":392,"column":2},"end":{"line":394,"column":3}},{"start":{"line":392,"column":2},"end":{"line":394,"column":3}}]},"43":{"loc":{"start":{"line":392,"column":6},"end":{"line":392,"column":73}},"type":"binary-expr","locations":[{"start":{"line":392,"column":6},"end":{"line":392,"column":24}},{"start":{"line":392,"column":28},"end":{"line":392,"column":47}},{"start":{"line":392,"column":51},"end":{"line":392,"column":73}}]},"44":{"loc":{"start":{"line":398,"column":13},"end":{"line":398,"column":41}},"type":"binary-expr","locations":[{"start":{"line":398,"column":13},"end":{"line":398,"column":21}},{"start":{"line":398,"column":25},"end":{"line":398,"column":41}}]},"45":{"loc":{"start":{"line":399,"column":13},"end":{"line":399,"column":44}},"type":"binary-expr","locations":[{"start":{"line":399,"column":13},"end":{"line":399,"column":21}},{"start":{"line":399,"column":25},"end":{"line":399,"column":44}}]},"46":{"loc":{"start":{"line":400,"column":13},"end":{"line":400,"column":41}},"type":"binary-expr","locations":[{"start":{"line":400,"column":13},"end":{"line":400,"column":15}},{"start":{"line":400,"column":19},"end":{"line":400,"column":41}}]},"47":{"loc":{"start":{"line":418,"column":2},"end":{"line":420,"column":3}},"type":"if","locations":[{"start":{"line":418,"column":2},"end":{"line":420,"column":3}},{"start":{"line":418,"column":2},"end":{"line":420,"column":3}}]},"48":{"loc":{"start":{"line":437,"column":9},"end":{"line":437,"column":71}},"type":"binary-expr","locations":[{"start":{"line":437,"column":9},"end":{"line":437,"column":43}},{"start":{"line":437,"column":47},"end":{"line":437,"column":71}}]},"49":{"loc":{"start":{"line":439,"column":4},"end":{"line":439,"column":39}},"type":"if","locations":[{"start":{"line":439,"column":4},"end":{"line":439,"column":39}},{"start":{"line":439,"column":4},"end":{"line":439,"column":39}}]},"50":{"loc":{"start":{"line":450,"column":2},"end":{"line":452,"column":3}},"type":"if","locations":[{"start":{"line":450,"column":2},"end":{"line":452,"column":3}},{"start":{"line":450,"column":2},"end":{"line":452,"column":3}}]},"51":{"loc":{"start":{"line":462,"column":4},"end":{"line":464,"column":5}},"type":"if","locations":[{"start":{"line":462,"column":4},"end":{"line":464,"column":5}},{"start":{"line":462,"column":4},"end":{"line":464,"column":5}}]},"52":{"loc":{"start":{"line":466,"column":2},"end":{"line":468,"column":3}},"type":"if","locations":[{"start":{"line":466,"column":2},"end":{"line":468,"column":3}},{"start":{"line":466,"column":2},"end":{"line":468,"column":3}}]},"53":{"loc":{"start":{"line":473,"column":2},"end":{"line":495,"column":3}},"type":"switch","locations":[{"start":{"line":474,"column":4},"end":{"line":475,"column":56}},{"start":{"line":477,"column":4},"end":{"line":478,"column":57}},{"start":{"line":480,"column":4},"end":{"line":480,"column":16}},{"start":{"line":481,"column":4},"end":{"line":482,"column":60}},{"start":{"line":484,"column":4},"end":{"line":485,"column":58}},{"start":{"line":487,"column":4},"end":{"line":488,"column":59}},{"start":{"line":490,"column":4},"end":{"line":491,"column":59}},{"start":{"line":493,"column":4},"end":{"line":494,"column":63}}]},"54":{"loc":{"start":{"line":508,"column":2},"end":{"line":616,"column":3}},"type":"switch","locations":[{"start":{"line":509,"column":4},"end":{"line":510,"column":94}},{"start":{"line":512,"column":4},"end":{"line":513,"column":40}},{"start":{"line":515,"column":4},"end":{"line":516,"column":39}},{"start":{"line":518,"column":4},"end":{"line":533,"column":12}},{"start":{"line":535,"column":4},"end":{"line":565,"column":61}},{"start":{"line":567,"column":4},"end":{"line":572,"column":66}},{"start":{"line":574,"column":4},"end":{"line":574,"column":18}},{"start":{"line":574,"column":19},"end":{"line":577,"column":67}},{"start":{"line":579,"column":4},"end":{"line":589,"column":7}},{"start":{"line":591,"column":4},"end":{"line":596,"column":67}},{"start":{"line":598,"column":4},"end":{"line":601,"column":64}},{"start":{"line":603,"column":4},"end":{"line":606,"column":57}},{"start":{"line":608,"column":4},"end":{"line":610,"column":59}},{"start":{"line":612,"column":4},"end":{"line":615,"column":7}}]},"55":{"loc":{"start":{"line":519,"column":6},"end":{"line":532,"column":7}},"type":"if","locations":[{"start":{"line":519,"column":6},"end":{"line":532,"column":7}},{"start":{"line":519,"column":6},"end":{"line":532,"column":7}}]},"56":{"loc":{"start":{"line":539,"column":6},"end":{"line":546,"column":7}},"type":"if","locations":[{"start":{"line":539,"column":6},"end":{"line":546,"column":7}},{"start":{"line":539,"column":6},"end":{"line":546,"column":7}}]},"57":{"loc":{"start":{"line":539,"column":10},"end":{"line":539,"column":60}},"type":"binary-expr","locations":[{"start":{"line":539,"column":10},"end":{"line":539,"column":32}},{"start":{"line":539,"column":36},"end":{"line":539,"column":60}}]},"58":{"loc":{"start":{"line":540,"column":8},"end":{"line":545,"column":9}},"type":"if","locations":[{"start":{"line":540,"column":8},"end":{"line":545,"column":9}},{"start":{"line":540,"column":8},"end":{"line":545,"column":9}}]},"59":{"loc":{"start":{"line":542,"column":26},"end":{"line":542,"column":69}},"type":"binary-expr","locations":[{"start":{"line":542,"column":26},"end":{"line":542,"column":47}},{"start":{"line":542,"column":51},"end":{"line":542,"column":69}}]},"60":{"loc":{"start":{"line":548,"column":6},"end":{"line":552,"column":7}},"type":"if","locations":[{"start":{"line":548,"column":6},"end":{"line":552,"column":7}},{"start":{"line":548,"column":6},"end":{"line":552,"column":7}}]},"61":{"loc":{"start":{"line":580,"column":6},"end":{"line":589,"column":7}},"type":"if","locations":[{"start":{"line":580,"column":6},"end":{"line":589,"column":7}},{"start":{"line":580,"column":6},"end":{"line":589,"column":7}}]},"62":{"loc":{"start":{"line":582,"column":8},"end":{"line":582,"column":51}},"type":"if","locations":[{"start":{"line":582,"column":8},"end":{"line":582,"column":51}},{"start":{"line":582,"column":8},"end":{"line":582,"column":51}}]},"63":{"loc":{"start":{"line":613,"column":6},"end":{"line":615,"column":7}},"type":"if","locations":[{"start":{"line":613,"column":6},"end":{"line":615,"column":7}},{"start":{"line":613,"column":6},"end":{"line":615,"column":7}}]},"64":{"loc":{"start":{"line":624,"column":2},"end":{"line":630,"column":3}},"type":"if","locations":[{"start":{"line":624,"column":2},"end":{"line":630,"column":3}},{"start":{"line":624,"column":2},"end":{"line":630,"column":3}}]},"65":{"loc":{"start":{"line":635,"column":2},"end":{"line":640,"column":3}},"type":"if","locations":[{"start":{"line":635,"column":2},"end":{"line":640,"column":3}},{"start":{"line":635,"column":2},"end":{"line":640,"column":3}}]},"66":{"loc":{"start":{"line":650,"column":9},"end":{"line":650,"column":93}},"type":"cond-expr","locations":[{"start":{"line":650,"column":35},"end":{"line":650,"column":39}},{"start":{"line":650,"column":42},"end":{"line":650,"column":93}}]},"67":{"loc":{"start":{"line":660,"column":9},"end":{"line":660,"column":86}},"type":"cond-expr","locations":[{"start":{"line":660,"column":35},"end":{"line":660,"column":39}},{"start":{"line":660,"column":42},"end":{"line":660,"column":86}}]},"68":{"loc":{"start":{"line":682,"column":2},"end":{"line":685,"column":3}},"type":"if","locations":[{"start":{"line":682,"column":2},"end":{"line":685,"column":3}},{"start":{"line":682,"column":2},"end":{"line":685,"column":3}}]},"69":{"loc":{"start":{"line":682,"column":6},"end":{"line":682,"column":49}},"type":"binary-expr","locations":[{"start":{"line":682,"column":6},"end":{"line":682,"column":24}},{"start":{"line":682,"column":28},"end":{"line":682,"column":49}}]},"70":{"loc":{"start":{"line":687,"column":2},"end":{"line":690,"column":3}},"type":"if","locations":[{"start":{"line":687,"column":2},"end":{"line":690,"column":3}},{"start":{"line":687,"column":2},"end":{"line":690,"column":3}}]},"71":{"loc":{"start":{"line":687,"column":6},"end":{"line":687,"column":51}},"type":"binary-expr","locations":[{"start":{"line":687,"column":6},"end":{"line":687,"column":27}},{"start":{"line":687,"column":31},"end":{"line":687,"column":51}}]},"72":{"loc":{"start":{"line":692,"column":2},"end":{"line":695,"column":3}},"type":"if","locations":[{"start":{"line":692,"column":2},"end":{"line":695,"column":3}},{"start":{"line":692,"column":2},"end":{"line":695,"column":3}}]},"73":{"loc":{"start":{"line":715,"column":6},"end":{"line":719,"column":7}},"type":"if","locations":[{"start":{"line":715,"column":6},"end":{"line":719,"column":7}},{"start":{"line":715,"column":6},"end":{"line":719,"column":7}}]},"74":{"loc":{"start":{"line":715,"column":10},"end":{"line":715,"column":50}},"type":"binary-expr","locations":[{"start":{"line":715,"column":10},"end":{"line":715,"column":30}},{"start":{"line":715,"column":34},"end":{"line":715,"column":50}}]},"75":{"loc":{"start":{"line":729,"column":6},"end":{"line":735,"column":7}},"type":"if","locations":[{"start":{"line":729,"column":6},"end":{"line":735,"column":7}},{"start":{"line":729,"column":6},"end":{"line":735,"column":7}}]},"76":{"loc":{"start":{"line":729,"column":10},"end":{"line":729,"column":86}},"type":"binary-expr","locations":[{"start":{"line":729,"column":10},"end":{"line":729,"column":27}},{"start":{"line":729,"column":31},"end":{"line":729,"column":50}},{"start":{"line":729,"column":54},"end":{"line":729,"column":86}}]},"77":{"loc":{"start":{"line":742,"column":6},"end":{"line":754,"column":7}},"type":"if","locations":[{"start":{"line":742,"column":6},"end":{"line":754,"column":7}},{"start":{"line":742,"column":6},"end":{"line":754,"column":7}}]},"78":{"loc":{"start":{"line":743,"column":8},"end":{"line":753,"column":9}},"type":"if","locations":[{"start":{"line":743,"column":8},"end":{"line":753,"column":9}},{"start":{"line":743,"column":8},"end":{"line":753,"column":9}}]},"79":{"loc":{"start":{"line":744,"column":10},"end":{"line":746,"column":11}},"type":"if","locations":[{"start":{"line":744,"column":10},"end":{"line":746,"column":11}},{"start":{"line":744,"column":10},"end":{"line":746,"column":11}}]},"80":{"loc":{"start":{"line":744,"column":14},"end":{"line":744,"column":109}},"type":"binary-expr","locations":[{"start":{"line":744,"column":14},"end":{"line":744,"column":35}},{"start":{"line":744,"column":39},"end":{"line":744,"column":58}},{"start":{"line":744,"column":62},"end":{"line":744,"column":86}},{"start":{"line":744,"column":90},"end":{"line":744,"column":109}}]},"81":{"loc":{"start":{"line":747,"column":15},"end":{"line":753,"column":9}},"type":"if","locations":[{"start":{"line":747,"column":15},"end":{"line":753,"column":9}},{"start":{"line":747,"column":15},"end":{"line":753,"column":9}}]},"82":{"loc":{"start":{"line":748,"column":10},"end":{"line":752,"column":11}},"type":"if","locations":[{"start":{"line":748,"column":10},"end":{"line":752,"column":11}},{"start":{"line":748,"column":10},"end":{"line":752,"column":11}}]},"83":{"loc":{"start":{"line":750,"column":17},"end":{"line":752,"column":11}},"type":"if","locations":[{"start":{"line":750,"column":17},"end":{"line":752,"column":11}},{"start":{"line":750,"column":17},"end":{"line":752,"column":11}}]},"84":{"loc":{"start":{"line":763,"column":13},"end":{"line":765,"column":29}},"type":"binary-expr","locations":[{"start":{"line":763,"column":13},"end":{"line":763,"column":38}},{"start":{"line":764,"column":13},"end":{"line":764,"column":43}},{"start":{"line":765,"column":13},"end":{"line":765,"column":29}}]},"85":{"loc":{"start":{"line":773,"column":6},"end":{"line":786,"column":7}},"type":"if","locations":[{"start":{"line":773,"column":6},"end":{"line":786,"column":7}},{"start":{"line":773,"column":6},"end":{"line":786,"column":7}}]},"86":{"loc":{"start":{"line":773,"column":10},"end":{"line":773,"column":53}},"type":"binary-expr","locations":[{"start":{"line":773,"column":10},"end":{"line":773,"column":26}},{"start":{"line":773,"column":30},"end":{"line":773,"column":53}}]},"87":{"loc":{"start":{"line":778,"column":10},"end":{"line":784,"column":11}},"type":"if","locations":[{"start":{"line":778,"column":10},"end":{"line":784,"column":11}},{"start":{"line":778,"column":10},"end":{"line":784,"column":11}}]},"88":{"loc":{"start":{"line":780,"column":37},"end":{"line":780,"column":64}},"type":"binary-expr","locations":[{"start":{"line":780,"column":37},"end":{"line":780,"column":44}},{"start":{"line":780,"column":48},"end":{"line":780,"column":64}}]},"89":{"loc":{"start":{"line":795,"column":6},"end":{"line":797,"column":7}},"type":"if","locations":[{"start":{"line":795,"column":6},"end":{"line":797,"column":7}},{"start":{"line":795,"column":6},"end":{"line":797,"column":7}}]},"90":{"loc":{"start":{"line":799,"column":6},"end":{"line":805,"column":7}},"type":"if","locations":[{"start":{"line":799,"column":6},"end":{"line":805,"column":7}},{"start":{"line":799,"column":6},"end":{"line":805,"column":7}}]},"91":{"loc":{"start":{"line":814,"column":6},"end":{"line":816,"column":7}},"type":"if","locations":[{"start":{"line":814,"column":6},"end":{"line":816,"column":7}},{"start":{"line":814,"column":6},"end":{"line":816,"column":7}}]},"92":{"loc":{"start":{"line":815,"column":26},"end":{"line":815,"column":52}},"type":"binary-expr","locations":[{"start":{"line":815,"column":26},"end":{"line":815,"column":41}},{"start":{"line":815,"column":45},"end":{"line":815,"column":52}}]},"93":{"loc":{"start":{"line":823,"column":6},"end":{"line":845,"column":7}},"type":"if","locations":[{"start":{"line":823,"column":6},"end":{"line":845,"column":7}},{"start":{"line":823,"column":6},"end":{"line":845,"column":7}}]},"94":{"loc":{"start":{"line":829,"column":8},"end":{"line":837,"column":9}},"type":"if","locations":[{"start":{"line":829,"column":8},"end":{"line":837,"column":9}},{"start":{"line":829,"column":8},"end":{"line":837,"column":9}}]},"95":{"loc":{"start":{"line":838,"column":13},"end":{"line":845,"column":7}},"type":"if","locations":[{"start":{"line":838,"column":13},"end":{"line":845,"column":7}},{"start":{"line":838,"column":13},"end":{"line":845,"column":7}}]},"96":{"loc":{"start":{"line":852,"column":6},"end":{"line":854,"column":7}},"type":"if","locations":[{"start":{"line":852,"column":6},"end":{"line":854,"column":7}},{"start":{"line":852,"column":6},"end":{"line":854,"column":7}}]},"97":{"loc":{"start":{"line":862,"column":6},"end":{"line":866,"column":7}},"type":"if","locations":[{"start":{"line":862,"column":6},"end":{"line":866,"column":7}},{"start":{"line":862,"column":6},"end":{"line":866,"column":7}}]},"98":{"loc":{"start":{"line":862,"column":10},"end":{"line":862,"column":46}},"type":"binary-expr","locations":[{"start":{"line":862,"column":10},"end":{"line":862,"column":27}},{"start":{"line":862,"column":31},"end":{"line":862,"column":46}}]},"99":{"loc":{"start":{"line":873,"column":6},"end":{"line":877,"column":7}},"type":"if","locations":[{"start":{"line":873,"column":6},"end":{"line":877,"column":7}},{"start":{"line":873,"column":6},"end":{"line":877,"column":7}}]},"100":{"loc":{"start":{"line":873,"column":10},"end":{"line":873,"column":59}},"type":"binary-expr","locations":[{"start":{"line":873,"column":10},"end":{"line":873,"column":27}},{"start":{"line":873,"column":32},"end":{"line":873,"column":43}},{"start":{"line":873,"column":47},"end":{"line":873,"column":58}}]},"101":{"loc":{"start":{"line":884,"column":6},"end":{"line":884,"column":54}},"type":"if","locations":[{"start":{"line":884,"column":6},"end":{"line":884,"column":54}},{"start":{"line":884,"column":6},"end":{"line":884,"column":54}}]},"102":{"loc":{"start":{"line":890,"column":6},"end":{"line":894,"column":7}},"type":"if","locations":[{"start":{"line":890,"column":6},"end":{"line":894,"column":7}},{"start":{"line":890,"column":6},"end":{"line":894,"column":7}}]},"103":{"loc":{"start":{"line":903,"column":8},"end":{"line":905,"column":9}},"type":"if","locations":[{"start":{"line":903,"column":8},"end":{"line":905,"column":9}},{"start":{"line":903,"column":8},"end":{"line":905,"column":9}}]},"104":{"loc":{"start":{"line":903,"column":12},"end":{"line":903,"column":54}},"type":"binary-expr","locations":[{"start":{"line":903,"column":12},"end":{"line":903,"column":16}},{"start":{"line":903,"column":20},"end":{"line":903,"column":54}}]},"105":{"loc":{"start":{"line":917,"column":8},"end":{"line":919,"column":9}},"type":"if","locations":[{"start":{"line":917,"column":8},"end":{"line":919,"column":9}},{"start":{"line":917,"column":8},"end":{"line":919,"column":9}}]},"106":{"loc":{"start":{"line":917,"column":12},"end":{"line":917,"column":76}},"type":"binary-expr","locations":[{"start":{"line":917,"column":12},"end":{"line":917,"column":16}},{"start":{"line":917,"column":20},"end":{"line":917,"column":38}},{"start":{"line":917,"column":42},"end":{"line":917,"column":76}}]},"107":{"loc":{"start":{"line":932,"column":6},"end":{"line":939,"column":7}},"type":"if","locations":[{"start":{"line":932,"column":6},"end":{"line":939,"column":7}},{"start":{"line":932,"column":6},"end":{"line":939,"column":7}}]},"108":{"loc":{"start":{"line":945,"column":6},"end":{"line":947,"column":7}},"type":"if","locations":[{"start":{"line":945,"column":6},"end":{"line":947,"column":7}},{"start":{"line":945,"column":6},"end":{"line":947,"column":7}}]},"109":{"loc":{"start":{"line":954,"column":6},"end":{"line":956,"column":7}},"type":"if","locations":[{"start":{"line":954,"column":6},"end":{"line":956,"column":7}},{"start":{"line":954,"column":6},"end":{"line":956,"column":7}}]},"110":{"loc":{"start":{"line":964,"column":13},"end":{"line":964,"column":53}},"type":"binary-expr","locations":[{"start":{"line":964,"column":13},"end":{"line":964,"column":33}},{"start":{"line":964,"column":37},"end":{"line":964,"column":53}}]},"111":{"loc":{"start":{"line":971,"column":6},"end":{"line":973,"column":7}},"type":"if","locations":[{"start":{"line":971,"column":6},"end":{"line":973,"column":7}},{"start":{"line":971,"column":6},"end":{"line":973,"column":7}}]},"112":{"loc":{"start":{"line":983,"column":6},"end":{"line":985,"column":7}},"type":"if","locations":[{"start":{"line":983,"column":6},"end":{"line":985,"column":7}},{"start":{"line":983,"column":6},"end":{"line":985,"column":7}}]},"113":{"loc":{"start":{"line":983,"column":10},"end":{"line":983,"column":51}},"type":"binary-expr","locations":[{"start":{"line":983,"column":10},"end":{"line":983,"column":25}},{"start":{"line":983,"column":29},"end":{"line":983,"column":51}}]},"114":{"loc":{"start":{"line":986,"column":6},"end":{"line":999,"column":7}},"type":"if","locations":[{"start":{"line":986,"column":6},"end":{"line":999,"column":7}},{"start":{"line":986,"column":6},"end":{"line":999,"column":7}}]},"115":{"loc":{"start":{"line":992,"column":10},"end":{"line":996,"column":11}},"type":"if","locations":[{"start":{"line":992,"column":10},"end":{"line":996,"column":11}},{"start":{"line":992,"column":10},"end":{"line":996,"column":11}}]},"116":{"loc":{"start":{"line":1009,"column":6},"end":{"line":1012,"column":7}},"type":"if","locations":[{"start":{"line":1009,"column":6},"end":{"line":1012,"column":7}},{"start":{"line":1009,"column":6},"end":{"line":1012,"column":7}}]},"117":{"loc":{"start":{"line":1011,"column":8},"end":{"line":1011,"column":54}},"type":"if","locations":[{"start":{"line":1011,"column":8},"end":{"line":1011,"column":54}},{"start":{"line":1011,"column":8},"end":{"line":1011,"column":54}}]},"118":{"loc":{"start":{"line":1017,"column":6},"end":{"line":1019,"column":7}},"type":"if","locations":[{"start":{"line":1017,"column":6},"end":{"line":1019,"column":7}},{"start":{"line":1017,"column":6},"end":{"line":1019,"column":7}}]},"119":{"loc":{"start":{"line":1018,"column":9},"end":{"line":1018,"column":27}},"type":"binary-expr","locations":[{"start":{"line":1018,"column":9},"end":{"line":1018,"column":19}},{"start":{"line":1018,"column":23},"end":{"line":1018,"column":27}}]},"120":{"loc":{"start":{"line":1025,"column":6},"end":{"line":1027,"column":7}},"type":"if","locations":[{"start":{"line":1025,"column":6},"end":{"line":1027,"column":7}},{"start":{"line":1025,"column":6},"end":{"line":1027,"column":7}}]},"121":{"loc":{"start":{"line":1028,"column":6},"end":{"line":1030,"column":7}},"type":"if","locations":[{"start":{"line":1028,"column":6},"end":{"line":1030,"column":7}},{"start":{"line":1028,"column":6},"end":{"line":1030,"column":7}}]},"122":{"loc":{"start":{"line":1043,"column":6},"end":{"line":1047,"column":7}},"type":"if","locations":[{"start":{"line":1043,"column":6},"end":{"line":1047,"column":7}},{"start":{"line":1043,"column":6},"end":{"line":1047,"column":7}}]},"123":{"loc":{"start":{"line":1045,"column":13},"end":{"line":1047,"column":7}},"type":"if","locations":[{"start":{"line":1045,"column":13},"end":{"line":1047,"column":7}},{"start":{"line":1045,"column":13},"end":{"line":1047,"column":7}}]},"124":{"loc":{"start":{"line":1048,"column":6},"end":{"line":1054,"column":7}},"type":"if","locations":[{"start":{"line":1048,"column":6},"end":{"line":1054,"column":7}},{"start":{"line":1048,"column":6},"end":{"line":1054,"column":7}}]},"125":{"loc":{"start":{"line":1050,"column":8},"end":{"line":1053,"column":9}},"type":"if","locations":[{"start":{"line":1050,"column":8},"end":{"line":1053,"column":9}},{"start":{"line":1050,"column":8},"end":{"line":1053,"column":9}}]},"126":{"loc":{"start":{"line":1050,"column":12},"end":{"line":1050,"column":104}},"type":"binary-expr","locations":[{"start":{"line":1050,"column":13},"end":{"line":1050,"column":32}},{"start":{"line":1050,"column":36},"end":{"line":1050,"column":55}},{"start":{"line":1050,"column":60},"end":{"line":1050,"column":81}},{"start":{"line":1050,"column":85},"end":{"line":1050,"column":104}}]},"127":{"loc":{"start":{"line":1063,"column":6},"end":{"line":1065,"column":7}},"type":"if","locations":[{"start":{"line":1063,"column":6},"end":{"line":1065,"column":7}},{"start":{"line":1063,"column":6},"end":{"line":1065,"column":7}}]},"128":{"loc":{"start":{"line":1074,"column":6},"end":{"line":1077,"column":7}},"type":"if","locations":[{"start":{"line":1074,"column":6},"end":{"line":1077,"column":7}},{"start":{"line":1074,"column":6},"end":{"line":1077,"column":7}}]},"129":{"loc":{"start":{"line":1084,"column":6},"end":{"line":1086,"column":7}},"type":"if","locations":[{"start":{"line":1084,"column":6},"end":{"line":1086,"column":7}},{"start":{"line":1084,"column":6},"end":{"line":1086,"column":7}}]},"130":{"loc":{"start":{"line":1095,"column":13},"end":{"line":1095,"column":53}},"type":"binary-expr","locations":[{"start":{"line":1095,"column":13},"end":{"line":1095,"column":33}},{"start":{"line":1095,"column":37},"end":{"line":1095,"column":53}}]},"131":{"loc":{"start":{"line":1112,"column":6},"end":{"line":1124,"column":7}},"type":"if","locations":[{"start":{"line":1112,"column":6},"end":{"line":1124,"column":7}},{"start":{"line":1112,"column":6},"end":{"line":1124,"column":7}}]},"132":{"loc":{"start":{"line":1112,"column":10},"end":{"line":1112,"column":54}},"type":"binary-expr","locations":[{"start":{"line":1112,"column":10},"end":{"line":1112,"column":24}},{"start":{"line":1112,"column":28},"end":{"line":1112,"column":54}}]},"133":{"loc":{"start":{"line":1117,"column":10},"end":{"line":1122,"column":11}},"type":"if","locations":[{"start":{"line":1117,"column":10},"end":{"line":1122,"column":11}},{"start":{"line":1117,"column":10},"end":{"line":1122,"column":11}}]},"134":{"loc":{"start":{"line":1129,"column":6},"end":{"line":1151,"column":7}},"type":"if","locations":[{"start":{"line":1129,"column":6},"end":{"line":1151,"column":7}},{"start":{"line":1129,"column":6},"end":{"line":1151,"column":7}}]},"135":{"loc":{"start":{"line":1129,"column":10},"end":{"line":1129,"column":52}},"type":"binary-expr","locations":[{"start":{"line":1129,"column":10},"end":{"line":1129,"column":26}},{"start":{"line":1129,"column":30},"end":{"line":1129,"column":52}}]},"136":{"loc":{"start":{"line":1138,"column":16},"end":{"line":1138,"column":31}},"type":"binary-expr","locations":[{"start":{"line":1138,"column":16},"end":{"line":1138,"column":24}},{"start":{"line":1138,"column":28},"end":{"line":1138,"column":31}}]},"137":{"loc":{"start":{"line":1141,"column":8},"end":{"line":1150,"column":9}},"type":"if","locations":[{"start":{"line":1141,"column":8},"end":{"line":1150,"column":9}},{"start":{"line":1141,"column":8},"end":{"line":1150,"column":9}}]},"138":{"loc":{"start":{"line":1143,"column":15},"end":{"line":1150,"column":9}},"type":"if","locations":[{"start":{"line":1143,"column":15},"end":{"line":1150,"column":9}},{"start":{"line":1143,"column":15},"end":{"line":1150,"column":9}}]},"139":{"loc":{"start":{"line":1161,"column":6},"end":{"line":1175,"column":7}},"type":"if","locations":[{"start":{"line":1161,"column":6},"end":{"line":1175,"column":7}},{"start":{"line":1161,"column":6},"end":{"line":1175,"column":7}}]},"140":{"loc":{"start":{"line":1165,"column":10},"end":{"line":1165,"column":55}},"type":"if","locations":[{"start":{"line":1165,"column":10},"end":{"line":1165,"column":55}},{"start":{"line":1165,"column":10},"end":{"line":1165,"column":55}}]},"141":{"loc":{"start":{"line":1169,"column":10},"end":{"line":1173,"column":11}},"type":"if","locations":[{"start":{"line":1169,"column":10},"end":{"line":1173,"column":11}},{"start":{"line":1169,"column":10},"end":{"line":1173,"column":11}}]},"142":{"loc":{"start":{"line":1183,"column":6},"end":{"line":1187,"column":7}},"type":"if","locations":[{"start":{"line":1183,"column":6},"end":{"line":1187,"column":7}},{"start":{"line":1183,"column":6},"end":{"line":1187,"column":7}}]}},"s":{"1":1,"2":1,"3":287,"4":287,"5":287,"6":287,"7":17,"8":3,"9":287,"10":284,"11":284,"12":1,"13":13,"14":13,"15":13,"16":1,"17":5,"18":5,"19":5,"20":5,"21":5,"22":1,"23":4,"24":5,"25":5,"26":5,"27":5,"28":5,"29":5,"30":5,"31":5,"32":5,"33":5,"34":5,"35":1,"36":36,"37":13,"38":23,"39":5,"40":18,"41":3,"42":15,"43":10,"44":2,"45":8,"46":5,"47":2,"48":3,"49":2,"50":1,"51":1,"52":3,"53":3,"54":3,"55":3,"56":1,"57":8,"58":8,"59":1,"60":7,"61":8,"62":8,"63":8,"64":8,"65":6,"66":6,"67":5,"68":6,"69":6,"70":6,"71":1,"72":2,"73":2,"74":2,"75":1,"76":1,"77":1,"78":2,"79":2,"80":2,"81":1,"82":2,"83":2,"84":2,"85":1,"86":27,"87":27,"88":12,"89":15,"90":27,"91":27,"92":27,"93":4,"94":5,"95":27,"96":1,"97":1,"98":2,"99":27,"100":1,"101":7,"102":7,"103":7,"104":4,"105":3,"106":7,"107":1,"108":12,"109":12,"110":1,"111":19,"112":19,"113":12,"114":7,"115":17,"116":17,"117":17,"118":1,"119":91,"120":91,"121":6,"122":3,"123":3,"124":3,"125":6,"126":91,"127":89,"128":89,"129":89,"130":89,"131":27,"132":27,"133":89,"134":1,"135":76,"136":76,"137":76,"138":76,"139":76,"140":76,"141":0,"142":76,"143":91,"144":89,"145":19,"146":70,"147":70,"148":70,"149":1,"150":28,"151":28,"152":28,"153":28,"154":28,"155":28,"156":31,"157":31,"158":3,"159":28,"160":28,"161":28,"162":1,"163":45,"164":1,"165":6,"166":6,"167":6,"168":6,"169":6,"170":6,"171":6,"172":6,"173":1,"174":13,"175":13,"176":13,"177":13,"178":2,"179":13,"180":13,"181":6,"182":6,"183":2,"184":13,"185":1,"186":13,"187":13,"188":13,"189":1,"190":6,"191":6,"192":6,"193":6,"194":6,"195":6,"196":6,"197":1,"198":7,"199":7,"200":7,"201":7,"202":7,"203":1,"204":56,"205":56,"206":56,"207":56,"208":56,"209":56,"210":52,"211":52,"212":52,"213":52,"214":52,"215":4,"216":4,"217":52,"218":6,"219":46,"220":7,"221":39,"222":0,"223":39,"224":39,"225":6,"226":33,"227":2,"228":33,"229":33,"230":33,"231":33,"232":33,"233":33,"234":56,"235":56,"236":1,"237":52,"238":0,"239":1,"240":84,"241":84,"242":84,"243":84,"244":7,"245":7,"246":7,"247":7,"248":84,"249":1,"250":77,"251":77,"252":77,"253":77,"254":21,"255":77,"256":1,"257":5,"258":5,"259":5,"260":5,"261":1,"262":4,"263":4,"264":4,"265":4,"266":4,"267":4,"268":2,"269":2,"270":4,"271":4,"272":1,"273":23,"274":23,"275":23,"276":23,"277":1,"278":23,"279":23,"280":23,"281":1,"282":26,"283":26,"284":13,"285":13,"286":4,"287":26,"288":3,"289":26,"290":1,"291":299,"292":2,"293":11,"294":7,"295":1,"296":113,"297":88,"298":77,"299":1,"300":396,"301":396,"302":396,"303":396,"304":396,"305":299,"306":29,"307":4,"308":4,"309":3,"310":2,"311":2,"312":2,"313":2,"314":2,"315":2,"316":2,"317":2,"318":1,"319":28,"320":28,"321":17,"322":10,"323":10,"324":7,"325":28,"326":9,"327":9,"328":9,"329":19,"330":19,"331":19,"332":19,"333":19,"334":19,"335":19,"336":19,"337":4,"338":4,"339":4,"340":4,"341":4,"342":2,"343":2,"344":2,"345":4,"346":4,"347":4,"348":0,"349":4,"350":4,"351":4,"352":4,"353":4,"354":8,"355":8,"356":8,"357":8,"358":8,"359":2,"360":2,"361":2,"362":1,"363":1,"364":1,"365":5,"366":5,"367":6,"368":5,"369":2,"370":1,"371":391,"372":391,"373":388,"374":10,"375":10,"376":10,"377":378,"378":1,"379":414,"380":414,"381":23,"382":23,"383":391,"384":1,"385":389,"386":389,"387":386,"388":386,"389":2,"390":386,"391":1,"392":379,"393":379,"394":376,"395":376,"396":10,"397":376,"398":1,"399":379,"400":379,"401":379,"402":376,"403":376,"404":1,"405":184,"406":184,"407":181,"408":1,"409":94,"410":92,"411":92,"412":0,"413":0,"414":92,"415":18,"416":18,"417":92,"418":0,"419":0,"420":92,"421":1,"422":16,"423":16,"424":253,"425":253,"426":84,"427":17,"428":84,"429":253,"430":253,"431":300,"432":10,"433":10,"434":10,"435":290,"436":253,"437":253,"438":115,"439":46,"440":31,"441":31,"442":15,"443":15,"444":0,"445":15,"446":15,"447":69,"448":253,"449":253,"450":6,"451":253,"452":253,"453":316,"454":5,"455":5,"456":5,"457":5,"458":5,"459":5,"460":5,"461":0,"462":311,"463":253,"464":253,"465":74,"466":74,"467":6,"468":74,"469":23,"470":23,"471":23,"472":23,"473":51,"474":253,"475":253,"476":7,"477":7,"478":6,"479":7,"480":253,"481":253,"482":6,"483":4,"484":4,"485":4,"486":4,"487":2,"488":2,"489":2,"490":2,"491":2,"492":2,"493":2,"494":2,"495":2,"496":0,"497":253,"498":253,"499":33,"500":33,"501":16,"502":253,"503":253,"504":1260,"505":11,"506":1249,"507":253,"508":253,"509":3518,"510":127,"511":3391,"512":253,"513":253,"514":0,"515":0,"516":253,"517":253,"518":43,"519":4,"520":39,"521":253,"522":253,"523":34,"524":32,"525":32,"526":12,"527":34,"528":253,"529":253,"530":14,"531":16,"532":16,"533":0,"534":14,"535":253,"536":253,"537":20,"538":20,"539":20,"540":0,"541":0,"542":0,"543":0,"544":20,"545":253,"546":253,"547":227,"548":223,"549":253,"550":253,"551":4,"552":4,"553":4,"554":253,"555":253,"556":14,"557":253,"558":253,"559":11,"560":4,"561":11,"562":11,"563":253,"564":253,"565":33,"566":33,"567":2,"568":33,"569":5,"570":5,"571":5,"572":6,"573":6,"574":6,"575":1,"576":5,"577":6,"578":253,"579":253,"580":17,"581":4,"582":4,"583":0,"584":17,"585":17,"586":4,"587":253,"588":253,"589":36,"590":1,"591":36,"592":30,"593":36,"594":36,"595":253,"596":253,"597":8,"598":8,"599":8,"600":3,"601":5,"602":5,"603":8,"604":8,"605":8,"606":6,"607":6,"608":8,"609":253,"610":253,"611":32,"612":6,"613":32,"614":253,"615":253,"616":91,"617":91,"618":68,"619":67,"620":253,"621":253,"622":7,"623":5,"624":7,"625":253,"626":253,"627":7,"628":253,"629":253,"630":330,"631":330,"632":50,"633":50,"634":50,"635":11,"636":11,"637":11,"638":0,"639":291,"640":291,"641":16,"642":16,"643":13,"644":11,"645":5,"646":11,"647":8,"648":3,"649":2,"650":1,"651":275,"652":275,"653":253,"654":253,"655":44,"656":18,"657":18,"658":18,"659":16,"660":3,"661":13,"662":5,"663":5,"664":0,"665":44,"666":253,"667":253,"668":11,"669":4,"670":7},"f":{"1":287,"2":13,"3":5,"4":36,"5":3,"6":8,"7":2,"8":2,"9":2,"10":27,"11":7,"12":12,"13":19,"14":91,"15":76,"16":28,"17":45,"18":6,"19":13,"20":6,"21":7,"22":56,"23":52,"24":84,"25":77,"26":5,"27":4,"28":23,"29":26,"30":299,"31":396,"32":391,"33":414,"34":389,"35":379,"36":379,"37":184,"38":94,"39":16,"40":253,"41":253,"42":84,"43":253,"44":300,"45":253,"46":115,"47":253,"48":6,"49":253,"50":316,"51":253,"52":74,"53":253,"54":7,"55":253,"56":6,"57":253,"58":33,"59":253,"60":1260,"61":253,"62":3518,"63":253,"64":0,"65":253,"66":43,"67":253,"68":34,"69":253,"70":14,"71":253,"72":20,"73":253,"74":227,"75":253,"76":4,"77":253,"78":14,"79":253,"80":11,"81":253,"82":33,"83":253,"84":17,"85":253,"86":36,"87":253,"88":8,"89":253,"90":32,"91":253,"92":91,"93":253,"94":7,"95":253,"96":7,"97":253,"98":330,"99":253,"100":44,"101":253,"102":11},"b":{"1":[287,270],"2":[17,270],"3":[3,14],"4":[17,16],"5":[1,4],"6":[13,23],"7":[5,18],"8":[3,15],"9":[10,5],"10":[2,8],"11":[2,3],"12":[2,1],"13":[1,7],"14":[12,15],"15":[4,23],"16":[1,26],"17":[4,3],"18":[12,7],"19":[6,85],"20":[3,3],"21":[3,0],"22":[27,62],"23":[76,0],"24":[76,11],"25":[19,70],"26":[3,28],"27":[0,45],"28":[45,45],"29":[2,11],"30":[2,4],"31":[1,12],"32":[4,48],"33":[52,7],"34":[6,46],"35":[7,39],"36":[46,40],"37":[0,39],"38":[39,2],"39":[6,33],"40":[39,38],"41":[2,31],"42":[0,52],"43":[52,26,26],"44":[84,7],"45":[84,7],"46":[84,7],"47":[21,56],"48":[6,6],"49":[2,2],"50":[1,22],"51":[4,9],"52":[3,23],"53":[2,11,7,7,1,113,88,77],"54":[299,29,4,4,28,4,1,2,4,8,2,1,5,6],"55":[3,1],"56":[17,11],"57":[28,18],"58":[10,7],"59":[10,9],"60":[9,19],"61":[4,0],"62":[0,4],"63":[5,1],"64":[10,378],"65":[23,391],"66":[384,2],"67":[367,9],"68":[0,92],"69":[92,0],"70":[18,74],"71":[92,92],"72":[0,92],"73":[17,67],"74":[84,17],"75":[10,290],"76":[300,256,70],"77":[46,69],"78":[31,15],"79":[31,0],"80":[31,19,6,2],"81":[15,0],"82":[0,15],"83":[15,0],"84":[6,2,0],"85":[5,311],"86":[316,49],"87":[5,0],"88":[5,0],"89":[6,68],"90":[23,51],"91":[6,1],"92":[6,0],"93":[4,2],"94":[2,2],"95":[2,0],"96":[16,17],"97":[11,1249],"98":[1260,469],"99":[127,3391],"100":[3518,1245,1145],"101":[0,0],"102":[4,39],"103":[12,20],"104":[32,32],"105":[0,16],"106":[16,16,0],"107":[0,20],"108":[223,4],"109":[4,0],"110":[14,10],"111":[4,7],"112":[2,31],"113":[33,6],"114":[5,28],"115":[1,5],"116":[4,13],"117":[0,4],"118":[4,13],"119":[4,4],"120":[1,35],"121":[30,6],"122":[3,5],"123":[5,0],"124":[8,0],"125":[6,2],"126":[8,3,6,4],"127":[6,26],"128":[68,23],"129":[5,2],"130":[7,2],"131":[50,280],"132":[330,330],"133":[11,0],"134":[16,275],"135":[291,280],"136":[5,0],"137":[8,3],"138":[2,1],"139":[18,26],"140":[3,13],"141":[5,0],"142":[4,7]},"hash":"d9670651c5cd02b04f94e68d2e9e80384286a740"}
+,"/home/travis/build/babel/babylon/src/plugins/jsx/index.js": {"path":"/home/travis/build/babel/babylon/src/plugins/jsx/index.js","statementMap":{"1":{"start":{"line":10,"column":19},"end":{"line":10,"column":34}},"2":{"start":{"line":11,"column":23},"end":{"line":11,"column":30}},"3":{"start":{"line":13,"column":0},"end":{"line":13,"column":42}},"4":{"start":{"line":14,"column":0},"end":{"line":14,"column":43}},"5":{"start":{"line":15,"column":0},"end":{"line":15,"column":57}},"6":{"start":{"line":17,"column":0},"end":{"line":17,"column":38}},"7":{"start":{"line":18,"column":0},"end":{"line":18,"column":60}},"8":{"start":{"line":19,"column":0},"end":{"line":19,"column":68}},"9":{"start":{"line":20,"column":0},"end":{"line":20,"column":42}},"10":{"start":{"line":22,"column":0},"end":{"line":26,"column":2}},"11":{"start":{"line":23,"column":2},"end":{"line":23,"column":37}},"12":{"start":{"line":24,"column":2},"end":{"line":24,"column":37}},"13":{"start":{"line":25,"column":2},"end":{"line":25,"column":33}},"14":{"start":{"line":28,"column":0},"end":{"line":36,"column":2}},"15":{"start":{"line":29,"column":12},"end":{"line":29,"column":36}},"16":{"start":{"line":30,"column":2},"end":{"line":35,"column":3}},"17":{"start":{"line":31,"column":4},"end":{"line":31,"column":29}},"18":{"start":{"line":32,"column":4},"end":{"line":32,"column":61}},"19":{"start":{"line":34,"column":4},"end":{"line":34,"column":34}},"20":{"start":{"line":38,"column":9},"end":{"line":38,"column":25}},"21":{"start":{"line":42,"column":0},"end":{"line":81,"column":2}},"22":{"start":{"line":43,"column":12},"end":{"line":43,"column":14}},"23":{"start":{"line":44,"column":19},"end":{"line":44,"column":33}},"24":{"start":{"line":45,"column":2},"end":{"line":80,"column":3}},"25":{"start":{"line":46,"column":4},"end":{"line":48,"column":5}},"26":{"start":{"line":47,"column":6},"end":{"line":47,"column":64}},"27":{"start":{"line":50,"column":13},"end":{"line":50,"column":50}},"28":{"start":{"line":52,"column":4},"end":{"line":79,"column":5}},"29":{"start":{"line":55,"column":8},"end":{"line":61,"column":9}},"30":{"start":{"line":56,"column":10},"end":{"line":59,"column":11}},"31":{"start":{"line":57,"column":12},"end":{"line":57,"column":29}},"32":{"start":{"line":58,"column":12},"end":{"line":58,"column":52}},"33":{"start":{"line":60,"column":10},"end":{"line":60,"column":43}},"34":{"start":{"line":62,"column":8},"end":{"line":62,"column":60}},"35":{"start":{"line":63,"column":8},"end":{"line":63,"column":49}},"36":{"start":{"line":66,"column":8},"end":{"line":66,"column":60}},"37":{"start":{"line":67,"column":8},"end":{"line":67,"column":36}},"38":{"start":{"line":68,"column":8},"end":{"line":68,"column":36}},"39":{"start":{"line":69,"column":8},"end":{"line":69,"column":14}},"40":{"start":{"line":72,"column":8},"end":{"line":78,"column":9}},"41":{"start":{"line":73,"column":10},"end":{"line":73,"column":62}},"42":{"start":{"line":74,"column":10},"end":{"line":74,"column":43}},"43":{"start":{"line":75,"column":10},"end":{"line":75,"column":38}},"44":{"start":{"line":77,"column":10},"end":{"line":77,"column":27}},"45":{"start":{"line":83,"column":0},"end":{"line":97,"column":2}},"46":{"start":{"line":84,"column":11},"end":{"line":84,"column":48}},"47":{"start":{"line":86,"column":2},"end":{"line":86,"column":19}},"48":{"start":{"line":87,"column":2},"end":{"line":92,"column":3}},"49":{"start":{"line":88,"column":4},"end":{"line":88,"column":21}},"50":{"start":{"line":89,"column":4},"end":{"line":89,"column":40}},"51":{"start":{"line":91,"column":4},"end":{"line":91,"column":34}},"52":{"start":{"line":93,"column":2},"end":{"line":93,"column":23}},"53":{"start":{"line":94,"column":2},"end":{"line":94,"column":40}},"54":{"start":{"line":96,"column":2},"end":{"line":96,"column":13}},"55":{"start":{"line":99,"column":0},"end":{"line":123,"column":2}},"56":{"start":{"line":100,"column":12},"end":{"line":100,"column":14}},"57":{"start":{"line":101,"column":19},"end":{"line":101,"column":35}},"58":{"start":{"line":102,"column":2},"end":{"line":120,"column":3}},"59":{"start":{"line":103,"column":4},"end":{"line":105,"column":5}},"60":{"start":{"line":104,"column":6},"end":{"line":104,"column":67}},"61":{"start":{"line":107,"column":13},"end":{"line":107,"column":50}},"62":{"start":{"line":108,"column":4},"end":{"line":108,"column":28}},"63":{"start":{"line":108,"column":22},"end":{"line":108,"column":28}},"64":{"start":{"line":109,"column":4},"end":{"line":119,"column":5}},"65":{"start":{"line":110,"column":6},"end":{"line":110,"column":58}},"66":{"start":{"line":111,"column":6},"end":{"line":111,"column":34}},"67":{"start":{"line":112,"column":6},"end":{"line":112,"column":34}},"68":{"start":{"line":113,"column":11},"end":{"line":119,"column":5}},"69":{"start":{"line":114,"column":6},"end":{"line":114,"column":58}},"70":{"start":{"line":115,"column":6},"end":{"line":115,"column":40}},"71":{"start":{"line":116,"column":6},"end":{"line":116,"column":34}},"72":{"start":{"line":118,"column":6},"end":{"line":118,"column":23}},"73":{"start":{"line":121,"column":2},"end":{"line":121,"column":56}},"74":{"start":{"line":122,"column":2},"end":{"line":122,"column":42}},"75":{"start":{"line":125,"column":0},"end":{"line":157,"column":2}},"76":{"start":{"line":126,"column":12},"end":{"line":126,"column":14}},"77":{"start":{"line":127,"column":14},"end":{"line":127,"column":15}},"78":{"start":{"line":129,"column":11},"end":{"line":129,"column":37}},"79":{"start":{"line":131,"column":17},"end":{"line":131,"column":33}},"80":{"start":{"line":132,"column":2},"end":{"line":151,"column":3}},"81":{"start":{"line":133,"column":4},"end":{"line":133,"column":38}},"82":{"start":{"line":134,"column":4},"end":{"line":149,"column":5}},"83":{"start":{"line":135,"column":6},"end":{"line":147,"column":7}},"84":{"start":{"line":136,"column":8},"end":{"line":144,"column":9}},"85":{"start":{"line":137,"column":10},"end":{"line":137,"column":30}},"86":{"start":{"line":138,"column":10},"end":{"line":139,"column":60}},"87":{"start":{"line":139,"column":12},"end":{"line":139,"column":60}},"88":{"start":{"line":141,"column":10},"end":{"line":141,"column":30}},"89":{"start":{"line":142,"column":10},"end":{"line":143,"column":60}},"90":{"start":{"line":143,"column":12},"end":{"line":143,"column":60}},"91":{"start":{"line":146,"column":8},"end":{"line":146,"column":36}},"92":{"start":{"line":148,"column":6},"end":{"line":148,"column":12}},"93":{"start":{"line":150,"column":4},"end":{"line":150,"column":14}},"94":{"start":{"line":152,"column":2},"end":{"line":155,"column":3}},"95":{"start":{"line":153,"column":4},"end":{"line":153,"column":30}},"96":{"start":{"line":154,"column":4},"end":{"line":154,"column":15}},"97":{"start":{"line":156,"column":2},"end":{"line":156,"column":16}},"98":{"start":{"line":167,"column":0},"end":{"line":174,"column":2}},"99":{"start":{"line":169,"column":14},"end":{"line":169,"column":28}},"100":{"start":{"line":170,"column":2},"end":{"line":172,"column":46}},"101":{"start":{"line":171,"column":4},"end":{"line":171,"column":49}},"102":{"start":{"line":173,"column":2},"end":{"line":173,"column":79}},"103":{"start":{"line":179,"column":2},"end":{"line":181,"column":3}},"104":{"start":{"line":180,"column":4},"end":{"line":180,"column":23}},"105":{"start":{"line":183,"column":2},"end":{"line":185,"column":3}},"106":{"start":{"line":184,"column":4},"end":{"line":184,"column":58}},"107":{"start":{"line":187,"column":2},"end":{"line":189,"column":3}},"108":{"start":{"line":188,"column":4},"end":{"line":188,"column":91}},"109":{"start":{"line":194,"column":0},"end":{"line":205,"column":2}},"110":{"start":{"line":195,"column":13},"end":{"line":195,"column":29}},"111":{"start":{"line":196,"column":2},"end":{"line":202,"column":3}},"112":{"start":{"line":197,"column":4},"end":{"line":197,"column":33}},"113":{"start":{"line":198,"column":9},"end":{"line":202,"column":3}},"114":{"start":{"line":199,"column":4},"end":{"line":199,"column":40}},"115":{"start":{"line":201,"column":4},"end":{"line":201,"column":22}},"116":{"start":{"line":203,"column":2},"end":{"line":203,"column":14}},"117":{"start":{"line":204,"column":2},"end":{"line":204,"column":48}},"118":{"start":{"line":209,"column":0},"end":{"line":218,"column":2}},"119":{"start":{"line":210,"column":17},"end":{"line":210,"column":33}},"120":{"start":{"line":210,"column":46},"end":{"line":210,"column":65}},"121":{"start":{"line":211,"column":13},"end":{"line":211,"column":38}},"122":{"start":{"line":212,"column":2},"end":{"line":212,"column":39}},"123":{"start":{"line":212,"column":27},"end":{"line":212,"column":39}},"124":{"start":{"line":214,"column":13},"end":{"line":214,"column":49}},"125":{"start":{"line":215,"column":2},"end":{"line":215,"column":24}},"126":{"start":{"line":216,"column":2},"end":{"line":216,"column":40}},"127":{"start":{"line":217,"column":2},"end":{"line":217,"column":52}},"128":{"start":{"line":223,"column":0},"end":{"line":233,"column":2}},"129":{"start":{"line":224,"column":17},"end":{"line":224,"column":33}},"130":{"start":{"line":224,"column":46},"end":{"line":224,"column":65}},"131":{"start":{"line":225,"column":13},"end":{"line":225,"column":42}},"132":{"start":{"line":226,"column":2},"end":{"line":231,"column":3}},"133":{"start":{"line":227,"column":18},"end":{"line":227,"column":54}},"134":{"start":{"line":228,"column":4},"end":{"line":228,"column":26}},"135":{"start":{"line":229,"column":4},"end":{"line":229,"column":49}},"136":{"start":{"line":230,"column":4},"end":{"line":230,"column":59}},"137":{"start":{"line":232,"column":2},"end":{"line":232,"column":14}},"138":{"start":{"line":237,"column":0},"end":{"line":257,"column":2}},"139":{"start":{"line":239,"column":2},"end":{"line":256,"column":3}},"140":{"start":{"line":241,"column":6},"end":{"line":241,"column":48}},"141":{"start":{"line":242,"column":6},"end":{"line":246,"column":7}},"142":{"start":{"line":243,"column":8},"end":{"line":243,"column":94}},"143":{"start":{"line":245,"column":8},"end":{"line":245,"column":20}},"144":{"start":{"line":250,"column":6},"end":{"line":250,"column":34}},"145":{"start":{"line":251,"column":6},"end":{"line":251,"column":24}},"146":{"start":{"line":252,"column":6},"end":{"line":252,"column":18}},"147":{"start":{"line":255,"column":6},"end":{"line":255,"column":100}},"148":{"start":{"line":263,"column":0},"end":{"line":266,"column":2}},"149":{"start":{"line":264,"column":13},"end":{"line":264,"column":66}},"150":{"start":{"line":265,"column":2},"end":{"line":265,"column":82}},"151":{"start":{"line":270,"column":0},"end":{"line":278,"column":2}},"152":{"start":{"line":271,"column":13},"end":{"line":271,"column":29}},"153":{"start":{"line":272,"column":2},"end":{"line":272,"column":25}},"154":{"start":{"line":273,"column":2},"end":{"line":273,"column":27}},"155":{"start":{"line":274,"column":2},"end":{"line":274,"column":43}},"156":{"start":{"line":275,"column":2},"end":{"line":275,"column":25}},"157":{"start":{"line":277,"column":2},"end":{"line":277,"column":49}},"158":{"start":{"line":283,"column":0},"end":{"line":293,"column":2}},"159":{"start":{"line":284,"column":13},"end":{"line":284,"column":29}},"160":{"start":{"line":285,"column":2},"end":{"line":285,"column":14}},"161":{"start":{"line":286,"column":2},"end":{"line":290,"column":3}},"162":{"start":{"line":287,"column":4},"end":{"line":287,"column":53}},"163":{"start":{"line":289,"column":4},"end":{"line":289,"column":45}},"164":{"start":{"line":291,"column":2},"end":{"line":291,"column":25}},"165":{"start":{"line":292,"column":2},"end":{"line":292,"column":57}},"166":{"start":{"line":297,"column":0},"end":{"line":308,"column":2}},"167":{"start":{"line":298,"column":13},"end":{"line":298,"column":29}},"168":{"start":{"line":299,"column":2},"end":{"line":304,"column":3}},"169":{"start":{"line":300,"column":4},"end":{"line":300,"column":29}},"170":{"start":{"line":301,"column":4},"end":{"line":301,"column":44}},"171":{"start":{"line":302,"column":4},"end":{"line":302,"column":27}},"172":{"start":{"line":303,"column":4},"end":{"line":303,"column":55}},"173":{"start":{"line":305,"column":2},"end":{"line":305,"column":44}},"174":{"start":{"line":306,"column":2},"end":{"line":306,"column":70}},"175":{"start":{"line":307,"column":2},"end":{"line":307,"column":47}},"176":{"start":{"line":312,"column":0},"end":{"line":322,"column":2}},"177":{"start":{"line":313,"column":13},"end":{"line":313,"column":49}},"178":{"start":{"line":314,"column":2},"end":{"line":314,"column":23}},"179":{"start":{"line":315,"column":2},"end":{"line":315,"column":41}},"180":{"start":{"line":316,"column":2},"end":{"line":318,"column":3}},"181":{"start":{"line":317,"column":4},"end":{"line":317,"column":51}},"182":{"start":{"line":319,"column":2},"end":{"line":319,"column":40}},"183":{"start":{"line":320,"column":2},"end":{"line":320,"column":28}},"184":{"start":{"line":321,"column":2},"end":{"line":321,"column":52}},"185":{"start":{"line":326,"column":0},"end":{"line":331,"column":2}},"186":{"start":{"line":327,"column":13},"end":{"line":327,"column":49}},"187":{"start":{"line":328,"column":2},"end":{"line":328,"column":41}},"188":{"start":{"line":329,"column":2},"end":{"line":329,"column":28}},"189":{"start":{"line":330,"column":2},"end":{"line":330,"column":52}},"190":{"start":{"line":336,"column":0},"end":{"line":388,"column":2}},"191":{"start":{"line":337,"column":13},"end":{"line":337,"column":49}},"192":{"start":{"line":338,"column":17},"end":{"line":338,"column":19}},"193":{"start":{"line":339,"column":23},"end":{"line":339,"column":72}},"194":{"start":{"line":340,"column":23},"end":{"line":340,"column":27}},"195":{"start":{"line":342,"column":2},"end":{"line":379,"column":3}},"196":{"start":{"line":343,"column":4},"end":{"line":371,"column":5}},"197":{"start":{"line":343,"column":14},"end":{"line":371,"column":5}},"198":{"start":{"line":344,"column":6},"end":{"line":370,"column":7}},"199":{"start":{"line":346,"column":10},"end":{"line":346,"column":38}},"200":{"start":{"line":346,"column":39},"end":{"line":346,"column":70}},"201":{"start":{"line":347,"column":10},"end":{"line":347,"column":22}},"202":{"start":{"line":348,"column":10},"end":{"line":351,"column":11}},"203":{"start":{"line":349,"column":12},"end":{"line":349,"column":79}},"204":{"start":{"line":350,"column":12},"end":{"line":350,"column":27}},"205":{"start":{"line":352,"column":10},"end":{"line":352,"column":68}},"206":{"start":{"line":353,"column":10},"end":{"line":353,"column":16}},"207":{"start":{"line":356,"column":10},"end":{"line":356,"column":46}},"208":{"start":{"line":357,"column":10},"end":{"line":357,"column":16}},"209":{"start":{"line":360,"column":10},"end":{"line":364,"column":11}},"210":{"start":{"line":361,"column":12},"end":{"line":361,"column":54}},"211":{"start":{"line":363,"column":12},"end":{"line":363,"column":62}},"212":{"start":{"line":366,"column":10},"end":{"line":366,"column":16}},"213":{"start":{"line":369,"column":10},"end":{"line":369,"column":28}},"214":{"start":{"line":373,"column":4},"end":{"line":378,"column":5}},"215":{"start":{"line":374,"column":6},"end":{"line":377,"column":8}},"216":{"start":{"line":381,"column":2},"end":{"line":381,"column":39}},"217":{"start":{"line":382,"column":2},"end":{"line":382,"column":39}},"218":{"start":{"line":383,"column":2},"end":{"line":383,"column":27}},"219":{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},"220":{"start":{"line":385,"column":4},"end":{"line":385,"column":94}},"221":{"start":{"line":387,"column":2},"end":{"line":387,"column":45}},"222":{"start":{"line":392,"column":0},"end":{"line":396,"column":2}},"223":{"start":{"line":393,"column":17},"end":{"line":393,"column":33}},"224":{"start":{"line":393,"column":46},"end":{"line":393,"column":65}},"225":{"start":{"line":394,"column":2},"end":{"line":394,"column":14}},"226":{"start":{"line":395,"column":2},"end":{"line":395,"column":52}},"227":{"start":{"line":399,"column":2},"end":{"line":412,"column":5}},"228":{"start":{"line":400,"column":4},"end":{"line":411,"column":6}},"229":{"start":{"line":401,"column":6},"end":{"line":410,"column":7}},"230":{"start":{"line":402,"column":19},"end":{"line":402,"column":65}},"231":{"start":{"line":404,"column":8},"end":{"line":404,"column":26}},"232":{"start":{"line":405,"column":8},"end":{"line":405,"column":20}},"233":{"start":{"line":406,"column":13},"end":{"line":410,"column":7}},"234":{"start":{"line":407,"column":8},"end":{"line":407,"column":38}},"235":{"start":{"line":409,"column":8},"end":{"line":409,"column":56}},"236":{"start":{"line":414,"column":2},"end":{"line":444,"column":5}},"237":{"start":{"line":415,"column":4},"end":{"line":443,"column":6}},"238":{"start":{"line":416,"column":20},"end":{"line":416,"column":37}},"239":{"start":{"line":418,"column":6},"end":{"line":420,"column":7}},"240":{"start":{"line":419,"column":8},"end":{"line":419,"column":35}},"241":{"start":{"line":422,"column":6},"end":{"line":435,"column":7}},"242":{"start":{"line":423,"column":8},"end":{"line":425,"column":9}},"243":{"start":{"line":424,"column":10},"end":{"line":424,"column":36}},"244":{"start":{"line":427,"column":8},"end":{"line":430,"column":9}},"245":{"start":{"line":428,"column":10},"end":{"line":428,"column":27}},"246":{"start":{"line":429,"column":10},"end":{"line":429,"column":48}},"247":{"start":{"line":432,"column":8},"end":{"line":434,"column":9}},"248":{"start":{"line":433,"column":10},"end":{"line":433,"column":42}},"249":{"start":{"line":437,"column":6},"end":{"line":440,"column":7}},"250":{"start":{"line":438,"column":8},"end":{"line":438,"column":25}},"251":{"start":{"line":439,"column":8},"end":{"line":439,"column":48}},"252":{"start":{"line":442,"column":6},"end":{"line":442,"column":36}},"253":{"start":{"line":446,"column":2},"end":{"line":466,"column":5}},"254":{"start":{"line":447,"column":4},"end":{"line":465,"column":6}},"255":{"start":{"line":448,"column":6},"end":{"line":464,"column":7}},"256":{"start":{"line":449,"column":25},"end":{"line":449,"column":42}},"257":{"start":{"line":450,"column":8},"end":{"line":456,"column":9}},"258":{"start":{"line":451,"column":10},"end":{"line":451,"column":54}},"259":{"start":{"line":452,"column":15},"end":{"line":456,"column":9}},"260":{"start":{"line":453,"column":10},"end":{"line":453,"column":52}},"261":{"start":{"line":455,"column":10},"end":{"line":455,"column":37}},"262":{"start":{"line":457,"column":8},"end":{"line":457,"column":38}},"263":{"start":{"line":458,"column":13},"end":{"line":464,"column":7}},"264":{"start":{"line":459,"column":8},"end":{"line":459,"column":39}},"265":{"start":{"line":460,"column":8},"end":{"line":460,"column":43}},"266":{"start":{"line":461,"column":8},"end":{"line":461,"column":39}},"267":{"start":{"line":463,"column":8},"end":{"line":463,"column":42}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":22,"column":31},"end":{"line":22,"column":32}},"loc":{"start":{"line":22,"column":42},"end":{"line":26,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":28,"column":29},"end":{"line":28,"column":30}},"loc":{"start":{"line":28,"column":48},"end":{"line":36,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":42,"column":18},"end":{"line":42,"column":19}},"loc":{"start":{"line":42,"column":29},"end":{"line":81,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":83,"column":20},"end":{"line":83,"column":21}},"loc":{"start":{"line":83,"column":44},"end":{"line":97,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":99,"column":19},"end":{"line":99,"column":20}},"loc":{"start":{"line":99,"column":35},"end":{"line":123,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":125,"column":19},"end":{"line":125,"column":20}},"loc":{"start":{"line":125,"column":30},"end":{"line":157,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":167,"column":17},"end":{"line":167,"column":18}},"loc":{"start":{"line":167,"column":28},"end":{"line":174,"column":1}}},"8":{"name":"getQualifiedJSXName","decl":{"start":{"line":178,"column":9},"end":{"line":178,"column":28}},"loc":{"start":{"line":178,"column":37},"end":{"line":190,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":194,"column":24},"end":{"line":194,"column":25}},"loc":{"start":{"line":194,"column":35},"end":{"line":205,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":209,"column":28},"end":{"line":209,"column":29}},"loc":{"start":{"line":209,"column":39},"end":{"line":218,"column":1}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":223,"column":25},"end":{"line":223,"column":26}},"loc":{"start":{"line":223,"column":36},"end":{"line":233,"column":1}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":237,"column":28},"end":{"line":237,"column":29}},"loc":{"start":{"line":237,"column":39},"end":{"line":257,"column":1}}},"13":{"name":"(anonymous_13)","decl":{"start":{"line":263,"column":29},"end":{"line":263,"column":30}},"loc":{"start":{"line":263,"column":40},"end":{"line":266,"column":1}}},"14":{"name":"(anonymous_14)","decl":{"start":{"line":270,"column":25},"end":{"line":270,"column":26}},"loc":{"start":{"line":270,"column":36},"end":{"line":278,"column":1}}},"15":{"name":"(anonymous_15)","decl":{"start":{"line":283,"column":33},"end":{"line":283,"column":34}},"loc":{"start":{"line":283,"column":44},"end":{"line":293,"column":1}}},"16":{"name":"(anonymous_16)","decl":{"start":{"line":297,"column":23},"end":{"line":297,"column":24}},"loc":{"start":{"line":297,"column":34},"end":{"line":308,"column":1}}},"17":{"name":"(anonymous_17)","decl":{"start":{"line":312,"column":30},"end":{"line":312,"column":31}},"loc":{"start":{"line":312,"column":59},"end":{"line":322,"column":1}}},"18":{"name":"(anonymous_18)","decl":{"start":{"line":326,"column":30},"end":{"line":326,"column":31}},"loc":{"start":{"line":326,"column":59},"end":{"line":331,"column":1}}},"19":{"name":"(anonymous_19)","decl":{"start":{"line":336,"column":23},"end":{"line":336,"column":24}},"loc":{"start":{"line":336,"column":52},"end":{"line":388,"column":1}}},"20":{"name":"(anonymous_20)","decl":{"start":{"line":392,"column":21},"end":{"line":392,"column":22}},"loc":{"start":{"line":392,"column":32},"end":{"line":396,"column":1}}},"21":{"name":"(anonymous_21)","decl":{"start":{"line":398,"column":15},"end":{"line":398,"column":16}},"loc":{"start":{"line":398,"column":34},"end":{"line":467,"column":1}}},"22":{"name":"(anonymous_22)","decl":{"start":{"line":399,"column":35},"end":{"line":399,"column":36}},"loc":{"start":{"line":399,"column":51},"end":{"line":412,"column":3}}},"23":{"name":"(anonymous_23)","decl":{"start":{"line":400,"column":11},"end":{"line":400,"column":12}},"loc":{"start":{"line":400,"column":44},"end":{"line":411,"column":5}}},"24":{"name":"(anonymous_24)","decl":{"start":{"line":414,"column":31},"end":{"line":414,"column":32}},"loc":{"start":{"line":414,"column":47},"end":{"line":444,"column":3}}},"25":{"name":"(anonymous_25)","decl":{"start":{"line":415,"column":11},"end":{"line":415,"column":12}},"loc":{"start":{"line":415,"column":26},"end":{"line":443,"column":5}}},"26":{"name":"(anonymous_26)","decl":{"start":{"line":446,"column":35},"end":{"line":446,"column":36}},"loc":{"start":{"line":446,"column":51},"end":{"line":466,"column":3}}},"27":{"name":"(anonymous_27)","decl":{"start":{"line":447,"column":11},"end":{"line":447,"column":12}},"loc":{"start":{"line":447,"column":30},"end":{"line":465,"column":5}}}},"branchMap":{"1":{"loc":{"start":{"line":30,"column":2},"end":{"line":35,"column":3}},"type":"if","locations":[{"start":{"line":30,"column":2},"end":{"line":35,"column":3}},{"start":{"line":30,"column":2},"end":{"line":35,"column":3}}]},"2":{"loc":{"start":{"line":30,"column":6},"end":{"line":30,"column":69}},"type":"binary-expr","locations":[{"start":{"line":30,"column":6},"end":{"line":30,"column":23}},{"start":{"line":30,"column":27},"end":{"line":30,"column":48}},{"start":{"line":30,"column":52},"end":{"line":30,"column":69}}]},"3":{"loc":{"start":{"line":46,"column":4},"end":{"line":48,"column":5}},"type":"if","locations":[{"start":{"line":46,"column":4},"end":{"line":48,"column":5}},{"start":{"line":46,"column":4},"end":{"line":48,"column":5}}]},"4":{"loc":{"start":{"line":52,"column":4},"end":{"line":79,"column":5}},"type":"switch","locations":[{"start":{"line":53,"column":6},"end":{"line":53,"column":14}},{"start":{"line":54,"column":6},"end":{"line":63,"column":49}},{"start":{"line":65,"column":6},"end":{"line":69,"column":14}},{"start":{"line":71,"column":6},"end":{"line":78,"column":9}}]},"5":{"loc":{"start":{"line":55,"column":8},"end":{"line":61,"column":9}},"type":"if","locations":[{"start":{"line":55,"column":8},"end":{"line":61,"column":9}},{"start":{"line":55,"column":8},"end":{"line":61,"column":9}}]},"6":{"loc":{"start":{"line":56,"column":10},"end":{"line":59,"column":11}},"type":"if","locations":[{"start":{"line":56,"column":10},"end":{"line":59,"column":11}},{"start":{"line":56,"column":10},"end":{"line":59,"column":11}}]},"7":{"loc":{"start":{"line":56,"column":14},"end":{"line":56,"column":49}},"type":"binary-expr","locations":[{"start":{"line":56,"column":14},"end":{"line":56,"column":23}},{"start":{"line":56,"column":27},"end":{"line":56,"column":49}}]},"8":{"loc":{"start":{"line":72,"column":8},"end":{"line":78,"column":9}},"type":"if","locations":[{"start":{"line":72,"column":8},"end":{"line":78,"column":9}},{"start":{"line":72,"column":8},"end":{"line":78,"column":9}}]},"9":{"loc":{"start":{"line":87,"column":2},"end":{"line":92,"column":3}},"type":"if","locations":[{"start":{"line":87,"column":2},"end":{"line":92,"column":3}},{"start":{"line":87,"column":2},"end":{"line":92,"column":3}}]},"10":{"loc":{"start":{"line":87,"column":6},"end":{"line":87,"column":63}},"type":"binary-expr","locations":[{"start":{"line":87,"column":6},"end":{"line":87,"column":15}},{"start":{"line":87,"column":19},"end":{"line":87,"column":63}}]},"11":{"loc":{"start":{"line":89,"column":10},"end":{"line":89,"column":39}},"type":"cond-expr","locations":[{"start":{"line":89,"column":26},"end":{"line":89,"column":30}},{"start":{"line":89,"column":33},"end":{"line":89,"column":39}}]},"12":{"loc":{"start":{"line":103,"column":4},"end":{"line":105,"column":5}},"type":"if","locations":[{"start":{"line":103,"column":4},"end":{"line":105,"column":5}},{"start":{"line":103,"column":4},"end":{"line":105,"column":5}}]},"13":{"loc":{"start":{"line":108,"column":4},"end":{"line":108,"column":28}},"type":"if","locations":[{"start":{"line":108,"column":4},"end":{"line":108,"column":28}},{"start":{"line":108,"column":4},"end":{"line":108,"column":28}}]},"14":{"loc":{"start":{"line":109,"column":4},"end":{"line":119,"column":5}},"type":"if","locations":[{"start":{"line":109,"column":4},"end":{"line":119,"column":5}},{"start":{"line":109,"column":4},"end":{"line":119,"column":5}}]},"15":{"loc":{"start":{"line":113,"column":11},"end":{"line":119,"column":5}},"type":"if","locations":[{"start":{"line":113,"column":11},"end":{"line":119,"column":5}},{"start":{"line":113,"column":11},"end":{"line":119,"column":5}}]},"16":{"loc":{"start":{"line":132,"column":9},"end":{"line":132,"column":59}},"type":"binary-expr","locations":[{"start":{"line":132,"column":9},"end":{"line":132,"column":43}},{"start":{"line":132,"column":47},"end":{"line":132,"column":59}}]},"17":{"loc":{"start":{"line":134,"column":4},"end":{"line":149,"column":5}},"type":"if","locations":[{"start":{"line":134,"column":4},"end":{"line":149,"column":5}},{"start":{"line":134,"column":4},"end":{"line":149,"column":5}}]},"18":{"loc":{"start":{"line":135,"column":6},"end":{"line":147,"column":7}},"type":"if","locations":[{"start":{"line":135,"column":6},"end":{"line":147,"column":7}},{"start":{"line":135,"column":6},"end":{"line":147,"column":7}}]},"19":{"loc":{"start":{"line":136,"column":8},"end":{"line":144,"column":9}},"type":"if","locations":[{"start":{"line":136,"column":8},"end":{"line":144,"column":9}},{"start":{"line":136,"column":8},"end":{"line":144,"column":9}}]},"20":{"loc":{"start":{"line":138,"column":10},"end":{"line":139,"column":60}},"type":"if","locations":[{"start":{"line":138,"column":10},"end":{"line":139,"column":60}},{"start":{"line":138,"column":10},"end":{"line":139,"column":60}}]},"21":{"loc":{"start":{"line":142,"column":10},"end":{"line":143,"column":60}},"type":"if","locations":[{"start":{"line":142,"column":10},"end":{"line":143,"column":60}},{"start":{"line":142,"column":10},"end":{"line":143,"column":60}}]},"22":{"loc":{"start":{"line":152,"column":2},"end":{"line":155,"column":3}},"type":"if","locations":[{"start":{"line":152,"column":2},"end":{"line":155,"column":3}},{"start":{"line":152,"column":2},"end":{"line":155,"column":3}}]},"23":{"loc":{"start":{"line":172,"column":11},"end":{"line":172,"column":44}},"type":"binary-expr","locations":[{"start":{"line":172,"column":11},"end":{"line":172,"column":31}},{"start":{"line":172,"column":35},"end":{"line":172,"column":44}}]},"24":{"loc":{"start":{"line":179,"column":2},"end":{"line":181,"column":3}},"type":"if","locations":[{"start":{"line":179,"column":2},"end":{"line":181,"column":3}},{"start":{"line":179,"column":2},"end":{"line":181,"column":3}}]},"25":{"loc":{"start":{"line":183,"column":2},"end":{"line":185,"column":3}},"type":"if","locations":[{"start":{"line":183,"column":2},"end":{"line":185,"column":3}},{"start":{"line":183,"column":2},"end":{"line":185,"column":3}}]},"26":{"loc":{"start":{"line":187,"column":2},"end":{"line":189,"column":3}},"type":"if","locations":[{"start":{"line":187,"column":2},"end":{"line":189,"column":3}},{"start":{"line":187,"column":2},"end":{"line":189,"column":3}}]},"27":{"loc":{"start":{"line":196,"column":2},"end":{"line":202,"column":3}},"type":"if","locations":[{"start":{"line":196,"column":2},"end":{"line":202,"column":3}},{"start":{"line":196,"column":2},"end":{"line":202,"column":3}}]},"28":{"loc":{"start":{"line":198,"column":9},"end":{"line":202,"column":3}},"type":"if","locations":[{"start":{"line":198,"column":9},"end":{"line":202,"column":3}},{"start":{"line":198,"column":9},"end":{"line":202,"column":3}}]},"29":{"loc":{"start":{"line":212,"column":2},"end":{"line":212,"column":39}},"type":"if","locations":[{"start":{"line":212,"column":2},"end":{"line":212,"column":39}},{"start":{"line":212,"column":2},"end":{"line":212,"column":39}}]},"30":{"loc":{"start":{"line":239,"column":2},"end":{"line":256,"column":3}},"type":"switch","locations":[{"start":{"line":240,"column":4},"end":{"line":246,"column":7}},{"start":{"line":248,"column":4},"end":{"line":248,"column":24}},{"start":{"line":249,"column":4},"end":{"line":252,"column":18}},{"start":{"line":254,"column":4},"end":{"line":255,"column":100}}]},"31":{"loc":{"start":{"line":242,"column":6},"end":{"line":246,"column":7}},"type":"if","locations":[{"start":{"line":242,"column":6},"end":{"line":246,"column":7}},{"start":{"line":242,"column":6},"end":{"line":246,"column":7}}]},"32":{"loc":{"start":{"line":286,"column":2},"end":{"line":290,"column":3}},"type":"if","locations":[{"start":{"line":286,"column":2},"end":{"line":290,"column":3}},{"start":{"line":286,"column":2},"end":{"line":290,"column":3}}]},"33":{"loc":{"start":{"line":299,"column":2},"end":{"line":304,"column":3}},"type":"if","locations":[{"start":{"line":299,"column":2},"end":{"line":304,"column":3}},{"start":{"line":299,"column":2},"end":{"line":304,"column":3}}]},"34":{"loc":{"start":{"line":306,"column":15},"end":{"line":306,"column":69}},"type":"cond-expr","locations":[{"start":{"line":306,"column":33},"end":{"line":306,"column":62}},{"start":{"line":306,"column":65},"end":{"line":306,"column":69}}]},"35":{"loc":{"start":{"line":316,"column":9},"end":{"line":316,"column":59}},"type":"binary-expr","locations":[{"start":{"line":316,"column":9},"end":{"line":316,"column":30}},{"start":{"line":316,"column":34},"end":{"line":316,"column":59}}]},"36":{"loc":{"start":{"line":342,"column":2},"end":{"line":379,"column":3}},"type":"if","locations":[{"start":{"line":342,"column":2},"end":{"line":379,"column":3}},{"start":{"line":342,"column":2},"end":{"line":379,"column":3}}]},"37":{"loc":{"start":{"line":344,"column":6},"end":{"line":370,"column":7}},"type":"switch","locations":[{"start":{"line":345,"column":8},"end":{"line":353,"column":16}},{"start":{"line":355,"column":8},"end":{"line":357,"column":16}},{"start":{"line":359,"column":8},"end":{"line":366,"column":16}},{"start":{"line":368,"column":8},"end":{"line":369,"column":28}}]},"38":{"loc":{"start":{"line":348,"column":10},"end":{"line":351,"column":11}},"type":"if","locations":[{"start":{"line":348,"column":10},"end":{"line":351,"column":11}},{"start":{"line":348,"column":10},"end":{"line":351,"column":11}}]},"39":{"loc":{"start":{"line":360,"column":10},"end":{"line":364,"column":11}},"type":"if","locations":[{"start":{"line":360,"column":10},"end":{"line":364,"column":11}},{"start":{"line":360,"column":10},"end":{"line":364,"column":11}}]},"40":{"loc":{"start":{"line":373,"column":4},"end":{"line":378,"column":5}},"type":"if","locations":[{"start":{"line":373,"column":4},"end":{"line":378,"column":5}},{"start":{"line":373,"column":4},"end":{"line":378,"column":5}}]},"41":{"loc":{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},"type":"if","locations":[{"start":{"line":384,"column":2},"end":{"line":386,"column":3}},{"start":{"line":384,"column":2},"end":{"line":386,"column":3}}]},"42":{"loc":{"start":{"line":384,"column":6},"end":{"line":384,"column":59}},"type":"binary-expr","locations":[{"start":{"line":384,"column":6},"end":{"line":384,"column":31}},{"start":{"line":384,"column":35},"end":{"line":384,"column":59}}]},"43":{"loc":{"start":{"line":401,"column":6},"end":{"line":410,"column":7}},"type":"if","locations":[{"start":{"line":401,"column":6},"end":{"line":410,"column":7}},{"start":{"line":401,"column":6},"end":{"line":410,"column":7}}]},"44":{"loc":{"start":{"line":406,"column":13},"end":{"line":410,"column":7}},"type":"if","locations":[{"start":{"line":406,"column":13},"end":{"line":410,"column":7}},{"start":{"line":406,"column":13},"end":{"line":410,"column":7}}]},"45":{"loc":{"start":{"line":418,"column":6},"end":{"line":420,"column":7}},"type":"if","locations":[{"start":{"line":418,"column":6},"end":{"line":420,"column":7}},{"start":{"line":418,"column":6},"end":{"line":420,"column":7}}]},"46":{"loc":{"start":{"line":422,"column":6},"end":{"line":435,"column":7}},"type":"if","locations":[{"start":{"line":422,"column":6},"end":{"line":435,"column":7}},{"start":{"line":422,"column":6},"end":{"line":435,"column":7}}]},"47":{"loc":{"start":{"line":422,"column":10},"end":{"line":422,"column":56}},"type":"binary-expr","locations":[{"start":{"line":422,"column":10},"end":{"line":422,"column":31}},{"start":{"line":422,"column":35},"end":{"line":422,"column":56}}]},"48":{"loc":{"start":{"line":423,"column":8},"end":{"line":425,"column":9}},"type":"if","locations":[{"start":{"line":423,"column":8},"end":{"line":425,"column":9}},{"start":{"line":423,"column":8},"end":{"line":425,"column":9}}]},"49":{"loc":{"start":{"line":427,"column":8},"end":{"line":430,"column":9}},"type":"if","locations":[{"start":{"line":427,"column":8},"end":{"line":430,"column":9}},{"start":{"line":427,"column":8},"end":{"line":430,"column":9}}]},"50":{"loc":{"start":{"line":432,"column":8},"end":{"line":434,"column":9}},"type":"if","locations":[{"start":{"line":432,"column":8},"end":{"line":434,"column":9}},{"start":{"line":432,"column":8},"end":{"line":434,"column":9}}]},"51":{"loc":{"start":{"line":432,"column":12},"end":{"line":432,"column":65}},"type":"binary-expr","locations":[{"start":{"line":432,"column":13},"end":{"line":432,"column":24}},{"start":{"line":432,"column":28},"end":{"line":432,"column":39}},{"start":{"line":432,"column":44},"end":{"line":432,"column":65}}]},"52":{"loc":{"start":{"line":437,"column":6},"end":{"line":440,"column":7}},"type":"if","locations":[{"start":{"line":437,"column":6},"end":{"line":440,"column":7}},{"start":{"line":437,"column":6},"end":{"line":440,"column":7}}]},"53":{"loc":{"start":{"line":437,"column":10},"end":{"line":437,"column":47}},"type":"binary-expr","locations":[{"start":{"line":437,"column":10},"end":{"line":437,"column":21}},{"start":{"line":437,"column":25},"end":{"line":437,"column":47}}]},"54":{"loc":{"start":{"line":448,"column":6},"end":{"line":464,"column":7}},"type":"if","locations":[{"start":{"line":448,"column":6},"end":{"line":464,"column":7}},{"start":{"line":448,"column":6},"end":{"line":464,"column":7}}]},"55":{"loc":{"start":{"line":450,"column":8},"end":{"line":456,"column":9}},"type":"if","locations":[{"start":{"line":450,"column":8},"end":{"line":456,"column":9}},{"start":{"line":450,"column":8},"end":{"line":456,"column":9}}]},"56":{"loc":{"start":{"line":452,"column":15},"end":{"line":456,"column":9}},"type":"if","locations":[{"start":{"line":452,"column":15},"end":{"line":456,"column":9}},{"start":{"line":452,"column":15},"end":{"line":456,"column":9}}]},"57":{"loc":{"start":{"line":458,"column":13},"end":{"line":464,"column":7}},"type":"if","locations":[{"start":{"line":458,"column":13},"end":{"line":464,"column":7}},{"start":{"line":458,"column":13},"end":{"line":464,"column":7}}]},"58":{"loc":{"start":{"line":458,"column":17},"end":{"line":458,"column":68}},"type":"binary-expr","locations":[{"start":{"line":458,"column":17},"end":{"line":458,"column":37}},{"start":{"line":458,"column":41},"end":{"line":458,"column":68}}]}},"s":{"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":1,"11":91,"12":91,"13":91,"14":1,"15":87,"16":87,"17":48,"18":48,"19":39,"20":1,"21":1,"22":81,"23":81,"24":81,"25":358,"26":6,"27":352,"28":352,"29":75,"30":53,"31":38,"32":38,"33":15,"34":22,"35":22,"36":0,"37":0,"38":0,"39":0,"40":277,"41":9,"42":9,"43":9,"44":268,"45":1,"46":11,"47":11,"48":11,"49":0,"50":0,"51":11,"52":11,"53":11,"54":11,"55":1,"56":13,"57":13,"58":13,"59":140,"60":1,"61":139,"62":139,"63":12,"64":127,"65":4,"66":4,"67":4,"68":123,"69":2,"70":2,"71":2,"72":121,"73":12,"74":12,"75":1,"76":4,"77":4,"78":4,"79":4,"80":4,"81":20,"82":20,"83":4,"84":2,"85":1,"86":1,"87":1,"88":1,"89":1,"90":1,"91":2,"92":4,"93":16,"94":4,"95":1,"96":1,"97":3,"98":1,"99":125,"100":125,"101":282,"102":125,"103":67,"104":59,"105":8,"106":2,"107":6,"108":6,"109":1,"110":124,"111":124,"112":124,"113":0,"114":0,"115":0,"116":124,"117":124,"118":1,"119":112,"120":112,"121":112,"122":112,"123":106,"124":6,"125":6,"126":6,"127":6,"128":1,"129":90,"130":90,"131":90,"132":90,"133":6,"134":6,"135":6,"136":6,"137":90,"138":1,"139":20,"140":5,"141":5,"142":1,"143":4,"144":14,"145":14,"146":14,"147":1,"148":1,"149":3,"150":3,"151":1,"152":2,"153":2,"154":2,"155":2,"156":2,"157":2,"158":1,"159":18,"160":18,"161":18,"162":3,"163":15,"164":18,"165":14,"166":1,"167":28,"168":28,"169":6,"170":6,"171":6,"172":6,"173":22,"174":22,"175":19,"176":1,"177":63,"178":63,"179":63,"180":63,"181":28,"182":60,"183":60,"184":58,"185":1,"186":27,"187":27,"188":27,"189":27,"190":1,"191":63,"192":63,"193":63,"194":58,"195":58,"196":37,"197":37,"198":75,"199":38,"200":38,"201":38,"202":38,"203":27,"204":27,"205":11,"206":5,"207":22,"208":22,"209":15,"210":2,"211":13,"212":11,"213":0,"214":27,"215":1,"216":47,"217":47,"218":47,"219":47,"220":1,"221":46,"222":1,"223":52,"224":52,"225":52,"226":52,"227":251,"228":251,"229":385,"230":22,"231":22,"232":22,"233":363,"234":52,"235":311,"236":251,"237":251,"238":3351,"239":3351,"240":81,"241":3270,"242":320,"243":125,"244":195,"245":87,"246":87,"247":108,"248":13,"249":3045,"250":53,"251":53,"252":2992,"253":251,"254":251,"255":3939,"256":212,"257":212,"258":11,"259":201,"260":15,"261":186,"262":212,"263":3727,"264":27,"265":27,"266":27,"267":3700},"f":{"1":91,"2":87,"3":81,"4":11,"5":13,"6":4,"7":125,"8":67,"9":124,"10":112,"11":90,"12":20,"13":3,"14":2,"15":18,"16":28,"17":63,"18":27,"19":63,"20":52,"21":251,"22":251,"23":385,"24":251,"25":3351,"26":251,"27":3939},"b":{"1":[48,39],"2":[87,60,66],"3":[6,352],"4":[54,75,0,277],"5":[53,22],"6":[38,15],"7":[53,38],"8":[9,268],"9":[0,11],"10":[11,0],"11":[0,0],"12":[1,139],"13":[12,127],"14":[4,123],"15":[2,121],"16":[20,20],"17":[4,16],"18":[2,2],"19":[1,1],"20":[1,0],"21":[1,0],"22":[1,3],"23":[282,127],"24":[59,8],"25":[2,6],"26":[6,0],"27":[124,0],"28":[0,0],"29":[106,6],"30":[5,2,14,1],"31":[1,4],"32":[3,15],"33":[6,22],"34":[20,1],"35":[88,67],"36":[37,21],"37":[38,22,15,0],"38":[27,11],"39":[2,13],"40":[1,26],"41":[1,46],"42":[47,1],"43":[22,363],"44":[52,311],"45":[81,3270],"46":[320,2950],"47":[3270,3012],"48":[125,195],"49":[87,108],"50":[13,95],"51":[108,95,13],"52":[53,2992],"53":[3045,119],"54":[212,3727],"55":[11,201],"56":[15,186],"57":[27,3700],"58":[3727,50]},"hash":"fd1e066a07dafa095075a6bcf6df9afd51080411"}
+,"/home/travis/build/babel/babylon/src/plugins/jsx/xhtml.js": {"path":"/home/travis/build/babel/babylon/src/plugins/jsx/xhtml.js","statementMap":{},"fnMap":{},"branchMap":{},"s":{},"f":{},"b":{},"hash":"1a4112b01cbc70550503fc26b73988e476d3cc4b"}
+,"/home/travis/build/babel/babylon/src/tokenizer/context.js": {"path":"/home/travis/build/babel/babylon/src/tokenizer/context.js","statementMap":{"1":{"start":{"line":15,"column":4},"end":{"line":15,"column":23}},"2":{"start":{"line":16,"column":4},"end":{"line":16,"column":27}},"3":{"start":{"line":17,"column":4},"end":{"line":17,"column":41}},"4":{"start":{"line":18,"column":4},"end":{"line":18,"column":29}},"5":{"start":{"line":29,"column":4},"end":{"line":37,"column":1}},"6":{"start":{"line":35,"column":51},"end":{"line":35,"column":68}},"7":{"start":{"line":41,"column":0},"end":{"line":56,"column":2}},"8":{"start":{"line":42,"column":2},"end":{"line":45,"column":3}},"9":{"start":{"line":43,"column":4},"end":{"line":43,"column":34}},"10":{"start":{"line":44,"column":4},"end":{"line":44,"column":11}},"11":{"start":{"line":47,"column":12},"end":{"line":47,"column":36}},"12":{"start":{"line":48,"column":2},"end":{"line":55,"column":3}},"13":{"start":{"line":49,"column":4},"end":{"line":49,"column":29}},"14":{"start":{"line":50,"column":4},"end":{"line":50,"column":35}},"15":{"start":{"line":51,"column":9},"end":{"line":55,"column":3}},"16":{"start":{"line":52,"column":4},"end":{"line":52,"column":34}},"17":{"start":{"line":54,"column":4},"end":{"line":54,"column":41}},"18":{"start":{"line":58,"column":0},"end":{"line":66,"column":2}},"19":{"start":{"line":59,"column":2},"end":{"line":59,"column":33}},"20":{"start":{"line":61,"column":2},"end":{"line":65,"column":3}},"21":{"start":{"line":62,"column":4},"end":{"line":64,"column":5}},"22":{"start":{"line":63,"column":6},"end":{"line":63,"column":36}},"23":{"start":{"line":68,"column":0},"end":{"line":71,"column":2}},"24":{"start":{"line":69,"column":2},"end":{"line":69,"column":102}},"25":{"start":{"line":70,"column":2},"end":{"line":70,"column":32}},"26":{"start":{"line":73,"column":0},"end":{"line":76,"column":2}},"27":{"start":{"line":74,"column":2},"end":{"line":74,"column":47}},"28":{"start":{"line":75,"column":2},"end":{"line":75,"column":32}},"29":{"start":{"line":78,"column":0},"end":{"line":83,"column":2}},"30":{"start":{"line":79,"column":24},"end":{"line":80,"column":71}},"31":{"start":{"line":81,"column":2},"end":{"line":81,"column":90}},"32":{"start":{"line":82,"column":2},"end":{"line":82,"column":32}},"33":{"start":{"line":85,"column":0},"end":{"line":87,"column":2}},"34":{"start":{"line":89,"column":0},"end":{"line":95,"column":2}},"35":{"start":{"line":90,"column":2},"end":{"line":92,"column":3}},"36":{"start":{"line":91,"column":4},"end":{"line":91,"column":54}},"37":{"start":{"line":94,"column":2},"end":{"line":94,"column":33}},"38":{"start":{"line":97,"column":0},"end":{"line":104,"column":2}},"39":{"start":{"line":98,"column":2},"end":{"line":102,"column":3}},"40":{"start":{"line":99,"column":4},"end":{"line":99,"column":29}},"41":{"start":{"line":101,"column":4},"end":{"line":101,"column":44}},"42":{"start":{"line":103,"column":2},"end":{"line":103,"column":33}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":9,"column":2},"end":{"line":9,"column":3}},"loc":{"start":{"line":14,"column":4},"end":{"line":19,"column":3}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":35,"column":44},"end":{"line":35,"column":45}},"loc":{"start":{"line":35,"column":51},"end":{"line":35,"column":68}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":41,"column":52},"end":{"line":41,"column":53}},"loc":{"start":{"line":41,"column":64},"end":{"line":56,"column":1}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":58,"column":24},"end":{"line":58,"column":25}},"loc":{"start":{"line":58,"column":44},"end":{"line":66,"column":1}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":68,"column":26},"end":{"line":68,"column":27}},"loc":{"start":{"line":68,"column":46},"end":{"line":71,"column":1}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":73,"column":32},"end":{"line":73,"column":33}},"loc":{"start":{"line":73,"column":44},"end":{"line":76,"column":1}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":78,"column":26},"end":{"line":78,"column":27}},"loc":{"start":{"line":78,"column":46},"end":{"line":83,"column":1}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":85,"column":26},"end":{"line":85,"column":27}},"loc":{"start":{"line":85,"column":38},"end":{"line":87,"column":1}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":89,"column":29},"end":{"line":89,"column":30}},"loc":{"start":{"line":89,"column":41},"end":{"line":95,"column":1}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":97,"column":29},"end":{"line":97,"column":30}},"loc":{"start":{"line":97,"column":41},"end":{"line":104,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":42,"column":2},"end":{"line":45,"column":3}},"type":"if","locations":[{"start":{"line":42,"column":2},"end":{"line":45,"column":3}},{"start":{"line":42,"column":2},"end":{"line":45,"column":3}}]},"2":{"loc":{"start":{"line":48,"column":2},"end":{"line":55,"column":3}},"type":"if","locations":[{"start":{"line":48,"column":2},"end":{"line":55,"column":3}},{"start":{"line":48,"column":2},"end":{"line":55,"column":3}}]},"3":{"loc":{"start":{"line":48,"column":6},"end":{"line":48,"column":84}},"type":"binary-expr","locations":[{"start":{"line":48,"column":6},"end":{"line":48,"column":34}},{"start":{"line":48,"column":38},"end":{"line":48,"column":84}}]},"4":{"loc":{"start":{"line":51,"column":9},"end":{"line":55,"column":3}},"type":"if","locations":[{"start":{"line":51,"column":9},"end":{"line":55,"column":3}},{"start":{"line":51,"column":9},"end":{"line":55,"column":3}}]},"5":{"loc":{"start":{"line":61,"column":2},"end":{"line":65,"column":3}},"type":"if","locations":[{"start":{"line":61,"column":2},"end":{"line":65,"column":3}},{"start":{"line":61,"column":2},"end":{"line":65,"column":3}}]},"6":{"loc":{"start":{"line":61,"column":6},"end":{"line":61,"column":76}},"type":"binary-expr","locations":[{"start":{"line":61,"column":6},"end":{"line":61,"column":26}},{"start":{"line":61,"column":30},"end":{"line":61,"column":52}},{"start":{"line":61,"column":56},"end":{"line":61,"column":76}}]},"7":{"loc":{"start":{"line":62,"column":4},"end":{"line":64,"column":5}},"type":"if","locations":[{"start":{"line":62,"column":4},"end":{"line":64,"column":5}},{"start":{"line":62,"column":4},"end":{"line":64,"column":5}}]},"8":{"loc":{"start":{"line":69,"column":26},"end":{"line":69,"column":100}},"type":"cond-expr","locations":[{"start":{"line":69,"column":56},"end":{"line":69,"column":76}},{"start":{"line":69,"column":79},"end":{"line":69,"column":100}}]},"9":{"loc":{"start":{"line":79,"column":24},"end":{"line":80,"column":71}},"type":"binary-expr","locations":[{"start":{"line":79,"column":24},"end":{"line":79,"column":43}},{"start":{"line":79,"column":47},"end":{"line":79,"column":67}},{"start":{"line":80,"column":24},"end":{"line":80,"column":45}},{"start":{"line":80,"column":49},"end":{"line":80,"column":71}}]},"10":{"loc":{"start":{"line":81,"column":26},"end":{"line":81,"column":88}},"type":"cond-expr","locations":[{"start":{"line":81,"column":44},"end":{"line":81,"column":64}},{"start":{"line":81,"column":67},"end":{"line":81,"column":88}}]},"11":{"loc":{"start":{"line":90,"column":2},"end":{"line":92,"column":3}},"type":"if","locations":[{"start":{"line":90,"column":2},"end":{"line":92,"column":3}},{"start":{"line":90,"column":2},"end":{"line":92,"column":3}}]},"12":{"loc":{"start":{"line":98,"column":2},"end":{"line":102,"column":3}},"type":"if","locations":[{"start":{"line":98,"column":2},"end":{"line":102,"column":3}},{"start":{"line":98,"column":2},"end":{"line":102,"column":3}}]}},"s":{"1":10,"2":10,"3":10,"4":10,"5":1,"6":87,"7":1,"8":2509,"9":4,"10":4,"11":2505,"12":2505,"13":83,"14":83,"15":2422,"16":30,"17":2392,"18":1,"19":3962,"20":3962,"21":284,"22":20,"23":1,"24":1353,"25":1353,"26":1,"27":17,"28":17,"29":1,"30":1488,"31":1488,"32":1488,"33":1,"34":1,"35":441,"36":98,"37":441,"38":1,"39":59,"40":24,"41":35,"42":59},"f":{"1":10,"2":87,"3":2509,"4":3962,"5":1353,"6":17,"7":1488,"8":67,"9":441,"10":59},"b":{"1":[4,2505],"2":[83,2422],"3":[2505,831],"4":[30,2392],"5":[284,3678],"6":[3962,3925,3895],"7":[20,264],"8":[996,357],"9":[1488,1461,1387,1375],"10":[179,1309],"11":[98,343],"12":[24,35]},"hash":"3082b707a208ee9f1e9021f7847025ae0fd9e6e5"}
+,"/home/travis/build/babel/babylon/src/tokenizer/index.js": {"path":"/home/travis/build/babel/babylon/src/tokenizer/index.js","statementMap":{"1":{"start":{"line":18,"column":4},"end":{"line":18,"column":27}},"2":{"start":{"line":19,"column":4},"end":{"line":19,"column":29}},"3":{"start":{"line":20,"column":4},"end":{"line":20,"column":29}},"4":{"start":{"line":21,"column":4},"end":{"line":21,"column":25}},"5":{"start":{"line":22,"column":4},"end":{"line":22,"column":64}},"6":{"start":{"line":36,"column":2},"end":{"line":40,"column":3}},"7":{"start":{"line":37,"column":4},"end":{"line":37,"column":37}},"8":{"start":{"line":39,"column":4},"end":{"line":39,"column":102}},"9":{"start":{"line":45,"column":4},"end":{"line":45,"column":27}},"10":{"start":{"line":46,"column":4},"end":{"line":46,"column":36}},"11":{"start":{"line":52,"column":4},"end":{"line":54,"column":5}},"12":{"start":{"line":53,"column":6},"end":{"line":53,"column":52}},"13":{"start":{"line":56,"column":4},"end":{"line":56,"column":43}},"14":{"start":{"line":57,"column":4},"end":{"line":57,"column":47}},"15":{"start":{"line":58,"column":4},"end":{"line":58,"column":49}},"16":{"start":{"line":59,"column":4},"end":{"line":59,"column":53}},"17":{"start":{"line":60,"column":4},"end":{"line":60,"column":21}},"18":{"start":{"line":66,"column":4},"end":{"line":71,"column":5}},"19":{"start":{"line":67,"column":6},"end":{"line":67,"column":18}},"20":{"start":{"line":68,"column":6},"end":{"line":68,"column":18}},"21":{"start":{"line":70,"column":6},"end":{"line":70,"column":19}},"22":{"start":{"line":77,"column":4},"end":{"line":77,"column":36}},"23":{"start":{"line":83,"column":4},"end":{"line":83,"column":27}},"24":{"start":{"line":89,"column":14},"end":{"line":89,"column":24}},"25":{"start":{"line":90,"column":4},"end":{"line":90,"column":33}},"26":{"start":{"line":92,"column":4},"end":{"line":92,"column":28}},"27":{"start":{"line":93,"column":4},"end":{"line":93,"column":16}},"28":{"start":{"line":94,"column":4},"end":{"line":94,"column":29}},"29":{"start":{"line":96,"column":15},"end":{"line":96,"column":37}},"30":{"start":{"line":97,"column":4},"end":{"line":97,"column":21}},"31":{"start":{"line":98,"column":4},"end":{"line":98,"column":16}},"32":{"start":{"line":105,"column":4},"end":{"line":105,"column":31}},"33":{"start":{"line":106,"column":4},"end":{"line":106,"column":62}},"34":{"start":{"line":106,"column":55},"end":{"line":106,"column":62}},"35":{"start":{"line":107,"column":4},"end":{"line":107,"column":38}},"36":{"start":{"line":108,"column":4},"end":{"line":111,"column":5}},"37":{"start":{"line":109,"column":6},"end":{"line":109,"column":88}},"38":{"start":{"line":110,"column":6},"end":{"line":110,"column":27}},"39":{"start":{"line":112,"column":4},"end":{"line":112,"column":21}},"40":{"start":{"line":116,"column":4},"end":{"line":116,"column":61}},"41":{"start":{"line":123,"column":21},"end":{"line":123,"column":38}},"42":{"start":{"line":124,"column":4},"end":{"line":124,"column":67}},"43":{"start":{"line":124,"column":50},"end":{"line":124,"column":67}},"44":{"start":{"line":126,"column":4},"end":{"line":126,"column":37}},"45":{"start":{"line":127,"column":4},"end":{"line":127,"column":36}},"46":{"start":{"line":128,"column":4},"end":{"line":128,"column":38}},"47":{"start":{"line":129,"column":4},"end":{"line":129,"column":51}},"48":{"start":{"line":130,"column":4},"end":{"line":130,"column":77}},"49":{"start":{"line":130,"column":45},"end":{"line":130,"column":77}},"50":{"start":{"line":132,"column":4},"end":{"line":136,"column":5}},"51":{"start":{"line":133,"column":6},"end":{"line":133,"column":39}},"52":{"start":{"line":135,"column":6},"end":{"line":135,"column":54}},"53":{"start":{"line":142,"column":4},"end":{"line":146,"column":5}},"54":{"start":{"line":143,"column":6},"end":{"line":143,"column":29}},"55":{"start":{"line":145,"column":6},"end":{"line":145,"column":41}},"56":{"start":{"line":150,"column":15},"end":{"line":150,"column":52}},"57":{"start":{"line":151,"column":4},"end":{"line":151,"column":54}},"58":{"start":{"line":151,"column":42},"end":{"line":151,"column":54}},"59":{"start":{"line":153,"column":15},"end":{"line":153,"column":56}},"60":{"start":{"line":154,"column":4},"end":{"line":154,"column":43}},"61":{"start":{"line":158,"column":18},"end":{"line":164,"column":5}},"62":{"start":{"line":166,"column":4},"end":{"line":170,"column":5}},"63":{"start":{"line":167,"column":6},"end":{"line":167,"column":38}},"64":{"start":{"line":168,"column":6},"end":{"line":168,"column":40}},"65":{"start":{"line":169,"column":6},"end":{"line":169,"column":31}},"66":{"start":{"line":174,"column":19},"end":{"line":174,"column":43}},"67":{"start":{"line":175,"column":16},"end":{"line":175,"column":30}},"68":{"start":{"line":175,"column":38},"end":{"line":175,"column":83}},"69":{"start":{"line":176,"column":4},"end":{"line":176,"column":75}},"70":{"start":{"line":176,"column":20},"end":{"line":176,"column":75}},"71":{"start":{"line":178,"column":4},"end":{"line":178,"column":29}},"72":{"start":{"line":179,"column":4},"end":{"line":179,"column":33}},"73":{"start":{"line":181,"column":4},"end":{"line":184,"column":5}},"74":{"start":{"line":182,"column":6},"end":{"line":182,"column":27}},"75":{"start":{"line":183,"column":6},"end":{"line":183,"column":59}},"76":{"start":{"line":186,"column":4},"end":{"line":186,"column":120}},"77":{"start":{"line":190,"column":16},"end":{"line":190,"column":30}},"78":{"start":{"line":191,"column":19},"end":{"line":191,"column":43}},"79":{"start":{"line":192,"column":13},"end":{"line":192,"column":63}},"80":{"start":{"line":193,"column":4},"end":{"line":196,"column":5}},"81":{"start":{"line":194,"column":6},"end":{"line":194,"column":23}},"82":{"start":{"line":195,"column":6},"end":{"line":195,"column":49}},"83":{"start":{"line":198,"column":4},"end":{"line":198,"column":140}},"84":{"start":{"line":205,"column":4},"end":{"line":245,"column":5}},"85":{"start":{"line":205,"column":10},"end":{"line":245,"column":5}},"86":{"start":{"line":206,"column":15},"end":{"line":206,"column":52}},"87":{"start":{"line":207,"column":6},"end":{"line":244,"column":7}},"88":{"start":{"line":209,"column":10},"end":{"line":209,"column":27}},"89":{"start":{"line":210,"column":10},"end":{"line":210,"column":16}},"90":{"start":{"line":213,"column":10},"end":{"line":215,"column":11}},"91":{"start":{"line":214,"column":12},"end":{"line":214,"column":29}},"92":{"start":{"line":218,"column":10},"end":{"line":218,"column":27}},"93":{"start":{"line":219,"column":10},"end":{"line":219,"column":31}},"94":{"start":{"line":220,"column":10},"end":{"line":220,"column":48}},"95":{"start":{"line":221,"column":10},"end":{"line":221,"column":16}},"96":{"start":{"line":224,"column":10},"end":{"line":235,"column":11}},"97":{"start":{"line":226,"column":14},"end":{"line":226,"column":38}},"98":{"start":{"line":227,"column":14},"end":{"line":227,"column":20}},"99":{"start":{"line":230,"column":14},"end":{"line":230,"column":38}},"100":{"start":{"line":231,"column":14},"end":{"line":231,"column":20}},"101":{"start":{"line":234,"column":14},"end":{"line":234,"column":25}},"102":{"start":{"line":236,"column":10},"end":{"line":236,"column":16}},"103":{"start":{"line":239,"column":10},"end":{"line":243,"column":11}},"104":{"start":{"line":240,"column":12},"end":{"line":240,"column":29}},"105":{"start":{"line":242,"column":12},"end":{"line":242,"column":23}},"106":{"start":{"line":254,"column":4},"end":{"line":254,"column":36}},"107":{"start":{"line":255,"column":4},"end":{"line":255,"column":49}},"108":{"start":{"line":256,"column":19},"end":{"line":256,"column":34}},"109":{"start":{"line":257,"column":4},"end":{"line":257,"column":27}},"110":{"start":{"line":258,"column":4},"end":{"line":258,"column":27}},"111":{"start":{"line":260,"column":4},"end":{"line":260,"column":33}},"112":{"start":{"line":273,"column":15},"end":{"line":273,"column":56}},"113":{"start":{"line":274,"column":4},"end":{"line":276,"column":5}},"114":{"start":{"line":275,"column":6},"end":{"line":275,"column":35}},"115":{"start":{"line":278,"column":16},"end":{"line":278,"column":57}},"116":{"start":{"line":279,"column":4},"end":{"line":285,"column":5}},"117":{"start":{"line":280,"column":6},"end":{"line":280,"column":26}},"118":{"start":{"line":281,"column":6},"end":{"line":281,"column":43}},"119":{"start":{"line":283,"column":6},"end":{"line":283,"column":23}},"120":{"start":{"line":284,"column":6},"end":{"line":284,"column":38}},"121":{"start":{"line":289,"column":4},"end":{"line":292,"column":5}},"122":{"start":{"line":290,"column":6},"end":{"line":290,"column":23}},"123":{"start":{"line":291,"column":6},"end":{"line":291,"column":31}},"124":{"start":{"line":294,"column":15},"end":{"line":294,"column":56}},"125":{"start":{"line":295,"column":4},"end":{"line":299,"column":5}},"126":{"start":{"line":296,"column":6},"end":{"line":296,"column":41}},"127":{"start":{"line":298,"column":6},"end":{"line":298,"column":40}},"128":{"start":{"line":303,"column":15},"end":{"line":303,"column":48}},"129":{"start":{"line":304,"column":16},"end":{"line":304,"column":17}},"130":{"start":{"line":305,"column":15},"end":{"line":305,"column":56}},"131":{"start":{"line":307,"column":4},"end":{"line":311,"column":5}},"132":{"start":{"line":308,"column":6},"end":{"line":308,"column":14}},"133":{"start":{"line":309,"column":6},"end":{"line":309,"column":55}},"134":{"start":{"line":310,"column":6},"end":{"line":310,"column":25}},"135":{"start":{"line":313,"column":4},"end":{"line":316,"column":5}},"136":{"start":{"line":314,"column":6},"end":{"line":314,"column":14}},"137":{"start":{"line":315,"column":6},"end":{"line":315,"column":23}},"138":{"start":{"line":318,"column":4},"end":{"line":318,"column":38}},"139":{"start":{"line":322,"column":15},"end":{"line":322,"column":56}},"140":{"start":{"line":323,"column":4},"end":{"line":323,"column":92}},"141":{"start":{"line":323,"column":23},"end":{"line":323,"column":92}},"142":{"start":{"line":324,"column":4},"end":{"line":324,"column":56}},"143":{"start":{"line":324,"column":21},"end":{"line":324,"column":56}},"144":{"start":{"line":325,"column":4},"end":{"line":325,"column":73}},"145":{"start":{"line":329,"column":15},"end":{"line":329,"column":56}},"146":{"start":{"line":330,"column":4},"end":{"line":334,"column":5}},"147":{"start":{"line":331,"column":6},"end":{"line":331,"column":41}},"148":{"start":{"line":333,"column":6},"end":{"line":333,"column":45}},"149":{"start":{"line":338,"column":15},"end":{"line":338,"column":56}},"150":{"start":{"line":340,"column":4},"end":{"line":348,"column":5}},"151":{"start":{"line":341,"column":6},"end":{"line":346,"column":7}},"152":{"start":{"line":343,"column":8},"end":{"line":343,"column":32}},"153":{"start":{"line":344,"column":8},"end":{"line":344,"column":25}},"154":{"start":{"line":345,"column":8},"end":{"line":345,"column":32}},"155":{"start":{"line":347,"column":6},"end":{"line":347,"column":41}},"156":{"start":{"line":350,"column":4},"end":{"line":354,"column":5}},"157":{"start":{"line":351,"column":6},"end":{"line":351,"column":41}},"158":{"start":{"line":353,"column":6},"end":{"line":353,"column":42}},"159":{"start":{"line":358,"column":15},"end":{"line":358,"column":56}},"160":{"start":{"line":359,"column":15},"end":{"line":359,"column":16}},"161":{"start":{"line":361,"column":4},"end":{"line":365,"column":5}},"162":{"start":{"line":362,"column":6},"end":{"line":362,"column":85}},"163":{"start":{"line":363,"column":6},"end":{"line":363,"column":105}},"164":{"start":{"line":363,"column":63},"end":{"line":363,"column":105}},"165":{"start":{"line":364,"column":6},"end":{"line":364,"column":46}},"166":{"start":{"line":367,"column":4},"end":{"line":373,"column":5}},"167":{"start":{"line":368,"column":6},"end":{"line":368,"column":43}},"168":{"start":{"line":368,"column":25},"end":{"line":368,"column":43}},"169":{"start":{"line":370,"column":6},"end":{"line":370,"column":30}},"170":{"start":{"line":371,"column":6},"end":{"line":371,"column":23}},"171":{"start":{"line":372,"column":6},"end":{"line":372,"column":30}},"172":{"start":{"line":375,"column":4},"end":{"line":378,"column":5}},"173":{"start":{"line":377,"column":6},"end":{"line":377,"column":15}},"174":{"start":{"line":380,"column":4},"end":{"line":380,"column":46}},"175":{"start":{"line":384,"column":15},"end":{"line":384,"column":56}},"176":{"start":{"line":385,"column":4},"end":{"line":385,"column":113}},"177":{"start":{"line":385,"column":21},"end":{"line":385,"column":113}},"178":{"start":{"line":386,"column":4},"end":{"line":389,"column":5}},"179":{"start":{"line":387,"column":6},"end":{"line":387,"column":26}},"180":{"start":{"line":388,"column":6},"end":{"line":388,"column":40}},"181":{"start":{"line":390,"column":4},"end":{"line":390,"column":61}},"182":{"start":{"line":394,"column":4},"end":{"line":467,"column":5}},"183":{"start":{"line":398,"column":8},"end":{"line":398,"column":36}},"184":{"start":{"line":401,"column":15},"end":{"line":401,"column":32}},"185":{"start":{"line":401,"column":33},"end":{"line":401,"column":68}},"186":{"start":{"line":402,"column":15},"end":{"line":402,"column":32}},"187":{"start":{"line":402,"column":33},"end":{"line":402,"column":68}},"188":{"start":{"line":403,"column":15},"end":{"line":403,"column":32}},"189":{"start":{"line":403,"column":33},"end":{"line":403,"column":66}},"190":{"start":{"line":404,"column":15},"end":{"line":404,"column":32}},"191":{"start":{"line":404,"column":33},"end":{"line":404,"column":67}},"192":{"start":{"line":405,"column":15},"end":{"line":405,"column":32}},"193":{"start":{"line":405,"column":33},"end":{"line":405,"column":70}},"194":{"start":{"line":406,"column":15},"end":{"line":406,"column":32}},"195":{"start":{"line":406,"column":33},"end":{"line":406,"column":70}},"196":{"start":{"line":407,"column":16},"end":{"line":407,"column":33}},"197":{"start":{"line":407,"column":34},"end":{"line":407,"column":69}},"198":{"start":{"line":408,"column":16},"end":{"line":408,"column":33}},"199":{"start":{"line":408,"column":34},"end":{"line":408,"column":69}},"200":{"start":{"line":411,"column":8},"end":{"line":416,"column":9}},"201":{"start":{"line":412,"column":10},"end":{"line":412,"column":50}},"202":{"start":{"line":414,"column":10},"end":{"line":414,"column":27}},"203":{"start":{"line":415,"column":10},"end":{"line":415,"column":44}},"204":{"start":{"line":418,"column":15},"end":{"line":418,"column":32}},"205":{"start":{"line":418,"column":33},"end":{"line":418,"column":70}},"206":{"start":{"line":419,"column":15},"end":{"line":419,"column":32}},"207":{"start":{"line":419,"column":33},"end":{"line":419,"column":64}},"208":{"start":{"line":422,"column":8},"end":{"line":422,"column":25}},"209":{"start":{"line":423,"column":8},"end":{"line":423,"column":46}},"210":{"start":{"line":426,"column":19},"end":{"line":426,"column":60}},"211":{"start":{"line":427,"column":8},"end":{"line":427,"column":73}},"212":{"start":{"line":427,"column":41},"end":{"line":427,"column":73}},"213":{"start":{"line":428,"column":8},"end":{"line":428,"column":72}},"214":{"start":{"line":428,"column":41},"end":{"line":428,"column":72}},"215":{"start":{"line":429,"column":8},"end":{"line":429,"column":71}},"216":{"start":{"line":429,"column":40},"end":{"line":429,"column":71}},"217":{"start":{"line":433,"column":8},"end":{"line":433,"column":38}},"218":{"start":{"line":437,"column":8},"end":{"line":437,"column":37}},"219":{"start":{"line":445,"column":8},"end":{"line":445,"column":38}},"220":{"start":{"line":448,"column":8},"end":{"line":448,"column":48}},"221":{"start":{"line":451,"column":8},"end":{"line":451,"column":45}},"222":{"start":{"line":454,"column":8},"end":{"line":454,"column":38}},"223":{"start":{"line":457,"column":8},"end":{"line":457,"column":45}},"224":{"start":{"line":460,"column":8},"end":{"line":460,"column":42}},"225":{"start":{"line":463,"column":8},"end":{"line":463,"column":44}},"226":{"start":{"line":466,"column":8},"end":{"line":466,"column":43}},"227":{"start":{"line":469,"column":4},"end":{"line":469,"column":84}},"228":{"start":{"line":473,"column":14},"end":{"line":473,"column":69}},"229":{"start":{"line":474,"column":4},"end":{"line":474,"column":27}},"230":{"start":{"line":475,"column":4},"end":{"line":475,"column":39}},"231":{"start":{"line":479,"column":34},"end":{"line":479,"column":48}},"232":{"start":{"line":480,"column":4},"end":{"line":499,"column":5}},"233":{"start":{"line":481,"column":6},"end":{"line":481,"column":100}},"234":{"start":{"line":481,"column":47},"end":{"line":481,"column":100}},"235":{"start":{"line":482,"column":15},"end":{"line":482,"column":48}},"236":{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},"237":{"start":{"line":484,"column":8},"end":{"line":484,"column":61}},"238":{"start":{"line":486,"column":6},"end":{"line":497,"column":7}},"239":{"start":{"line":487,"column":8},"end":{"line":487,"column":24}},"240":{"start":{"line":489,"column":8},"end":{"line":495,"column":9}},"241":{"start":{"line":490,"column":10},"end":{"line":490,"column":25}},"242":{"start":{"line":491,"column":15},"end":{"line":495,"column":9}},"243":{"start":{"line":492,"column":10},"end":{"line":492,"column":26}},"244":{"start":{"line":493,"column":15},"end":{"line":495,"column":9}},"245":{"start":{"line":494,"column":10},"end":{"line":494,"column":16}},"246":{"start":{"line":496,"column":8},"end":{"line":496,"column":30}},"247":{"start":{"line":498,"column":6},"end":{"line":498,"column":23}},"248":{"start":{"line":500,"column":18},"end":{"line":500,"column":57}},"249":{"start":{"line":501,"column":4},"end":{"line":501,"column":21}},"250":{"start":{"line":504,"column":15},"end":{"line":504,"column":31}},"251":{"start":{"line":505,"column":4},"end":{"line":508,"column":5}},"252":{"start":{"line":506,"column":23},"end":{"line":506,"column":36}},"253":{"start":{"line":507,"column":6},"end":{"line":507,"column":87}},"254":{"start":{"line":507,"column":34},"end":{"line":507,"column":87}},"255":{"start":{"line":509,"column":4},"end":{"line":512,"column":7}},"256":{"start":{"line":520,"column":16},"end":{"line":520,"column":30}},"257":{"start":{"line":520,"column":40},"end":{"line":520,"column":41}},"258":{"start":{"line":521,"column":4},"end":{"line":535,"column":5}},"259":{"start":{"line":522,"column":17},"end":{"line":522,"column":54}},"260":{"start":{"line":523,"column":6},"end":{"line":531,"column":7}},"261":{"start":{"line":524,"column":8},"end":{"line":524,"column":29}},"262":{"start":{"line":525,"column":13},"end":{"line":531,"column":7}},"263":{"start":{"line":526,"column":8},"end":{"line":526,"column":29}},"264":{"start":{"line":527,"column":13},"end":{"line":531,"column":7}},"265":{"start":{"line":528,"column":8},"end":{"line":528,"column":24}},"266":{"start":{"line":530,"column":8},"end":{"line":530,"column":23}},"267":{"start":{"line":532,"column":6},"end":{"line":532,"column":30}},"268":{"start":{"line":532,"column":24},"end":{"line":532,"column":30}},"269":{"start":{"line":533,"column":6},"end":{"line":533,"column":23}},"270":{"start":{"line":534,"column":6},"end":{"line":534,"column":34}},"271":{"start":{"line":536,"column":4},"end":{"line":536,"column":95}},"272":{"start":{"line":536,"column":83},"end":{"line":536,"column":95}},"273":{"start":{"line":538,"column":4},"end":{"line":538,"column":17}},"274":{"start":{"line":542,"column":4},"end":{"line":542,"column":24}},"275":{"start":{"line":543,"column":14},"end":{"line":543,"column":33}},"276":{"start":{"line":544,"column":4},"end":{"line":544,"column":91}},"277":{"start":{"line":544,"column":21},"end":{"line":544,"column":91}},"278":{"start":{"line":545,"column":4},"end":{"line":545,"column":116}},"279":{"start":{"line":545,"column":53},"end":{"line":545,"column":116}},"280":{"start":{"line":546,"column":4},"end":{"line":546,"column":41}},"281":{"start":{"line":552,"column":16},"end":{"line":552,"column":30}},"282":{"start":{"line":552,"column":42},"end":{"line":552,"column":47}},"283":{"start":{"line":552,"column":57},"end":{"line":552,"column":101}},"284":{"start":{"line":553,"column":4},"end":{"line":553,"column":89}},"285":{"start":{"line":553,"column":53},"end":{"line":553,"column":89}},"286":{"start":{"line":554,"column":15},"end":{"line":554,"column":52}},"287":{"start":{"line":555,"column":4},"end":{"line":560,"column":5}},"288":{"start":{"line":556,"column":6},"end":{"line":556,"column":23}},"289":{"start":{"line":557,"column":6},"end":{"line":557,"column":23}},"290":{"start":{"line":558,"column":6},"end":{"line":558,"column":21}},"291":{"start":{"line":559,"column":6},"end":{"line":559,"column":51}},"292":{"start":{"line":561,"column":4},"end":{"line":566,"column":5}},"293":{"start":{"line":562,"column":6},"end":{"line":562,"column":53}},"294":{"start":{"line":563,"column":6},"end":{"line":563,"column":55}},"295":{"start":{"line":563,"column":38},"end":{"line":563,"column":55}},"296":{"start":{"line":564,"column":6},"end":{"line":564,"column":73}},"297":{"start":{"line":564,"column":37},"end":{"line":564,"column":73}},"298":{"start":{"line":565,"column":6},"end":{"line":565,"column":21}},"299":{"start":{"line":567,"column":4},"end":{"line":567,"column":116}},"300":{"start":{"line":567,"column":53},"end":{"line":567,"column":116}},"301":{"start":{"line":569,"column":14},"end":{"line":569,"column":53}},"302":{"start":{"line":570,"column":4},"end":{"line":578,"column":5}},"303":{"start":{"line":571,"column":6},"end":{"line":571,"column":28}},"304":{"start":{"line":572,"column":11},"end":{"line":578,"column":5}},"305":{"start":{"line":573,"column":6},"end":{"line":573,"column":30}},"306":{"start":{"line":574,"column":11},"end":{"line":578,"column":5}},"307":{"start":{"line":575,"column":6},"end":{"line":575,"column":42}},"308":{"start":{"line":577,"column":6},"end":{"line":577,"column":29}},"309":{"start":{"line":579,"column":4},"end":{"line":579,"column":41}},"310":{"start":{"line":585,"column":13},"end":{"line":585,"column":50}},"311":{"start":{"line":587,"column":4},"end":{"line":594,"column":5}},"312":{"start":{"line":588,"column":20},"end":{"line":588,"column":36}},"313":{"start":{"line":589,"column":6},"end":{"line":589,"column":88}},"314":{"start":{"line":590,"column":6},"end":{"line":590,"column":23}},"315":{"start":{"line":591,"column":6},"end":{"line":591,"column":75}},"316":{"start":{"line":591,"column":27},"end":{"line":591,"column":75}},"317":{"start":{"line":593,"column":6},"end":{"line":593,"column":33}},"318":{"start":{"line":595,"column":4},"end":{"line":595,"column":16}},"319":{"start":{"line":599,"column":14},"end":{"line":599,"column":16}},"320":{"start":{"line":599,"column":31},"end":{"line":599,"column":47}},"321":{"start":{"line":600,"column":4},"end":{"line":612,"column":5}},"322":{"start":{"line":601,"column":6},"end":{"line":601,"column":108}},"323":{"start":{"line":601,"column":47},"end":{"line":601,"column":108}},"324":{"start":{"line":602,"column":15},"end":{"line":602,"column":52}},"325":{"start":{"line":603,"column":6},"end":{"line":603,"column":30}},"326":{"start":{"line":603,"column":24},"end":{"line":603,"column":30}},"327":{"start":{"line":604,"column":6},"end":{"line":611,"column":7}},"328":{"start":{"line":605,"column":8},"end":{"line":605,"column":60}},"329":{"start":{"line":606,"column":8},"end":{"line":606,"column":43}},"330":{"start":{"line":607,"column":8},"end":{"line":607,"column":36}},"331":{"start":{"line":609,"column":8},"end":{"line":609,"column":88}},"332":{"start":{"line":609,"column":27},"end":{"line":609,"column":88}},"333":{"start":{"line":610,"column":8},"end":{"line":610,"column":25}},"334":{"start":{"line":613,"column":4},"end":{"line":613,"column":58}},"335":{"start":{"line":614,"column":4},"end":{"line":614,"column":44}},"336":{"start":{"line":620,"column":14},"end":{"line":620,"column":16}},"337":{"start":{"line":620,"column":31},"end":{"line":620,"column":45}},"338":{"start":{"line":621,"column":4},"end":{"line":660,"column":5}},"339":{"start":{"line":622,"column":6},"end":{"line":622,"column":101}},"340":{"start":{"line":622,"column":47},"end":{"line":622,"column":101}},"341":{"start":{"line":623,"column":15},"end":{"line":623,"column":52}},"342":{"start":{"line":624,"column":6},"end":{"line":636,"column":7}},"343":{"start":{"line":625,"column":8},"end":{"line":633,"column":9}},"344":{"start":{"line":626,"column":10},"end":{"line":632,"column":11}},"345":{"start":{"line":627,"column":12},"end":{"line":627,"column":32}},"346":{"start":{"line":628,"column":12},"end":{"line":628,"column":53}},"347":{"start":{"line":630,"column":12},"end":{"line":630,"column":29}},"348":{"start":{"line":631,"column":12},"end":{"line":631,"column":50}},"349":{"start":{"line":634,"column":8},"end":{"line":634,"column":60}},"350":{"start":{"line":635,"column":8},"end":{"line":635,"column":50}},"351":{"start":{"line":637,"column":6},"end":{"line":659,"column":7}},"352":{"start":{"line":638,"column":8},"end":{"line":638,"column":60}},"353":{"start":{"line":639,"column":8},"end":{"line":639,"column":42}},"354":{"start":{"line":640,"column":8},"end":{"line":640,"column":36}},"355":{"start":{"line":641,"column":13},"end":{"line":659,"column":7}},"356":{"start":{"line":642,"column":8},"end":{"line":642,"column":60}},"357":{"start":{"line":643,"column":8},"end":{"line":643,"column":25}},"358":{"start":{"line":644,"column":8},"end":{"line":653,"column":9}},"359":{"start":{"line":646,"column":12},"end":{"line":646,"column":79}},"360":{"start":{"line":646,"column":62},"end":{"line":646,"column":79}},"361":{"start":{"line":648,"column":12},"end":{"line":648,"column":24}},"362":{"start":{"line":649,"column":12},"end":{"line":649,"column":18}},"363":{"start":{"line":651,"column":12},"end":{"line":651,"column":43}},"364":{"start":{"line":652,"column":12},"end":{"line":652,"column":18}},"365":{"start":{"line":654,"column":8},"end":{"line":654,"column":29}},"366":{"start":{"line":655,"column":8},"end":{"line":655,"column":46}},"367":{"start":{"line":656,"column":8},"end":{"line":656,"column":36}},"368":{"start":{"line":658,"column":8},"end":{"line":658,"column":25}},"369":{"start":{"line":666,"column":13},"end":{"line":666,"column":52}},"370":{"start":{"line":667,"column":4},"end":{"line":667,"column":21}},"371":{"start":{"line":668,"column":4},"end":{"line":703,"column":5}},"372":{"start":{"line":669,"column":16},"end":{"line":669,"column":28}},"373":{"start":{"line":670,"column":16},"end":{"line":670,"column":28}},"374":{"start":{"line":671,"column":16},"end":{"line":671,"column":64}},"375":{"start":{"line":672,"column":16},"end":{"line":672,"column":63}},"376":{"start":{"line":673,"column":16},"end":{"line":673,"column":28}},"377":{"start":{"line":674,"column":15},"end":{"line":674,"column":27}},"378":{"start":{"line":675,"column":16},"end":{"line":675,"column":32}},"379":{"start":{"line":676,"column":16},"end":{"line":676,"column":28}},"380":{"start":{"line":677,"column":15},"end":{"line":677,"column":82}},"381":{"start":{"line":677,"column":65},"end":{"line":677,"column":82}},"382":{"start":{"line":679,"column":8},"end":{"line":679,"column":46}},"383":{"start":{"line":680,"column":8},"end":{"line":680,"column":29}},"384":{"start":{"line":681,"column":8},"end":{"line":681,"column":18}},"385":{"start":{"line":683,"column":8},"end":{"line":701,"column":9}},"386":{"start":{"line":684,"column":25},"end":{"line":684,"column":85}},"387":{"start":{"line":685,"column":22},"end":{"line":685,"column":43}},"388":{"start":{"line":686,"column":10},"end":{"line":689,"column":11}},"389":{"start":{"line":687,"column":12},"end":{"line":687,"column":45}},"390":{"start":{"line":688,"column":12},"end":{"line":688,"column":42}},"391":{"start":{"line":690,"column":10},"end":{"line":698,"column":11}},"392":{"start":{"line":691,"column":12},"end":{"line":694,"column":13}},"393":{"start":{"line":692,"column":14},"end":{"line":692,"column":46}},"394":{"start":{"line":693,"column":14},"end":{"line":693,"column":60}},"395":{"start":{"line":695,"column":12},"end":{"line":697,"column":13}},"396":{"start":{"line":696,"column":14},"end":{"line":696,"column":77}},"397":{"start":{"line":699,"column":10},"end":{"line":699,"column":48}},"398":{"start":{"line":700,"column":10},"end":{"line":700,"column":44}},"399":{"start":{"line":702,"column":8},"end":{"line":702,"column":39}},"400":{"start":{"line":709,"column":18},"end":{"line":709,"column":32}},"401":{"start":{"line":710,"column":12},"end":{"line":710,"column":33}},"402":{"start":{"line":711,"column":4},"end":{"line":711,"column":73}},"403":{"start":{"line":711,"column":20},"end":{"line":711,"column":73}},"404":{"start":{"line":712,"column":4},"end":{"line":712,"column":13}},"405":{"start":{"line":722,"column":4},"end":{"line":722,"column":35}},"406":{"start":{"line":723,"column":15},"end":{"line":723,"column":17}},"407":{"start":{"line":723,"column":27},"end":{"line":723,"column":31}},"408":{"start":{"line":723,"column":46},"end":{"line":723,"column":60}},"409":{"start":{"line":724,"column":4},"end":{"line":750,"column":5}},"410":{"start":{"line":725,"column":15},"end":{"line":725,"column":39}},"411":{"start":{"line":726,"column":6},"end":{"line":748,"column":7}},"412":{"start":{"line":727,"column":8},"end":{"line":727,"column":47}},"413":{"start":{"line":728,"column":13},"end":{"line":748,"column":7}},"414":{"start":{"line":729,"column":8},"end":{"line":729,"column":38}},"415":{"start":{"line":731,"column":8},"end":{"line":731,"column":61}},"416":{"start":{"line":732,"column":23},"end":{"line":732,"column":37}},"417":{"start":{"line":734,"column":8},"end":{"line":736,"column":9}},"418":{"start":{"line":735,"column":10},"end":{"line":735,"column":82}},"419":{"start":{"line":738,"column":8},"end":{"line":738,"column":25}},"420":{"start":{"line":739,"column":18},"end":{"line":739,"column":38}},"421":{"start":{"line":740,"column":8},"end":{"line":742,"column":9}},"422":{"start":{"line":741,"column":10},"end":{"line":741,"column":57}},"423":{"start":{"line":744,"column":8},"end":{"line":744,"column":39}},"424":{"start":{"line":745,"column":8},"end":{"line":745,"column":36}},"425":{"start":{"line":747,"column":8},"end":{"line":747,"column":14}},"426":{"start":{"line":749,"column":6},"end":{"line":749,"column":20}},"427":{"start":{"line":751,"column":4},"end":{"line":751,"column":63}},"428":{"start":{"line":758,"column":15},"end":{"line":758,"column":31}},"429":{"start":{"line":759,"column":15},"end":{"line":759,"column":22}},"430":{"start":{"line":760,"column":4},"end":{"line":762,"column":5}},"431":{"start":{"line":761,"column":6},"end":{"line":761,"column":32}},"432":{"start":{"line":763,"column":4},"end":{"line":763,"column":40}},"433":{"start":{"line":767,"column":4},"end":{"line":772,"column":5}},"434":{"start":{"line":768,"column":19},"end":{"line":768,"column":36}},"435":{"start":{"line":769,"column":6},"end":{"line":771,"column":7}},"436":{"start":{"line":770,"column":8},"end":{"line":770,"column":30}},"437":{"start":{"line":774,"column":4},"end":{"line":776,"column":5}},"438":{"start":{"line":775,"column":6},"end":{"line":775,"column":87}},"439":{"start":{"line":778,"column":4},"end":{"line":780,"column":5}},"440":{"start":{"line":779,"column":6},"end":{"line":779,"column":18}},"441":{"start":{"line":782,"column":4},"end":{"line":784,"column":5}},"442":{"start":{"line":783,"column":6},"end":{"line":783,"column":53}},"443":{"start":{"line":786,"column":4},"end":{"line":786,"column":35}},"444":{"start":{"line":790,"column":23},"end":{"line":790,"column":38}},"445":{"start":{"line":791,"column":4},"end":{"line":797,"column":5}},"446":{"start":{"line":792,"column":6},"end":{"line":792,"column":37}},"447":{"start":{"line":793,"column":11},"end":{"line":797,"column":5}},"448":{"start":{"line":794,"column":6},"end":{"line":794,"column":34}},"449":{"start":{"line":796,"column":6},"end":{"line":796,"column":47}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":17,"column":2},"end":{"line":17,"column":3}},"loc":{"start":{"line":17,"column":21},"end":{"line":23,"column":3}}},"2":{"name":"codePointToString","decl":{"start":{"line":34,"column":9},"end":{"line":34,"column":26}},"loc":{"start":{"line":34,"column":33},"end":{"line":41,"column":1}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":44,"column":2},"end":{"line":44,"column":3}},"loc":{"start":{"line":44,"column":30},"end":{"line":47,"column":3}}},"4":{"name":"(anonymous_4)","decl":{"start":{"line":51,"column":2},"end":{"line":51,"column":3}},"loc":{"start":{"line":51,"column":9},"end":{"line":61,"column":3}}},"5":{"name":"(anonymous_5)","decl":{"start":{"line":65,"column":2},"end":{"line":65,"column":3}},"loc":{"start":{"line":65,"column":12},"end":{"line":72,"column":3}}},"6":{"name":"(anonymous_6)","decl":{"start":{"line":76,"column":2},"end":{"line":76,"column":3}},"loc":{"start":{"line":76,"column":14},"end":{"line":78,"column":3}}},"7":{"name":"(anonymous_7)","decl":{"start":{"line":82,"column":2},"end":{"line":82,"column":3}},"loc":{"start":{"line":82,"column":18},"end":{"line":84,"column":3}}},"8":{"name":"(anonymous_8)","decl":{"start":{"line":88,"column":2},"end":{"line":88,"column":3}},"loc":{"start":{"line":88,"column":14},"end":{"line":99,"column":3}}},"9":{"name":"(anonymous_9)","decl":{"start":{"line":104,"column":2},"end":{"line":104,"column":3}},"loc":{"start":{"line":104,"column":20},"end":{"line":113,"column":3}}},"10":{"name":"(anonymous_10)","decl":{"start":{"line":115,"column":2},"end":{"line":115,"column":3}},"loc":{"start":{"line":115,"column":15},"end":{"line":117,"column":3}}},"11":{"name":"(anonymous_11)","decl":{"start":{"line":122,"column":2},"end":{"line":122,"column":3}},"loc":{"start":{"line":122,"column":14},"end":{"line":137,"column":3}}},"12":{"name":"(anonymous_12)","decl":{"start":{"line":139,"column":2},"end":{"line":139,"column":3}},"loc":{"start":{"line":139,"column":18},"end":{"line":147,"column":3}}},"13":{"name":"(anonymous_13)","decl":{"start":{"line":149,"column":2},"end":{"line":149,"column":3}},"loc":{"start":{"line":149,"column":22},"end":{"line":155,"column":3}}},"14":{"name":"(anonymous_14)","decl":{"start":{"line":157,"column":2},"end":{"line":157,"column":3}},"loc":{"start":{"line":157,"column":57},"end":{"line":171,"column":3}}},"15":{"name":"(anonymous_15)","decl":{"start":{"line":173,"column":2},"end":{"line":173,"column":3}},"loc":{"start":{"line":173,"column":21},"end":{"line":187,"column":3}}},"16":{"name":"(anonymous_16)","decl":{"start":{"line":189,"column":2},"end":{"line":189,"column":3}},"loc":{"start":{"line":189,"column":29},"end":{"line":199,"column":3}}},"17":{"name":"(anonymous_17)","decl":{"start":{"line":204,"column":2},"end":{"line":204,"column":3}},"loc":{"start":{"line":204,"column":14},"end":{"line":246,"column":3}}},"18":{"name":"(anonymous_18)","decl":{"start":{"line":253,"column":2},"end":{"line":253,"column":3}},"loc":{"start":{"line":253,"column":25},"end":{"line":261,"column":3}}},"19":{"name":"(anonymous_19)","decl":{"start":{"line":272,"column":2},"end":{"line":272,"column":3}},"loc":{"start":{"line":272,"column":18},"end":{"line":286,"column":3}}},"20":{"name":"(anonymous_20)","decl":{"start":{"line":288,"column":2},"end":{"line":288,"column":3}},"loc":{"start":{"line":288,"column":20},"end":{"line":300,"column":3}}},"21":{"name":"(anonymous_21)","decl":{"start":{"line":302,"column":2},"end":{"line":302,"column":3}},"loc":{"start":{"line":302,"column":30},"end":{"line":319,"column":3}}},"22":{"name":"(anonymous_22)","decl":{"start":{"line":321,"column":2},"end":{"line":321,"column":3}},"loc":{"start":{"line":321,"column":27},"end":{"line":326,"column":3}}},"23":{"name":"(anonymous_23)","decl":{"start":{"line":328,"column":2},"end":{"line":328,"column":3}},"loc":{"start":{"line":328,"column":20},"end":{"line":335,"column":3}}},"24":{"name":"(anonymous_24)","decl":{"start":{"line":337,"column":2},"end":{"line":337,"column":3}},"loc":{"start":{"line":337,"column":27},"end":{"line":355,"column":3}}},"25":{"name":"(anonymous_25)","decl":{"start":{"line":357,"column":2},"end":{"line":357,"column":3}},"loc":{"start":{"line":357,"column":24},"end":{"line":381,"column":3}}},"26":{"name":"(anonymous_26)","decl":{"start":{"line":383,"column":2},"end":{"line":383,"column":3}},"loc":{"start":{"line":383,"column":26},"end":{"line":391,"column":3}}},"27":{"name":"(anonymous_27)","decl":{"start":{"line":393,"column":2},"end":{"line":393,"column":3}},"loc":{"start":{"line":393,"column":25},"end":{"line":470,"column":3}}},"28":{"name":"(anonymous_28)","decl":{"start":{"line":472,"column":2},"end":{"line":472,"column":3}},"loc":{"start":{"line":472,"column":23},"end":{"line":476,"column":3}}},"29":{"name":"(anonymous_29)","decl":{"start":{"line":478,"column":2},"end":{"line":478,"column":3}},"loc":{"start":{"line":478,"column":15},"end":{"line":513,"column":3}}},"30":{"name":"(anonymous_30)","decl":{"start":{"line":519,"column":2},"end":{"line":519,"column":3}},"loc":{"start":{"line":519,"column":22},"end":{"line":539,"column":3}}},"31":{"name":"(anonymous_31)","decl":{"start":{"line":541,"column":2},"end":{"line":541,"column":3}},"loc":{"start":{"line":541,"column":25},"end":{"line":547,"column":3}}},"32":{"name":"(anonymous_32)","decl":{"start":{"line":551,"column":2},"end":{"line":551,"column":3}},"loc":{"start":{"line":551,"column":28},"end":{"line":580,"column":3}}},"33":{"name":"(anonymous_33)","decl":{"start":{"line":584,"column":2},"end":{"line":584,"column":3}},"loc":{"start":{"line":584,"column":18},"end":{"line":596,"column":3}}},"34":{"name":"(anonymous_34)","decl":{"start":{"line":598,"column":2},"end":{"line":598,"column":3}},"loc":{"start":{"line":598,"column":20},"end":{"line":615,"column":3}}},"35":{"name":"(anonymous_35)","decl":{"start":{"line":619,"column":2},"end":{"line":619,"column":3}},"loc":{"start":{"line":619,"column":18},"end":{"line":661,"column":3}}},"36":{"name":"(anonymous_36)","decl":{"start":{"line":665,"column":2},"end":{"line":665,"column":3}},"loc":{"start":{"line":665,"column":30},"end":{"line":704,"column":3}}},"37":{"name":"(anonymous_37)","decl":{"start":{"line":708,"column":2},"end":{"line":708,"column":3}},"loc":{"start":{"line":708,"column":19},"end":{"line":713,"column":3}}},"38":{"name":"(anonymous_38)","decl":{"start":{"line":721,"column":2},"end":{"line":721,"column":3}},"loc":{"start":{"line":721,"column":14},"end":{"line":752,"column":3}}},"39":{"name":"(anonymous_39)","decl":{"start":{"line":757,"column":2},"end":{"line":757,"column":3}},"loc":{"start":{"line":757,"column":13},"end":{"line":764,"column":3}}},"40":{"name":"(anonymous_40)","decl":{"start":{"line":766,"column":2},"end":{"line":766,"column":3}},"loc":{"start":{"line":766,"column":25},"end":{"line":787,"column":3}}},"41":{"name":"(anonymous_41)","decl":{"start":{"line":789,"column":2},"end":{"line":789,"column":3}},"loc":{"start":{"line":789,"column":26},"end":{"line":798,"column":3}}}},"branchMap":{"1":{"loc":{"start":{"line":36,"column":2},"end":{"line":40,"column":3}},"type":"if","locations":[{"start":{"line":36,"column":2},"end":{"line":40,"column":3}},{"start":{"line":36,"column":2},"end":{"line":40,"column":3}}]},"2":{"loc":{"start":{"line":52,"column":4},"end":{"line":54,"column":5}},"type":"if","locations":[{"start":{"line":52,"column":4},"end":{"line":54,"column":5}},{"start":{"line":52,"column":4},"end":{"line":54,"column":5}}]},"3":{"loc":{"start":{"line":66,"column":4},"end":{"line":71,"column":5}},"type":"if","locations":[{"start":{"line":66,"column":4},"end":{"line":71,"column":5}},{"start":{"line":66,"column":4},"end":{"line":71,"column":5}}]},"4":{"loc":{"start":{"line":106,"column":4},"end":{"line":106,"column":62}},"type":"if","locations":[{"start":{"line":106,"column":4},"end":{"line":106,"column":62}},{"start":{"line":106,"column":4},"end":{"line":106,"column":62}}]},"5":{"loc":{"start":{"line":106,"column":8},"end":{"line":106,"column":53}},"type":"binary-expr","locations":[{"start":{"line":106,"column":8},"end":{"line":106,"column":27}},{"start":{"line":106,"column":31},"end":{"line":106,"column":53}}]},"6":{"loc":{"start":{"line":124,"column":4},"end":{"line":124,"column":67}},"type":"if","locations":[{"start":{"line":124,"column":4},"end":{"line":124,"column":67}},{"start":{"line":124,"column":4},"end":{"line":124,"column":67}}]},"7":{"loc":{"start":{"line":124,"column":8},"end":{"line":124,"column":48}},"type":"binary-expr","locations":[{"start":{"line":124,"column":8},"end":{"line":124,"column":19}},{"start":{"line":124,"column":23},"end":{"line":124,"column":48}}]},"8":{"loc":{"start":{"line":130,"column":4},"end":{"line":130,"column":77}},"type":"if","locations":[{"start":{"line":130,"column":4},"end":{"line":130,"column":77}},{"start":{"line":130,"column":4},"end":{"line":130,"column":77}}]},"9":{"loc":{"start":{"line":132,"column":4},"end":{"line":136,"column":5}},"type":"if","locations":[{"start":{"line":132,"column":4},"end":{"line":136,"column":5}},{"start":{"line":132,"column":4},"end":{"line":136,"column":5}}]},"10":{"loc":{"start":{"line":142,"column":4},"end":{"line":146,"column":5}},"type":"if","locations":[{"start":{"line":142,"column":4},"end":{"line":146,"column":5}},{"start":{"line":142,"column":4},"end":{"line":146,"column":5}}]},"11":{"loc":{"start":{"line":142,"column":8},"end":{"line":142,"column":46}},"type":"binary-expr","locations":[{"start":{"line":142,"column":8},"end":{"line":142,"column":31}},{"start":{"line":142,"column":35},"end":{"line":142,"column":46}}]},"12":{"loc":{"start":{"line":151,"column":4},"end":{"line":151,"column":54}},"type":"if","locations":[{"start":{"line":151,"column":4},"end":{"line":151,"column":54}},{"start":{"line":151,"column":4},"end":{"line":151,"column":54}}]},"13":{"loc":{"start":{"line":151,"column":8},"end":{"line":151,"column":40}},"type":"binary-expr","locations":[{"start":{"line":151,"column":8},"end":{"line":151,"column":22}},{"start":{"line":151,"column":26},"end":{"line":151,"column":40}}]},"14":{"loc":{"start":{"line":159,"column":12},"end":{"line":159,"column":50}},"type":"cond-expr","locations":[{"start":{"line":159,"column":20},"end":{"line":159,"column":34}},{"start":{"line":159,"column":37},"end":{"line":159,"column":50}}]},"15":{"loc":{"start":{"line":166,"column":4},"end":{"line":170,"column":5}},"type":"if","locations":[{"start":{"line":166,"column":4},"end":{"line":170,"column":5}},{"start":{"line":166,"column":4},"end":{"line":170,"column":5}}]},"16":{"loc":{"start":{"line":176,"column":4},"end":{"line":176,"column":75}},"type":"if","locations":[{"start":{"line":176,"column":4},"end":{"line":176,"column":75}},{"start":{"line":176,"column":4},"end":{"line":176,"column":75}}]},"17":{"loc":{"start":{"line":181,"column":11},"end":{"line":181,"column":80}},"type":"binary-expr","locations":[{"start":{"line":181,"column":12},"end":{"line":181,"column":47}},{"start":{"line":181,"column":52},"end":{"line":181,"column":80}}]},"18":{"loc":{"start":{"line":193,"column":11},"end":{"line":193,"column":101}},"type":"binary-expr","locations":[{"start":{"line":193,"column":11},"end":{"line":193,"column":45}},{"start":{"line":193,"column":49},"end":{"line":193,"column":58}},{"start":{"line":193,"column":62},"end":{"line":193,"column":71}},{"start":{"line":193,"column":75},"end":{"line":193,"column":86}},{"start":{"line":193,"column":90},"end":{"line":193,"column":101}}]},"19":{"loc":{"start":{"line":207,"column":6},"end":{"line":244,"column":7}},"type":"switch","locations":[{"start":{"line":208,"column":8},"end":{"line":208,"column":16}},{"start":{"line":208,"column":17},"end":{"line":210,"column":16}},{"start":{"line":212,"column":8},"end":{"line":215,"column":11}},{"start":{"line":217,"column":8},"end":{"line":217,"column":16}},{"start":{"line":217,"column":17},"end":{"line":217,"column":27}},{"start":{"line":217,"column":28},"end":{"line":221,"column":16}},{"start":{"line":223,"column":8},"end":{"line":236,"column":16}},{"start":{"line":238,"column":8},"end":{"line":243,"column":11}}]},"20":{"loc":{"start":{"line":213,"column":10},"end":{"line":215,"column":11}},"type":"if","locations":[{"start":{"line":213,"column":10},"end":{"line":215,"column":11}},{"start":{"line":213,"column":10},"end":{"line":215,"column":11}}]},"21":{"loc":{"start":{"line":224,"column":10},"end":{"line":235,"column":11}},"type":"switch","locations":[{"start":{"line":225,"column":12},"end":{"line":227,"column":20}},{"start":{"line":229,"column":12},"end":{"line":231,"column":20}},{"start":{"line":233,"column":12},"end":{"line":234,"column":25}}]},"22":{"loc":{"start":{"line":239,"column":10},"end":{"line":243,"column":11}},"type":"if","locations":[{"start":{"line":239,"column":10},"end":{"line":243,"column":11}},{"start":{"line":239,"column":10},"end":{"line":243,"column":11}}]},"23":{"loc":{"start":{"line":239,"column":14},"end":{"line":239,"column":97}},"type":"binary-expr","locations":[{"start":{"line":239,"column":14},"end":{"line":239,"column":20}},{"start":{"line":239,"column":24},"end":{"line":239,"column":31}},{"start":{"line":239,"column":35},"end":{"line":239,"column":45}},{"start":{"line":239,"column":49},"end":{"line":239,"column":97}}]},"24":{"loc":{"start":{"line":274,"column":4},"end":{"line":276,"column":5}},"type":"if","locations":[{"start":{"line":274,"column":4},"end":{"line":276,"column":5}},{"start":{"line":274,"column":4},"end":{"line":276,"column":5}}]},"25":{"loc":{"start":{"line":274,"column":8},"end":{"line":274,"column":32}},"type":"binary-expr","locations":[{"start":{"line":274,"column":8},"end":{"line":274,"column":18}},{"start":{"line":274,"column":22},"end":{"line":274,"column":32}}]},"26":{"loc":{"start":{"line":279,"column":4},"end":{"line":285,"column":5}},"type":"if","locations":[{"start":{"line":279,"column":4},"end":{"line":285,"column":5}},{"start":{"line":279,"column":4},"end":{"line":285,"column":5}}]},"27":{"loc":{"start":{"line":279,"column":8},"end":{"line":279,"column":35}},"type":"binary-expr","locations":[{"start":{"line":279,"column":8},"end":{"line":279,"column":19}},{"start":{"line":279,"column":23},"end":{"line":279,"column":35}}]},"28":{"loc":{"start":{"line":289,"column":4},"end":{"line":292,"column":5}},"type":"if","locations":[{"start":{"line":289,"column":4},"end":{"line":292,"column":5}},{"start":{"line":289,"column":4},"end":{"line":292,"column":5}}]},"29":{"loc":{"start":{"line":295,"column":4},"end":{"line":299,"column":5}},"type":"if","locations":[{"start":{"line":295,"column":4},"end":{"line":299,"column":5}},{"start":{"line":295,"column":4},"end":{"line":299,"column":5}}]},"30":{"loc":{"start":{"line":303,"column":15},"end":{"line":303,"column":48}},"type":"cond-expr","locations":[{"start":{"line":303,"column":29},"end":{"line":303,"column":36}},{"start":{"line":303,"column":39},"end":{"line":303,"column":48}}]},"31":{"loc":{"start":{"line":307,"column":4},"end":{"line":311,"column":5}},"type":"if","locations":[{"start":{"line":307,"column":4},"end":{"line":311,"column":5}},{"start":{"line":307,"column":4},"end":{"line":311,"column":5}}]},"32":{"loc":{"start":{"line":313,"column":4},"end":{"line":316,"column":5}},"type":"if","locations":[{"start":{"line":313,"column":4},"end":{"line":316,"column":5}},{"start":{"line":313,"column":4},"end":{"line":316,"column":5}}]},"33":{"loc":{"start":{"line":323,"column":4},"end":{"line":323,"column":92}},"type":"if","locations":[{"start":{"line":323,"column":4},"end":{"line":323,"column":92}},{"start":{"line":323,"column":4},"end":{"line":323,"column":92}}]},"34":{"loc":{"start":{"line":323,"column":44},"end":{"line":323,"column":87}},"type":"cond-expr","locations":[{"start":{"line":323,"column":59},"end":{"line":323,"column":71}},{"start":{"line":323,"column":74},"end":{"line":323,"column":87}}]},"35":{"loc":{"start":{"line":324,"column":4},"end":{"line":324,"column":56}},"type":"if","locations":[{"start":{"line":324,"column":4},"end":{"line":324,"column":56}},{"start":{"line":324,"column":4},"end":{"line":324,"column":56}}]},"36":{"loc":{"start":{"line":325,"column":25},"end":{"line":325,"column":68}},"type":"cond-expr","locations":[{"start":{"line":325,"column":40},"end":{"line":325,"column":52}},{"start":{"line":325,"column":55},"end":{"line":325,"column":68}}]},"37":{"loc":{"start":{"line":330,"column":4},"end":{"line":334,"column":5}},"type":"if","locations":[{"start":{"line":330,"column":4},"end":{"line":334,"column":5}},{"start":{"line":330,"column":4},"end":{"line":334,"column":5}}]},"38":{"loc":{"start":{"line":340,"column":4},"end":{"line":348,"column":5}},"type":"if","locations":[{"start":{"line":340,"column":4},"end":{"line":348,"column":5}},{"start":{"line":340,"column":4},"end":{"line":348,"column":5}}]},"39":{"loc":{"start":{"line":341,"column":6},"end":{"line":346,"column":7}},"type":"if","locations":[{"start":{"line":341,"column":6},"end":{"line":346,"column":7}},{"start":{"line":341,"column":6},"end":{"line":346,"column":7}}]},"40":{"loc":{"start":{"line":341,"column":10},"end":{"line":341,"column":148}},"type":"binary-expr","locations":[{"start":{"line":341,"column":10},"end":{"line":341,"column":21}},{"start":{"line":341,"column":25},"end":{"line":341,"column":73}},{"start":{"line":341,"column":77},"end":{"line":341,"column":148}}]},"41":{"loc":{"start":{"line":350,"column":4},"end":{"line":354,"column":5}},"type":"if","locations":[{"start":{"line":350,"column":4},"end":{"line":354,"column":5}},{"start":{"line":350,"column":4},"end":{"line":354,"column":5}}]},"42":{"loc":{"start":{"line":361,"column":4},"end":{"line":365,"column":5}},"type":"if","locations":[{"start":{"line":361,"column":4},"end":{"line":365,"column":5}},{"start":{"line":361,"column":4},"end":{"line":365,"column":5}}]},"43":{"loc":{"start":{"line":362,"column":13},"end":{"line":362,"column":84}},"type":"cond-expr","locations":[{"start":{"line":362,"column":79},"end":{"line":362,"column":80}},{"start":{"line":362,"column":83},"end":{"line":362,"column":84}}]},"44":{"loc":{"start":{"line":362,"column":13},"end":{"line":362,"column":76}},"type":"binary-expr","locations":[{"start":{"line":362,"column":13},"end":{"line":362,"column":24}},{"start":{"line":362,"column":28},"end":{"line":362,"column":76}}]},"45":{"loc":{"start":{"line":363,"column":6},"end":{"line":363,"column":105}},"type":"if","locations":[{"start":{"line":363,"column":6},"end":{"line":363,"column":105}},{"start":{"line":363,"column":6},"end":{"line":363,"column":105}}]},"46":{"loc":{"start":{"line":367,"column":4},"end":{"line":373,"column":5}},"type":"if","locations":[{"start":{"line":367,"column":4},"end":{"line":373,"column":5}},{"start":{"line":367,"column":4},"end":{"line":373,"column":5}}]},"47":{"loc":{"start":{"line":367,"column":8},"end":{"line":367,"column":138}},"type":"binary-expr","locations":[{"start":{"line":367,"column":8},"end":{"line":367,"column":19}},{"start":{"line":367,"column":23},"end":{"line":367,"column":34}},{"start":{"line":367,"column":38},"end":{"line":367,"column":86}},{"start":{"line":367,"column":90},"end":{"line":367,"column":138}}]},"48":{"loc":{"start":{"line":368,"column":6},"end":{"line":368,"column":43}},"type":"if","locations":[{"start":{"line":368,"column":6},"end":{"line":368,"column":43}},{"start":{"line":368,"column":6},"end":{"line":368,"column":43}}]},"49":{"loc":{"start":{"line":375,"column":4},"end":{"line":378,"column":5}},"type":"if","locations":[{"start":{"line":375,"column":4},"end":{"line":378,"column":5}},{"start":{"line":375,"column":4},"end":{"line":378,"column":5}}]},"50":{"loc":{"start":{"line":385,"column":4},"end":{"line":385,"column":113}},"type":"if","locations":[{"start":{"line":385,"column":4},"end":{"line":385,"column":113}},{"start":{"line":385,"column":4},"end":{"line":385,"column":113}}]},"51":{"loc":{"start":{"line":385,"column":55},"end":{"line":385,"column":111}},"type":"cond-expr","locations":[{"start":{"line":385,"column":106},"end":{"line":385,"column":107}},{"start":{"line":385,"column":110},"end":{"line":385,"column":111}}]},"52":{"loc":{"start":{"line":386,"column":4},"end":{"line":389,"column":5}},"type":"if","locations":[{"start":{"line":386,"column":4},"end":{"line":389,"column":5}},{"start":{"line":386,"column":4},"end":{"line":389,"column":5}}]},"53":{"loc":{"start":{"line":386,"column":8},"end":{"line":386,"column":34}},"type":"binary-expr","locations":[{"start":{"line":386,"column":8},"end":{"line":386,"column":19}},{"start":{"line":386,"column":23},"end":{"line":386,"column":34}}]},"54":{"loc":{"start":{"line":390,"column":25},"end":{"line":390,"column":56}},"type":"cond-expr","locations":[{"start":{"line":390,"column":39},"end":{"line":390,"column":44}},{"start":{"line":390,"column":47},"end":{"line":390,"column":56}}]},"55":{"loc":{"start":{"line":394,"column":4},"end":{"line":467,"column":5}},"type":"switch","locations":[{"start":{"line":397,"column":6},"end":{"line":398,"column":36}},{"start":{"line":401,"column":6},"end":{"line":401,"column":68}},{"start":{"line":402,"column":6},"end":{"line":402,"column":68}},{"start":{"line":403,"column":6},"end":{"line":403,"column":66}},{"start":{"line":404,"column":6},"end":{"line":404,"column":67}},{"start":{"line":405,"column":6},"end":{"line":405,"column":70}},{"start":{"line":406,"column":6},"end":{"line":406,"column":70}},{"start":{"line":407,"column":6},"end":{"line":407,"column":69}},{"start":{"line":408,"column":6},"end":{"line":408,"column":69}},{"start":{"line":410,"column":6},"end":{"line":416,"column":9}},{"start":{"line":418,"column":6},"end":{"line":418,"column":70}},{"start":{"line":419,"column":6},"end":{"line":419,"column":64}},{"start":{"line":421,"column":6},"end":{"line":423,"column":46}},{"start":{"line":425,"column":6},"end":{"line":429,"column":71}},{"start":{"line":432,"column":6},"end":{"line":432,"column":14}},{"start":{"line":432,"column":15},"end":{"line":432,"column":23}},{"start":{"line":432,"column":24},"end":{"line":432,"column":32}},{"start":{"line":432,"column":33},"end":{"line":432,"column":41}},{"start":{"line":432,"column":42},"end":{"line":432,"column":50}},{"start":{"line":432,"column":51},"end":{"line":432,"column":59}},{"start":{"line":432,"column":60},"end":{"line":432,"column":68}},{"start":{"line":432,"column":69},"end":{"line":432,"column":77}},{"start":{"line":432,"column":78},"end":{"line":433,"column":38}},{"start":{"line":436,"column":6},"end":{"line":436,"column":14}},{"start":{"line":436,"column":15},"end":{"line":437,"column":37}},{"start":{"line":444,"column":6},"end":{"line":445,"column":38}},{"start":{"line":447,"column":6},"end":{"line":447,"column":14}},{"start":{"line":447,"column":15},"end":{"line":448,"column":48}},{"start":{"line":450,"column":6},"end":{"line":450,"column":15}},{"start":{"line":450,"column":16},"end":{"line":451,"column":45}},{"start":{"line":453,"column":6},"end":{"line":454,"column":38}},{"start":{"line":456,"column":6},"end":{"line":456,"column":14}},{"start":{"line":456,"column":15},"end":{"line":457,"column":45}},{"start":{"line":459,"column":6},"end":{"line":459,"column":14}},{"start":{"line":459,"column":15},"end":{"line":460,"column":42}},{"start":{"line":462,"column":6},"end":{"line":462,"column":14}},{"start":{"line":462,"column":15},"end":{"line":463,"column":44}},{"start":{"line":465,"column":6},"end":{"line":466,"column":43}}]},"56":{"loc":{"start":{"line":411,"column":8},"end":{"line":416,"column":9}},"type":"if","locations":[{"start":{"line":411,"column":8},"end":{"line":416,"column":9}},{"start":{"line":411,"column":8},"end":{"line":416,"column":9}}]},"57":{"loc":{"start":{"line":411,"column":12},"end":{"line":411,"column":94}},"type":"binary-expr","locations":[{"start":{"line":411,"column":12},"end":{"line":411,"column":42}},{"start":{"line":411,"column":46},"end":{"line":411,"column":94}}]},"58":{"loc":{"start":{"line":427,"column":8},"end":{"line":427,"column":73}},"type":"if","locations":[{"start":{"line":427,"column":8},"end":{"line":427,"column":73}},{"start":{"line":427,"column":8},"end":{"line":427,"column":73}}]},"59":{"loc":{"start":{"line":427,"column":12},"end":{"line":427,"column":39}},"type":"binary-expr","locations":[{"start":{"line":427,"column":12},"end":{"line":427,"column":24}},{"start":{"line":427,"column":28},"end":{"line":427,"column":39}}]},"60":{"loc":{"start":{"line":428,"column":8},"end":{"line":428,"column":72}},"type":"if","locations":[{"start":{"line":428,"column":8},"end":{"line":428,"column":72}},{"start":{"line":428,"column":8},"end":{"line":428,"column":72}}]},"61":{"loc":{"start":{"line":428,"column":12},"end":{"line":428,"column":39}},"type":"binary-expr","locations":[{"start":{"line":428,"column":12},"end":{"line":428,"column":24}},{"start":{"line":428,"column":28},"end":{"line":428,"column":39}}]},"62":{"loc":{"start":{"line":429,"column":8},"end":{"line":429,"column":71}},"type":"if","locations":[{"start":{"line":429,"column":8},"end":{"line":429,"column":71}},{"start":{"line":429,"column":8},"end":{"line":429,"column":71}}]},"63":{"loc":{"start":{"line":429,"column":12},"end":{"line":429,"column":38}},"type":"binary-expr","locations":[{"start":{"line":429,"column":12},"end":{"line":429,"column":23}},{"start":{"line":429,"column":27},"end":{"line":429,"column":38}}]},"64":{"loc":{"start":{"line":481,"column":6},"end":{"line":481,"column":100}},"type":"if","locations":[{"start":{"line":481,"column":6},"end":{"line":481,"column":100}},{"start":{"line":481,"column":6},"end":{"line":481,"column":100}}]},"65":{"loc":{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},"type":"if","locations":[{"start":{"line":483,"column":6},"end":{"line":485,"column":7}},{"start":{"line":483,"column":6},"end":{"line":485,"column":7}}]},"66":{"loc":{"start":{"line":486,"column":6},"end":{"line":497,"column":7}},"type":"if","locations":[{"start":{"line":486,"column":6},"end":{"line":497,"column":7}},{"start":{"line":486,"column":6},"end":{"line":497,"column":7}}]},"67":{"loc":{"start":{"line":489,"column":8},"end":{"line":495,"column":9}},"type":"if","locations":[{"start":{"line":489,"column":8},"end":{"line":495,"column":9}},{"start":{"line":489,"column":8},"end":{"line":495,"column":9}}]},"68":{"loc":{"start":{"line":491,"column":15},"end":{"line":495,"column":9}},"type":"if","locations":[{"start":{"line":491,"column":15},"end":{"line":495,"column":9}},{"start":{"line":491,"column":15},"end":{"line":495,"column":9}}]},"69":{"loc":{"start":{"line":491,"column":19},"end":{"line":491,"column":40}},"type":"binary-expr","locations":[{"start":{"line":491,"column":19},"end":{"line":491,"column":29}},{"start":{"line":491,"column":33},"end":{"line":491,"column":40}}]},"70":{"loc":{"start":{"line":493,"column":15},"end":{"line":495,"column":9}},"type":"if","locations":[{"start":{"line":493,"column":15},"end":{"line":495,"column":9}},{"start":{"line":493,"column":15},"end":{"line":495,"column":9}}]},"71":{"loc":{"start":{"line":493,"column":19},"end":{"line":493,"column":41}},"type":"binary-expr","locations":[{"start":{"line":493,"column":19},"end":{"line":493,"column":29}},{"start":{"line":493,"column":33},"end":{"line":493,"column":41}}]},"72":{"loc":{"start":{"line":505,"column":4},"end":{"line":508,"column":5}},"type":"if","locations":[{"start":{"line":505,"column":4},"end":{"line":508,"column":5}},{"start":{"line":505,"column":4},"end":{"line":508,"column":5}}]},"73":{"loc":{"start":{"line":507,"column":6},"end":{"line":507,"column":87}},"type":"if","locations":[{"start":{"line":507,"column":6},"end":{"line":507,"column":87}},{"start":{"line":507,"column":6},"end":{"line":507,"column":87}}]},"74":{"loc":{"start":{"line":523,"column":6},"end":{"line":531,"column":7}},"type":"if","locations":[{"start":{"line":523,"column":6},"end":{"line":531,"column":7}},{"start":{"line":523,"column":6},"end":{"line":531,"column":7}}]},"75":{"loc":{"start":{"line":525,"column":13},"end":{"line":531,"column":7}},"type":"if","locations":[{"start":{"line":525,"column":13},"end":{"line":531,"column":7}},{"start":{"line":525,"column":13},"end":{"line":531,"column":7}}]},"76":{"loc":{"start":{"line":527,"column":13},"end":{"line":531,"column":7}},"type":"if","locations":[{"start":{"line":527,"column":13},"end":{"line":531,"column":7}},{"start":{"line":527,"column":13},"end":{"line":531,"column":7}}]},"77":{"loc":{"start":{"line":527,"column":17},"end":{"line":527,"column":41}},"type":"binary-expr","locations":[{"start":{"line":527,"column":17},"end":{"line":527,"column":27}},{"start":{"line":527,"column":31},"end":{"line":527,"column":41}}]},"78":{"loc":{"start":{"line":532,"column":6},"end":{"line":532,"column":30}},"type":"if","locations":[{"start":{"line":532,"column":6},"end":{"line":532,"column":30}},{"start":{"line":532,"column":6},"end":{"line":532,"column":30}}]},"79":{"loc":{"start":{"line":536,"column":4},"end":{"line":536,"column":95}},"type":"if","locations":[{"start":{"line":536,"column":4},"end":{"line":536,"column":95}},{"start":{"line":536,"column":4},"end":{"line":536,"column":95}}]},"80":{"loc":{"start":{"line":536,"column":8},"end":{"line":536,"column":81}},"type":"binary-expr","locations":[{"start":{"line":536,"column":8},"end":{"line":536,"column":32}},{"start":{"line":536,"column":36},"end":{"line":536,"column":47}},{"start":{"line":536,"column":51},"end":{"line":536,"column":81}}]},"81":{"loc":{"start":{"line":544,"column":4},"end":{"line":544,"column":91}},"type":"if","locations":[{"start":{"line":544,"column":4},"end":{"line":544,"column":91}},{"start":{"line":544,"column":4},"end":{"line":544,"column":91}}]},"82":{"loc":{"start":{"line":545,"column":4},"end":{"line":545,"column":116}},"type":"if","locations":[{"start":{"line":545,"column":4},"end":{"line":545,"column":116}},{"start":{"line":545,"column":4},"end":{"line":545,"column":116}}]},"83":{"loc":{"start":{"line":553,"column":4},"end":{"line":553,"column":89}},"type":"if","locations":[{"start":{"line":553,"column":4},"end":{"line":553,"column":89}},{"start":{"line":553,"column":4},"end":{"line":553,"column":89}}]},"84":{"loc":{"start":{"line":553,"column":8},"end":{"line":553,"column":51}},"type":"binary-expr","locations":[{"start":{"line":553,"column":8},"end":{"line":553,"column":22}},{"start":{"line":553,"column":26},"end":{"line":553,"column":51}}]},"85":{"loc":{"start":{"line":555,"column":4},"end":{"line":560,"column":5}},"type":"if","locations":[{"start":{"line":555,"column":4},"end":{"line":560,"column":5}},{"start":{"line":555,"column":4},"end":{"line":560,"column":5}}]},"86":{"loc":{"start":{"line":561,"column":4},"end":{"line":566,"column":5}},"type":"if","locations":[{"start":{"line":561,"column":4},"end":{"line":566,"column":5}},{"start":{"line":561,"column":4},"end":{"line":566,"column":5}}]},"87":{"loc":{"start":{"line":561,"column":8},"end":{"line":561,"column":35}},"type":"binary-expr","locations":[{"start":{"line":561,"column":8},"end":{"line":561,"column":19}},{"start":{"line":561,"column":23},"end":{"line":561,"column":35}}]},"88":{"loc":{"start":{"line":563,"column":6},"end":{"line":563,"column":55}},"type":"if","locations":[{"start":{"line":563,"column":6},"end":{"line":563,"column":55}},{"start":{"line":563,"column":6},"end":{"line":563,"column":55}}]},"89":{"loc":{"start":{"line":563,"column":10},"end":{"line":563,"column":36}},"type":"binary-expr","locations":[{"start":{"line":563,"column":10},"end":{"line":563,"column":21}},{"start":{"line":563,"column":25},"end":{"line":563,"column":36}}]},"90":{"loc":{"start":{"line":564,"column":6},"end":{"line":564,"column":73}},"type":"if","locations":[{"start":{"line":564,"column":6},"end":{"line":564,"column":73}},{"start":{"line":564,"column":6},"end":{"line":564,"column":73}}]},"91":{"loc":{"start":{"line":567,"column":4},"end":{"line":567,"column":116}},"type":"if","locations":[{"start":{"line":567,"column":4},"end":{"line":567,"column":116}},{"start":{"line":567,"column":4},"end":{"line":567,"column":116}}]},"92":{"loc":{"start":{"line":570,"column":4},"end":{"line":578,"column":5}},"type":"if","locations":[{"start":{"line":570,"column":4},"end":{"line":578,"column":5}},{"start":{"line":570,"column":4},"end":{"line":578,"column":5}}]},"93":{"loc":{"start":{"line":572,"column":11},"end":{"line":578,"column":5}},"type":"if","locations":[{"start":{"line":572,"column":11},"end":{"line":578,"column":5}},{"start":{"line":572,"column":11},"end":{"line":578,"column":5}}]},"94":{"loc":{"start":{"line":572,"column":15},"end":{"line":572,"column":41}},"type":"binary-expr","locations":[{"start":{"line":572,"column":15},"end":{"line":572,"column":21}},{"start":{"line":572,"column":25},"end":{"line":572,"column":41}}]},"95":{"loc":{"start":{"line":574,"column":11},"end":{"line":578,"column":5}},"type":"if","locations":[{"start":{"line":574,"column":11},"end":{"line":578,"column":5}},{"start":{"line":574,"column":11},"end":{"line":578,"column":5}}]},"96":{"loc":{"start":{"line":574,"column":15},"end":{"line":574,"column":52}},"type":"binary-expr","locations":[{"start":{"line":574,"column":15},"end":{"line":574,"column":31}},{"start":{"line":574,"column":35},"end":{"line":574,"column":52}}]},"97":{"loc":{"start":{"line":587,"column":4},"end":{"line":594,"column":5}},"type":"if","locations":[{"start":{"line":587,"column":4},"end":{"line":594,"column":5}},{"start":{"line":587,"column":4},"end":{"line":594,"column":5}}]},"98":{"loc":{"start":{"line":591,"column":6},"end":{"line":591,"column":75}},"type":"if","locations":[{"start":{"line":591,"column":6},"end":{"line":591,"column":75}},{"start":{"line":591,"column":6},"end":{"line":591,"column":75}}]},"99":{"loc":{"start":{"line":601,"column":6},"end":{"line":601,"column":108}},"type":"if","locations":[{"start":{"line":601,"column":6},"end":{"line":601,"column":108}},{"start":{"line":601,"column":6},"end":{"line":601,"column":108}}]},"100":{"loc":{"start":{"line":603,"column":6},"end":{"line":603,"column":30}},"type":"if","locations":[{"start":{"line":603,"column":6},"end":{"line":603,"column":30}},{"start":{"line":603,"column":6},"end":{"line":603,"column":30}}]},"101":{"loc":{"start":{"line":604,"column":6},"end":{"line":611,"column":7}},"type":"if","locations":[{"start":{"line":604,"column":6},"end":{"line":611,"column":7}},{"start":{"line":604,"column":6},"end":{"line":611,"column":7}}]},"102":{"loc":{"start":{"line":609,"column":8},"end":{"line":609,"column":88}},"type":"if","locations":[{"start":{"line":609,"column":8},"end":{"line":609,"column":88}},{"start":{"line":609,"column":8},"end":{"line":609,"column":88}}]},"103":{"loc":{"start":{"line":622,"column":6},"end":{"line":622,"column":101}},"type":"if","locations":[{"start":{"line":622,"column":6},"end":{"line":622,"column":101}},{"start":{"line":622,"column":6},"end":{"line":622,"column":101}}]},"104":{"loc":{"start":{"line":624,"column":6},"end":{"line":636,"column":7}},"type":"if","locations":[{"start":{"line":624,"column":6},"end":{"line":636,"column":7}},{"start":{"line":624,"column":6},"end":{"line":636,"column":7}}]},"105":{"loc":{"start":{"line":624,"column":10},"end":{"line":624,"column":85}},"type":"binary-expr","locations":[{"start":{"line":624,"column":10},"end":{"line":624,"column":19}},{"start":{"line":624,"column":23},"end":{"line":624,"column":32}},{"start":{"line":624,"column":36},"end":{"line":624,"column":85}}]},"106":{"loc":{"start":{"line":625,"column":8},"end":{"line":633,"column":9}},"type":"if","locations":[{"start":{"line":625,"column":8},"end":{"line":633,"column":9}},{"start":{"line":625,"column":8},"end":{"line":633,"column":9}}]},"107":{"loc":{"start":{"line":625,"column":12},"end":{"line":625,"column":74}},"type":"binary-expr","locations":[{"start":{"line":625,"column":12},"end":{"line":625,"column":47}},{"start":{"line":625,"column":51},"end":{"line":625,"column":74}}]},"108":{"loc":{"start":{"line":626,"column":10},"end":{"line":632,"column":11}},"type":"if","locations":[{"start":{"line":626,"column":10},"end":{"line":632,"column":11}},{"start":{"line":626,"column":10},"end":{"line":632,"column":11}}]},"109":{"loc":{"start":{"line":637,"column":6},"end":{"line":659,"column":7}},"type":"if","locations":[{"start":{"line":637,"column":6},"end":{"line":659,"column":7}},{"start":{"line":637,"column":6},"end":{"line":659,"column":7}}]},"110":{"loc":{"start":{"line":641,"column":13},"end":{"line":659,"column":7}},"type":"if","locations":[{"start":{"line":641,"column":13},"end":{"line":659,"column":7}},{"start":{"line":641,"column":13},"end":{"line":659,"column":7}}]},"111":{"loc":{"start":{"line":644,"column":8},"end":{"line":653,"column":9}},"type":"switch","locations":[{"start":{"line":645,"column":10},"end":{"line":646,"column":79}},{"start":{"line":647,"column":10},"end":{"line":649,"column":18}},{"start":{"line":650,"column":10},"end":{"line":652,"column":18}}]},"112":{"loc":{"start":{"line":646,"column":12},"end":{"line":646,"column":79}},"type":"if","locations":[{"start":{"line":646,"column":12},"end":{"line":646,"column":79}},{"start":{"line":646,"column":12},"end":{"line":646,"column":79}}]},"113":{"loc":{"start":{"line":668,"column":4},"end":{"line":703,"column":5}},"type":"switch","locations":[{"start":{"line":669,"column":6},"end":{"line":669,"column":28}},{"start":{"line":670,"column":6},"end":{"line":670,"column":28}},{"start":{"line":671,"column":6},"end":{"line":671,"column":64}},{"start":{"line":672,"column":6},"end":{"line":672,"column":63}},{"start":{"line":673,"column":6},"end":{"line":673,"column":28}},{"start":{"line":674,"column":6},"end":{"line":674,"column":27}},{"start":{"line":675,"column":6},"end":{"line":675,"column":32}},{"start":{"line":676,"column":6},"end":{"line":676,"column":28}},{"start":{"line":677,"column":6},"end":{"line":677,"column":82}},{"start":{"line":678,"column":6},"end":{"line":681,"column":18}},{"start":{"line":682,"column":6},"end":{"line":702,"column":39}}]},"114":{"loc":{"start":{"line":677,"column":15},"end":{"line":677,"column":82}},"type":"if","locations":[{"start":{"line":677,"column":15},"end":{"line":677,"column":82}},{"start":{"line":677,"column":15},"end":{"line":677,"column":82}}]},"115":{"loc":{"start":{"line":683,"column":8},"end":{"line":701,"column":9}},"type":"if","locations":[{"start":{"line":683,"column":8},"end":{"line":701,"column":9}},{"start":{"line":683,"column":8},"end":{"line":701,"column":9}}]},"116":{"loc":{"start":{"line":683,"column":12},"end":{"line":683,"column":32}},"type":"binary-expr","locations":[{"start":{"line":683,"column":12},"end":{"line":683,"column":20}},{"start":{"line":683,"column":24},"end":{"line":683,"column":32}}]},"117":{"loc":{"start":{"line":686,"column":10},"end":{"line":689,"column":11}},"type":"if","locations":[{"start":{"line":686,"column":10},"end":{"line":689,"column":11}},{"start":{"line":686,"column":10},"end":{"line":689,"column":11}}]},"118":{"loc":{"start":{"line":690,"column":10},"end":{"line":698,"column":11}},"type":"if","locations":[{"start":{"line":690,"column":10},"end":{"line":698,"column":11}},{"start":{"line":690,"column":10},"end":{"line":698,"column":11}}]},"119":{"loc":{"start":{"line":691,"column":12},"end":{"line":694,"column":13}},"type":"if","locations":[{"start":{"line":691,"column":12},"end":{"line":694,"column":13}},{"start":{"line":691,"column":12},"end":{"line":694,"column":13}}]},"120":{"loc":{"start":{"line":695,"column":12},"end":{"line":697,"column":13}},"type":"if","locations":[{"start":{"line":695,"column":12},"end":{"line":697,"column":13}},{"start":{"line":695,"column":12},"end":{"line":697,"column":13}}]},"121":{"loc":{"start":{"line":695,"column":16},"end":{"line":695,"column":47}},"type":"binary-expr","locations":[{"start":{"line":695,"column":16},"end":{"line":695,"column":33}},{"start":{"line":695,"column":37},"end":{"line":695,"column":47}}]},"122":{"loc":{"start":{"line":711,"column":4},"end":{"line":711,"column":73}},"type":"if","locations":[{"start":{"line":711,"column":4},"end":{"line":711,"column":73}},{"start":{"line":711,"column":4},"end":{"line":711,"column":73}}]},"123":{"loc":{"start":{"line":726,"column":6},"end":{"line":748,"column":7}},"type":"if","locations":[{"start":{"line":726,"column":6},"end":{"line":748,"column":7}},{"start":{"line":726,"column":6},"end":{"line":748,"column":7}}]},"124":{"loc":{"start":{"line":727,"column":26},"end":{"line":727,"column":46}},"type":"cond-expr","locations":[{"start":{"line":727,"column":41},"end":{"line":727,"column":42}},{"start":{"line":727,"column":45},"end":{"line":727,"column":46}}]},"125":{"loc":{"start":{"line":728,"column":13},"end":{"line":748,"column":7}},"type":"if","locations":[{"start":{"line":728,"column":13},"end":{"line":748,"column":7}},{"start":{"line":728,"column":13},"end":{"line":748,"column":7}}]},"126":{"loc":{"start":{"line":734,"column":8},"end":{"line":736,"column":9}},"type":"if","locations":[{"start":{"line":734,"column":8},"end":{"line":736,"column":9}},{"start":{"line":734,"column":8},"end":{"line":736,"column":9}}]},"127":{"loc":{"start":{"line":740,"column":8},"end":{"line":742,"column":9}},"type":"if","locations":[{"start":{"line":740,"column":8},"end":{"line":742,"column":9}},{"start":{"line":740,"column":8},"end":{"line":742,"column":9}}]},"128":{"loc":{"start":{"line":740,"column":14},"end":{"line":740,"column":58}},"type":"cond-expr","locations":[{"start":{"line":740,"column":22},"end":{"line":740,"column":39}},{"start":{"line":740,"column":42},"end":{"line":740,"column":58}}]},"129":{"loc":{"start":{"line":760,"column":4},"end":{"line":762,"column":5}},"type":"if","locations":[{"start":{"line":760,"column":4},"end":{"line":762,"column":5}},{"start":{"line":760,"column":4},"end":{"line":762,"column":5}}]},"130":{"loc":{"start":{"line":760,"column":8},"end":{"line":760,"column":55}},"type":"binary-expr","locations":[{"start":{"line":760,"column":8},"end":{"line":760,"column":31}},{"start":{"line":760,"column":35},"end":{"line":760,"column":55}}]},"131":{"loc":{"start":{"line":767,"column":4},"end":{"line":772,"column":5}},"type":"if","locations":[{"start":{"line":767,"column":4},"end":{"line":772,"column":5}},{"start":{"line":767,"column":4},"end":{"line":772,"column":5}}]},"132":{"loc":{"start":{"line":769,"column":6},"end":{"line":771,"column":7}},"type":"if","locations":[{"start":{"line":769,"column":6},"end":{"line":771,"column":7}},{"start":{"line":769,"column":6},"end":{"line":771,"column":7}}]},"133":{"loc":{"start":{"line":769,"column":10},"end":{"line":769,"column":71}},"type":"binary-expr","locations":[{"start":{"line":769,"column":10},"end":{"line":769,"column":38}},{"start":{"line":769,"column":42},"end":{"line":769,"column":71}}]},"134":{"loc":{"start":{"line":774,"column":4},"end":{"line":776,"column":5}},"type":"if","locations":[{"start":{"line":774,"column":4},"end":{"line":776,"column":5}},{"start":{"line":774,"column":4},"end":{"line":776,"column":5}}]},"135":{"loc":{"start":{"line":778,"column":4},"end":{"line":780,"column":5}},"type":"if","locations":[{"start":{"line":778,"column":4},"end":{"line":780,"column":5}},{"start":{"line":778,"column":4},"end":{"line":780,"column":5}}]},"136":{"loc":{"start":{"line":778,"column":8},"end":{"line":778,"column":102}},"type":"binary-expr","locations":[{"start":{"line":778,"column":8},"end":{"line":778,"column":29}},{"start":{"line":778,"column":33},"end":{"line":778,"column":53}},{"start":{"line":778,"column":57},"end":{"line":778,"column":76}},{"start":{"line":778,"column":80},"end":{"line":778,"column":102}}]},"137":{"loc":{"start":{"line":782,"column":4},"end":{"line":784,"column":5}},"type":"if","locations":[{"start":{"line":782,"column":4},"end":{"line":784,"column":5}},{"start":{"line":782,"column":4},"end":{"line":784,"column":5}}]},"138":{"loc":{"start":{"line":791,"column":4},"end":{"line":797,"column":5}},"type":"if","locations":[{"start":{"line":791,"column":4},"end":{"line":797,"column":5}},{"start":{"line":791,"column":4},"end":{"line":797,"column":5}}]},"139":{"loc":{"start":{"line":791,"column":8},"end":{"line":791,"column":43}},"type":"binary-expr","locations":[{"start":{"line":791,"column":8},"end":{"line":791,"column":20}},{"start":{"line":791,"column":24},"end":{"line":791,"column":43}}]},"140":{"loc":{"start":{"line":793,"column":11},"end":{"line":797,"column":5}},"type":"if","locations":[{"start":{"line":793,"column":11},"end":{"line":797,"column":5}},{"start":{"line":793,"column":11},"end":{"line":797,"column":5}}]}},"s":{"1":17531,"2":17531,"3":17531,"4":17531,"5":17531,"6":31,"7":21,"8":10,"9":2123,"10":2123,"11":17576,"12":17531,"13":17576,"14":17576,"15":17576,"16":17576,"17":17576,"18":32202,"19":8994,"20":8976,"21":23208,"22":83412,"23":5955,"24":45,"25":45,"26":45,"27":45,"28":45,"29":45,"30":45,"31":45,"32":235,"33":235,"34":223,"35":12,"36":12,"37":0,"38":0,"39":12,"40":24694,"41":19714,"42":19714,"43":19546,"44":19702,"45":19702,"46":19702,"47":19702,"48":19702,"49":2934,"50":16768,"51":87,"52":16681,"53":16195,"54":5944,"55":10251,"56":45377,"57":45377,"58":45165,"59":212,"60":212,"61":88,"62":88,"63":87,"64":87,"65":87,"66":59,"67":59,"68":59,"69":59,"70":12,"71":47,"72":47,"73":47,"74":34,"75":34,"76":47,"77":41,"78":41,"79":41,"80":41,"81":468,"82":468,"83":41,"84":19549,"85":19549,"86":24559,"87":24559,"88":7518,"89":7518,"90":6,"91":0,"92":342,"93":342,"94":342,"95":342,"96":200,"97":59,"98":47,"99":36,"100":36,"101":105,"102":83,"103":16499,"104":2,"105":16497,"106":19578,"107":19578,"108":19578,"109":19578,"110":19578,"111":19578,"112":232,"113":232,"114":3,"115":229,"116":229,"117":116,"118":116,"119":113,"120":113,"121":105,"122":37,"123":37,"124":68,"125":68,"126":2,"127":66,"128":166,"129":166,"130":166,"131":166,"132":15,"133":15,"134":15,"135":166,"136":5,"137":5,"138":166,"139":68,"140":68,"141":22,"142":46,"143":4,"144":42,"145":13,"146":13,"147":2,"148":11,"149":170,"150":170,"151":68,"152":1,"153":1,"154":1,"155":67,"156":102,"157":5,"158":97,"159":118,"160":118,"161":118,"162":19,"163":19,"164":6,"165":13,"166":99,"167":2,"168":0,"169":2,"170":2,"171":2,"172":97,"173":7,"174":97,"175":707,"176":707,"177":9,"178":698,"179":175,"180":175,"181":523,"182":10266,"183":232,"184":1488,"185":1488,"186":1321,"187":1321,"188":788,"189":788,"190":392,"191":392,"192":239,"193":239,"194":239,"195":239,"196":1379,"197":1379,"198":1188,"199":1188,"200":483,"201":0,"202":483,"203":483,"204":51,"205":51,"206":33,"207":33,"208":35,"209":35,"210":189,"211":189,"212":13,"213":176,"214":36,"215":140,"216":34,"217":588,"218":371,"219":105,"220":166,"221":68,"222":13,"223":170,"224":118,"225":707,"226":2,"227":7,"228":1261,"229":1261,"230":1261,"231":37,"232":37,"233":206,"234":4,"235":202,"236":202,"237":5,"238":197,"239":7,"240":190,"241":10,"242":180,"243":10,"244":170,"245":28,"246":162,"247":169,"248":28,"249":28,"250":28,"251":27,"252":10,"253":10,"254":1,"255":26,"256":740,"257":740,"258":740,"259":1906,"260":1906,"261":67,"262":1839,"263":47,"264":1792,"265":1187,"266":605,"267":1906,"268":703,"269":1203,"270":1203,"271":740,"272":41,"273":699,"274":83,"275":83,"276":83,"277":18,"278":65,"279":10,"280":55,"281":591,"282":591,"283":591,"284":591,"285":0,"286":591,"287":591,"288":10,"289":10,"290":10,"291":10,"292":591,"293":11,"294":11,"295":7,"296":11,"297":8,"298":3,"299":583,"300":10,"301":573,"302":573,"303":11,"304":562,"305":539,"306":23,"307":9,"308":14,"309":564,"310":40,"311":40,"312":21,"313":21,"314":18,"315":18,"316":1,"317":19,"318":29,"319":371,"320":371,"321":371,"322":3274,"323":4,"324":3270,"325":3270,"326":346,"327":2924,"328":92,"329":92,"330":73,"331":2832,"332":2,"333":2830,"334":346,"335":346,"336":87,"337":87,"338":87,"339":227,"340":2,"341":225,"342":225,"343":82,"344":41,"345":17,"346":17,"347":24,"348":24,"349":41,"350":41,"351":143,"352":15,"353":15,"354":12,"355":128,"356":3,"357":3,"358":3,"359":1,"360":0,"361":3,"362":3,"363":0,"364":0,"365":3,"366":3,"367":3,"368":125,"369":107,"370":107,"371":107,"372":8,"373":4,"374":8,"375":19,"376":2,"377":2,"378":2,"379":2,"380":0,"381":0,"382":4,"383":4,"384":4,"385":56,"386":25,"387":25,"388":25,"389":2,"390":2,"391":25,"392":23,"393":23,"394":23,"395":23,"396":9,"397":16,"398":16,"399":31,"400":48,"401":48,"402":48,"403":13,"404":35,"405":5972,"406":5972,"407":5972,"408":5972,"409":5972,"410":28048,"411":28048,"412":22348,"413":5700,"414":27,"415":27,"416":27,"417":27,"418":6,"419":21,"420":21,"421":20,"422":5,"423":15,"424":15,"425":5673,"426":22363,"427":5960,"428":5944,"429":5933,"430":5933,"431":1971,"432":5933,"433":1353,"434":34,"435":34,"436":32,"437":1321,"438":2,"439":1319,"440":689,"441":630,"442":4,"443":626,"444":19525,"445":19525,"446":14,"447":19511,"448":10074,"449":9437},"f":{"1":17531,"2":31,"3":2123,"4":17576,"5":32202,"6":83412,"7":5955,"8":45,"9":235,"10":24694,"11":19714,"12":16195,"13":45377,"14":88,"15":59,"16":41,"17":19549,"18":19578,"19":232,"20":105,"21":166,"22":68,"23":13,"24":170,"25":118,"26":707,"27":10266,"28":1261,"29":37,"30":740,"31":83,"32":591,"33":40,"34":371,"35":87,"36":107,"37":48,"38":5972,"39":5944,"40":1353,"41":19525},"b":{"1":[21,10],"2":[17531,45],"3":[8994,23208],"4":[223,12],"5":[235,227],"6":[19546,168],"7":[19714,19714],"8":[2934,16768],"9":[87,16681],"10":[5944,10251],"11":[16195,10264],"12":[45165,212],"13":[45377,212],"14":[47,41],"15":[87,1],"16":[12,47],"17":[81,48],"18":[509,503,470,468,468],"19":[7518,7518,6,341,342,342,200,16499],"20":[0,6],"21":[59,36,105],"22":[2,16497],"23":[16499,16499,16499,21],"24":[3,229],"25":[232,109],"26":[116,113],"27":[229,119],"28":[37,68],"29":[2,66],"30":[156,10],"31":[15,151],"32":[5,161],"33":[22,46],"34":[11,11],"35":[4,42],"36":[28,14],"37":[2,11],"38":[68,102],"39":[1,67],"40":[68,32,2],"41":[5,97],"42":[19,99],"43":[5,14],"44":[19,9],"45":[6,13],"46":[2,97],"47":[99,2,2,2],"48":[0,2],"49":[7,90],"50":[9,698],"51":[4,5],"52":[175,523],"53":[698,695],"54":[520,3],"55":[232,1488,1321,788,392,239,239,1379,1188,483,51,33,35,189,301,359,407,564,574,578,580,588,588,250,371,105,10,166,41,68,13,105,170,104,118,700,707,2],"56":[0,483],"57":[483,0],"58":[13,176],"59":[189,178],"60":[36,140],"61":[176,156],"62":[34,106],"63":[140,122],"64":[4,202],"65":[5,197],"66":[7,190],"67":[10,180],"68":[10,170],"69":[180,10],"70":[28,142],"71":[170,28],"72":[10,17],"73":[1,9],"74":[67,1839],"75":[47,1792],"76":[1187,605],"77":[1792,1313],"78":[703,1203],"79":[41,699],"80":[740,704,40],"81":[18,65],"82":[10,55],"83":[0,591],"84":[591,588],"85":[10,581],"86":[11,580],"87":[591,591],"88":[7,4],"89":[11,7],"90":[8,3],"91":[10,573],"92":[11,562],"93":[539,23],"94":[562,103],"95":[9,14],"96":[23,21],"97":[21,19],"98":[1,17],"99":[4,3270],"100":[346,2924],"101":[92,2832],"102":[2,2830],"103":[2,225],"104":[82,143],"105":[225,177,36],"106":[41,41],"107":[82,49],"108":[17,24],"109":[15,128],"110":[3,125],"111":[1,3,0],"112":[0,1],"113":[8,4,8,19,2,2,2,2,0,4,56],"114":[0,0],"115":[25,31],"116":[56,52],"117":[2,23],"118":[23,2],"119":[23,0],"120":[9,14],"121":[23,16],"122":[13,35],"123":[22348,5700],"124":[22342,6],"125":[27,5673],"126":[6,21],"127":[5,15],"128":[10,10],"129":[1971,3962],"130":[5933,5922],"131":[34,1319],"132":[32,2],"133":[34,11],"134":[2,1319],"135":[689,630],"136":[1319,1319,1319,1280],"137":[4,626],"138":[14,19511],"139":[19525,1971],"140":[10074,9437]},"hash":"0b96307dcefc47464133f55be59f5ce3b08338ca"}
+,"/home/travis/build/babel/babylon/src/tokenizer/state.js": {"path":"/home/travis/build/babel/babylon/src/tokenizer/state.js","statementMap":{"1":{"start":{"line":9,"column":4},"end":{"line":9,"column":89}},"2":{"start":{"line":11,"column":4},"end":{"line":11,"column":23}},"3":{"start":{"line":13,"column":4},"end":{"line":13,"column":31}},"4":{"start":{"line":15,"column":4},"end":{"line":15,"column":78}},"5":{"start":{"line":17,"column":4},"end":{"line":17,"column":21}},"6":{"start":{"line":19,"column":4},"end":{"line":19,"column":25}},"7":{"start":{"line":21,"column":4},"end":{"line":21,"column":21}},"8":{"start":{"line":23,"column":4},"end":{"line":23,"column":23}},"9":{"start":{"line":25,"column":4},"end":{"line":25,"column":31}},"10":{"start":{"line":26,"column":4},"end":{"line":26,"column":31}},"11":{"start":{"line":27,"column":4},"end":{"line":27,"column":31}},"12":{"start":{"line":29,"column":4},"end":{"line":29,"column":34}},"13":{"start":{"line":30,"column":4},"end":{"line":30,"column":21}},"14":{"start":{"line":32,"column":4},"end":{"line":32,"column":23}},"15":{"start":{"line":33,"column":4},"end":{"line":33,"column":22}},"16":{"start":{"line":34,"column":4},"end":{"line":34,"column":37}},"17":{"start":{"line":35,"column":4},"end":{"line":35,"column":53}},"18":{"start":{"line":37,"column":4},"end":{"line":37,"column":53}},"19":{"start":{"line":38,"column":4},"end":{"line":38,"column":51}},"20":{"start":{"line":40,"column":4},"end":{"line":40,"column":39}},"21":{"start":{"line":41,"column":4},"end":{"line":41,"column":28}},"22":{"start":{"line":43,"column":4},"end":{"line":43,"column":50}},"23":{"start":{"line":44,"column":4},"end":{"line":44,"column":30}},"24":{"start":{"line":46,"column":4},"end":{"line":46,"column":16}},"25":{"start":{"line":123,"column":4},"end":{"line":123,"column":65}},"26":{"start":{"line":127,"column":16},"end":{"line":127,"column":25}},"27":{"start":{"line":128,"column":4},"end":{"line":136,"column":5}},"28":{"start":{"line":129,"column":16},"end":{"line":129,"column":25}},"29":{"start":{"line":131,"column":6},"end":{"line":133,"column":7}},"30":{"start":{"line":132,"column":8},"end":{"line":132,"column":26}},"31":{"start":{"line":135,"column":6},"end":{"line":135,"column":23}},"32":{"start":{"line":137,"column":4},"end":{"line":137,"column":17}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":8,"column":2},"end":{"line":8,"column":3}},"loc":{"start":{"line":8,"column":39},"end":{"line":47,"column":3}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":122,"column":2},"end":{"line":122,"column":3}},"loc":{"start":{"line":122,"column":16},"end":{"line":124,"column":3}}},"3":{"name":"(anonymous_3)","decl":{"start":{"line":126,"column":2},"end":{"line":126,"column":3}},"loc":{"start":{"line":126,"column":21},"end":{"line":138,"column":3}}}},"branchMap":{"1":{"loc":{"start":{"line":9,"column":18},"end":{"line":9,"column":88}},"type":"cond-expr","locations":[{"start":{"line":9,"column":49},"end":{"line":9,"column":54}},{"start":{"line":9,"column":57},"end":{"line":9,"column":88}}]},"2":{"loc":{"start":{"line":131,"column":6},"end":{"line":133,"column":7}},"type":"if","locations":[{"start":{"line":131,"column":6},"end":{"line":133,"column":7}},{"start":{"line":131,"column":6},"end":{"line":133,"column":7}}]},"3":{"loc":{"start":{"line":131,"column":10},"end":{"line":131,"column":66}},"type":"binary-expr","locations":[{"start":{"line":131,"column":11},"end":{"line":131,"column":22}},{"start":{"line":131,"column":26},"end":{"line":131,"column":43}},{"start":{"line":131,"column":48},"end":{"line":131,"column":66}}]}},"s":{"1":2123,"2":2123,"3":2123,"4":2123,"5":2123,"6":2123,"7":2123,"8":2123,"9":2123,"10":2123,"11":2123,"12":2123,"13":2123,"14":2123,"15":2123,"16":2123,"17":2123,"18":2123,"19":2123,"20":2123,"21":2123,"22":2123,"23":2123,"24":2123,"25":41591,"26":176,"27":176,"28":5777,"29":5777,"30":778,"31":5777,"32":176},"f":{"1":2123,"2":41591,"3":176},"b":{"1":[0,2123],"2":[778,4999],"3":[5777,2978,2889]},"hash":"40e48b5bafa0a3ee9e647b1359b7d05ab7ff395f"}
+,"/home/travis/build/babel/babylon/src/tokenizer/types.js": {"path":"/home/travis/build/babel/babylon/src/tokenizer/types.js","statementMap":{"1":{"start":{"line":21,"column":4},"end":{"line":21,"column":23}},"2":{"start":{"line":22,"column":4},"end":{"line":22,"column":32}},"3":{"start":{"line":23,"column":4},"end":{"line":23,"column":40}},"4":{"start":{"line":24,"column":4},"end":{"line":24,"column":40}},"5":{"start":{"line":25,"column":4},"end":{"line":25,"column":52}},"6":{"start":{"line":26,"column":4},"end":{"line":26,"column":32}},"7":{"start":{"line":27,"column":4},"end":{"line":27,"column":36}},"8":{"start":{"line":28,"column":4},"end":{"line":28,"column":32}},"9":{"start":{"line":29,"column":4},"end":{"line":29,"column":34}},"10":{"start":{"line":30,"column":4},"end":{"line":30,"column":36}},"11":{"start":{"line":31,"column":4},"end":{"line":31,"column":30}},"12":{"start":{"line":36,"column":2},"end":{"line":36,"column":62}},"13":{"start":{"line":38,"column":19},"end":{"line":38,"column":37}},"14":{"start":{"line":38,"column":52},"end":{"line":38,"column":70}},"15":{"start":{"line":40,"column":21},"end":{"line":98,"column":1}},"16":{"start":{"line":102,"column":24},"end":{"line":102,"column":26}},"17":{"start":{"line":106,"column":2},"end":{"line":106,"column":25}},"18":{"start":{"line":107,"column":2},"end":{"line":107,"column":68}},"19":{"start":{"line":110,"column":0},"end":{"line":110,"column":12}},"20":{"start":{"line":111,"column":0},"end":{"line":111,"column":23}},"21":{"start":{"line":112,"column":0},"end":{"line":112,"column":12}},"22":{"start":{"line":113,"column":0},"end":{"line":113,"column":15}},"23":{"start":{"line":114,"column":0},"end":{"line":114,"column":15}},"24":{"start":{"line":115,"column":0},"end":{"line":115,"column":26}},"25":{"start":{"line":116,"column":0},"end":{"line":116,"column":43}},"26":{"start":{"line":117,"column":0},"end":{"line":117,"column":23}},"27":{"start":{"line":118,"column":0},"end":{"line":118,"column":14}},"28":{"start":{"line":119,"column":0},"end":{"line":119,"column":26}},"29":{"start":{"line":120,"column":0},"end":{"line":120,"column":27}},"30":{"start":{"line":121,"column":0},"end":{"line":121,"column":9}},"31":{"start":{"line":122,"column":0},"end":{"line":122,"column":25}},"32":{"start":{"line":123,"column":0},"end":{"line":123,"column":13}},"33":{"start":{"line":124,"column":0},"end":{"line":124,"column":24}},"34":{"start":{"line":125,"column":0},"end":{"line":125,"column":10}},"35":{"start":{"line":126,"column":0},"end":{"line":126,"column":10}},"36":{"start":{"line":127,"column":0},"end":{"line":127,"column":10}},"37":{"start":{"line":128,"column":0},"end":{"line":128,"column":12}},"38":{"start":{"line":129,"column":0},"end":{"line":129,"column":28}},"39":{"start":{"line":130,"column":0},"end":{"line":130,"column":11}},"40":{"start":{"line":131,"column":0},"end":{"line":131,"column":48}},"41":{"start":{"line":132,"column":0},"end":{"line":132,"column":23}},"42":{"start":{"line":133,"column":0},"end":{"line":133,"column":24}},"43":{"start":{"line":134,"column":0},"end":{"line":134,"column":12}},"44":{"start":{"line":135,"column":0},"end":{"line":135,"column":26}},"45":{"start":{"line":136,"column":0},"end":{"line":136,"column":13}},"46":{"start":{"line":137,"column":0},"end":{"line":137,"column":13}},"47":{"start":{"line":138,"column":0},"end":{"line":138,"column":50}},"48":{"start":{"line":139,"column":0},"end":{"line":139,"column":23}},"49":{"start":{"line":140,"column":0},"end":{"line":140,"column":23}},"50":{"start":{"line":141,"column":0},"end":{"line":141,"column":24}},"51":{"start":{"line":142,"column":0},"end":{"line":142,"column":39}},"52":{"start":{"line":143,"column":0},"end":{"line":143,"column":47}},"53":{"start":{"line":144,"column":0},"end":{"line":144,"column":65}},"54":{"start":{"line":145,"column":0},"end":{"line":145,"column":63}},"55":{"start":{"line":146,"column":0},"end":{"line":146,"column":65}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":20,"column":2},"end":{"line":20,"column":3}},"loc":{"start":{"line":20,"column":32},"end":{"line":32,"column":3}}},"2":{"name":"binop","decl":{"start":{"line":35,"column":9},"end":{"line":35,"column":14}},"loc":{"start":{"line":35,"column":27},"end":{"line":37,"column":1}}},"3":{"name":"kw","decl":{"start":{"line":105,"column":9},"end":{"line":105,"column":11}},"loc":{"start":{"line":105,"column":32},"end":{"line":108,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":20,"column":21},"end":{"line":20,"column":30}},"type":"default-arg","locations":[{"start":{"line":20,"column":28},"end":{"line":20,"column":30}}]},"2":{"loc":{"start":{"line":30,"column":17},"end":{"line":30,"column":35}},"type":"binary-expr","locations":[{"start":{"line":30,"column":17},"end":{"line":30,"column":27}},{"start":{"line":30,"column":31},"end":{"line":30,"column":35}}]},"3":{"loc":{"start":{"line":105,"column":18},"end":{"line":105,"column":30}},"type":"default-arg","locations":[{"start":{"line":105,"column":28},"end":{"line":105,"column":30}}]}},"s":{"1":81,"2":81,"3":81,"4":81,"5":81,"6":81,"7":81,"8":81,"9":81,"10":81,"11":81,"12":11,"13":1,"14":1,"15":1,"16":1,"17":37,"18":37,"19":1,"20":1,"21":1,"22":1,"23":1,"24":1,"25":1,"26":1,"27":1,"28":1,"29":1,"30":1,"31":1,"32":1,"33":1,"34":1,"35":1,"36":1,"37":1,"38":1,"39":1,"40":1,"41":1,"42":1,"43":1,"44":1,"45":1,"46":1,"47":1,"48":1,"49":1,"50":1,"51":1,"52":1,"53":1,"54":1,"55":1},"f":{"1":81,"2":11,"3":37},"b":{"1":[9],"2":[81,66],"3":[15]},"hash":"ff2900f09f77cfa5c056744ad19c3191983284da"}
+,"/home/travis/build/babel/babylon/src/util/identifier.js": {"path":"/home/travis/build/babel/babylon/src/util/identifier.js","statementMap":{"1":{"start":{"line":13,"column":2},"end":{"line":13,"column":27}},"2":{"start":{"line":14,"column":2},"end":{"line":16,"column":4}},"3":{"start":{"line":15,"column":4},"end":{"line":15,"column":35}},"4":{"start":{"line":21,"column":29},"end":{"line":25,"column":1}},"5":{"start":{"line":29,"column":25},"end":{"line":29,"column":263}},"6":{"start":{"line":39,"column":35},"end":{"line":39,"column":4312}},"7":{"start":{"line":40,"column":30},"end":{"line":40,"column":2605}},"8":{"start":{"line":42,"column":32},"end":{"line":42,"column":84}},"9":{"start":{"line":43,"column":27},"end":{"line":43,"column":105}},"10":{"start":{"line":45,"column":0},"end":{"line":45,"column":62}},"11":{"start":{"line":52,"column":35},"end":{"line":52,"column":1052}},"12":{"start":{"line":53,"column":30},"end":{"line":53,"column":485}},"13":{"start":{"line":59,"column":12},"end":{"line":59,"column":19}},"14":{"start":{"line":60,"column":2},"end":{"line":66,"column":3}},"15":{"start":{"line":61,"column":4},"end":{"line":61,"column":18}},"16":{"start":{"line":62,"column":4},"end":{"line":62,"column":33}},"17":{"start":{"line":62,"column":20},"end":{"line":62,"column":33}},"18":{"start":{"line":64,"column":4},"end":{"line":64,"column":22}},"19":{"start":{"line":65,"column":4},"end":{"line":65,"column":33}},"20":{"start":{"line":65,"column":21},"end":{"line":65,"column":33}},"21":{"start":{"line":72,"column":2},"end":{"line":72,"column":36}},"22":{"start":{"line":72,"column":17},"end":{"line":72,"column":36}},"23":{"start":{"line":73,"column":2},"end":{"line":73,"column":29}},"24":{"start":{"line":73,"column":17},"end":{"line":73,"column":29}},"25":{"start":{"line":74,"column":2},"end":{"line":74,"column":36}},"26":{"start":{"line":74,"column":17},"end":{"line":74,"column":36}},"27":{"start":{"line":75,"column":2},"end":{"line":75,"column":30}},"28":{"start":{"line":75,"column":18},"end":{"line":75,"column":30}},"29":{"start":{"line":76,"column":2},"end":{"line":76,"column":101}},"30":{"start":{"line":76,"column":22},"end":{"line":76,"column":101}},"31":{"start":{"line":77,"column":2},"end":{"line":77,"column":57}},"32":{"start":{"line":83,"column":2},"end":{"line":83,"column":36}},"33":{"start":{"line":83,"column":17},"end":{"line":83,"column":36}},"34":{"start":{"line":84,"column":2},"end":{"line":84,"column":29}},"35":{"start":{"line":84,"column":17},"end":{"line":84,"column":29}},"36":{"start":{"line":85,"column":2},"end":{"line":85,"column":30}},"37":{"start":{"line":85,"column":17},"end":{"line":85,"column":30}},"38":{"start":{"line":86,"column":2},"end":{"line":86,"column":29}},"39":{"start":{"line":86,"column":17},"end":{"line":86,"column":29}},"40":{"start":{"line":87,"column":2},"end":{"line":87,"column":36}},"41":{"start":{"line":87,"column":17},"end":{"line":87,"column":36}},"42":{"start":{"line":88,"column":2},"end":{"line":88,"column":30}},"43":{"start":{"line":88,"column":18},"end":{"line":88,"column":30}},"44":{"start":{"line":89,"column":2},"end":{"line":89,"column":96}},"45":{"start":{"line":89,"column":22},"end":{"line":89,"column":96}},"46":{"start":{"line":90,"column":2},"end":{"line":90,"column":103}}},"fnMap":{"1":{"name":"makePredicate","decl":{"start":{"line":12,"column":9},"end":{"line":12,"column":22}},"loc":{"start":{"line":12,"column":30},"end":{"line":17,"column":1}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":14,"column":9},"end":{"line":14,"column":10}},"loc":{"start":{"line":14,"column":24},"end":{"line":16,"column":3}}},"3":{"name":"isInAstralSet","decl":{"start":{"line":58,"column":9},"end":{"line":58,"column":22}},"loc":{"start":{"line":58,"column":34},"end":{"line":67,"column":1}}},"4":{"name":"isIdentifierStart","decl":{"start":{"line":71,"column":16},"end":{"line":71,"column":33}},"loc":{"start":{"line":71,"column":40},"end":{"line":78,"column":1}}},"5":{"name":"isIdentifierChar","decl":{"start":{"line":82,"column":16},"end":{"line":82,"column":32}},"loc":{"start":{"line":82,"column":39},"end":{"line":91,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":62,"column":4},"end":{"line":62,"column":33}},"type":"if","locations":[{"start":{"line":62,"column":4},"end":{"line":62,"column":33}},{"start":{"line":62,"column":4},"end":{"line":62,"column":33}}]},"2":{"loc":{"start":{"line":65,"column":4},"end":{"line":65,"column":33}},"type":"if","locations":[{"start":{"line":65,"column":4},"end":{"line":65,"column":33}},{"start":{"line":65,"column":4},"end":{"line":65,"column":33}}]},"3":{"loc":{"start":{"line":72,"column":2},"end":{"line":72,"column":36}},"type":"if","locations":[{"start":{"line":72,"column":2},"end":{"line":72,"column":36}},{"start":{"line":72,"column":2},"end":{"line":72,"column":36}}]},"4":{"loc":{"start":{"line":73,"column":2},"end":{"line":73,"column":29}},"type":"if","locations":[{"start":{"line":73,"column":2},"end":{"line":73,"column":29}},{"start":{"line":73,"column":2},"end":{"line":73,"column":29}}]},"5":{"loc":{"start":{"line":74,"column":2},"end":{"line":74,"column":36}},"type":"if","locations":[{"start":{"line":74,"column":2},"end":{"line":74,"column":36}},{"start":{"line":74,"column":2},"end":{"line":74,"column":36}}]},"6":{"loc":{"start":{"line":75,"column":2},"end":{"line":75,"column":30}},"type":"if","locations":[{"start":{"line":75,"column":2},"end":{"line":75,"column":30}},{"start":{"line":75,"column":2},"end":{"line":75,"column":30}}]},"7":{"loc":{"start":{"line":76,"column":2},"end":{"line":76,"column":101}},"type":"if","locations":[{"start":{"line":76,"column":2},"end":{"line":76,"column":101}},{"start":{"line":76,"column":2},"end":{"line":76,"column":101}}]},"8":{"loc":{"start":{"line":76,"column":29},"end":{"line":76,"column":100}},"type":"binary-expr","locations":[{"start":{"line":76,"column":29},"end":{"line":76,"column":41}},{"start":{"line":76,"column":45},"end":{"line":76,"column":100}}]},"9":{"loc":{"start":{"line":83,"column":2},"end":{"line":83,"column":36}},"type":"if","locations":[{"start":{"line":83,"column":2},"end":{"line":83,"column":36}},{"start":{"line":83,"column":2},"end":{"line":83,"column":36}}]},"10":{"loc":{"start":{"line":84,"column":2},"end":{"line":84,"column":29}},"type":"if","locations":[{"start":{"line":84,"column":2},"end":{"line":84,"column":29}},{"start":{"line":84,"column":2},"end":{"line":84,"column":29}}]},"11":{"loc":{"start":{"line":85,"column":2},"end":{"line":85,"column":30}},"type":"if","locations":[{"start":{"line":85,"column":2},"end":{"line":85,"column":30}},{"start":{"line":85,"column":2},"end":{"line":85,"column":30}}]},"12":{"loc":{"start":{"line":86,"column":2},"end":{"line":86,"column":29}},"type":"if","locations":[{"start":{"line":86,"column":2},"end":{"line":86,"column":29}},{"start":{"line":86,"column":2},"end":{"line":86,"column":29}}]},"13":{"loc":{"start":{"line":87,"column":2},"end":{"line":87,"column":36}},"type":"if","locations":[{"start":{"line":87,"column":2},"end":{"line":87,"column":36}},{"start":{"line":87,"column":2},"end":{"line":87,"column":36}}]},"14":{"loc":{"start":{"line":88,"column":2},"end":{"line":88,"column":30}},"type":"if","locations":[{"start":{"line":88,"column":2},"end":{"line":88,"column":30}},{"start":{"line":88,"column":2},"end":{"line":88,"column":30}}]},"15":{"loc":{"start":{"line":89,"column":2},"end":{"line":89,"column":96}},"type":"if","locations":[{"start":{"line":89,"column":2},"end":{"line":89,"column":96}},{"start":{"line":89,"column":2},"end":{"line":89,"column":96}}]},"16":{"loc":{"start":{"line":89,"column":29},"end":{"line":89,"column":95}},"type":"binary-expr","locations":[{"start":{"line":89,"column":29},"end":{"line":89,"column":41}},{"start":{"line":89,"column":45},"end":{"line":89,"column":95}}]},"17":{"loc":{"start":{"line":90,"column":9},"end":{"line":90,"column":102}},"type":"binary-expr","locations":[{"start":{"line":90,"column":9},"end":{"line":90,"column":56}},{"start":{"line":90,"column":60},"end":{"line":90,"column":102}}]}},"s":{"1":4,"2":4,"3":7769,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":1,"11":1,"12":1,"13":221,"14":221,"15":40931,"16":40931,"17":7,"18":40924,"19":40924,"20":15,"21":17173,"22":7699,"23":9474,"24":443,"25":9031,"26":601,"27":8430,"28":5580,"29":2850,"30":2642,"31":208,"32":28340,"33":4718,"34":23622,"35":29,"36":23593,"37":843,"38":22750,"39":551,"40":22199,"41":292,"42":21907,"43":21744,"44":163,"45":153,"46":10},"f":{"1":4,"2":7769,"3":221,"4":17173,"5":28340},"b":{"1":[7,40924],"2":[15,40909],"3":[7699,9474],"4":[443,9031],"5":[601,8430],"6":[5580,2850],"7":[2642,208],"8":[2642,14],"9":[4718,23622],"10":[29,23593],"11":[843,22750],"12":[551,22199],"13":[292,21907],"14":[21744,163],"15":[153,10],"16":[153,33],"17":[10,3]},"hash":"4e54a8370bca83a76606b3f06730fa99460e0c59"}
+,"/home/travis/build/babel/babylon/src/util/location.js": {"path":"/home/travis/build/babel/babylon/src/util/location.js","statementMap":{"1":{"start":{"line":8,"column":4},"end":{"line":8,"column":21}},"2":{"start":{"line":9,"column":4},"end":{"line":9,"column":22}},"3":{"start":{"line":15,"column":4},"end":{"line":15,"column":23}},"4":{"start":{"line":16,"column":4},"end":{"line":16,"column":19}},"5":{"start":{"line":27,"column":2},"end":{"line":36,"column":3}},"6":{"start":{"line":28,"column":4},"end":{"line":28,"column":31}},"7":{"start":{"line":29,"column":16},"end":{"line":29,"column":38}},"8":{"start":{"line":30,"column":4},"end":{"line":35,"column":5}},"9":{"start":{"line":31,"column":6},"end":{"line":31,"column":13}},"10":{"start":{"line":32,"column":6},"end":{"line":32,"column":42}},"11":{"start":{"line":34,"column":6},"end":{"line":34,"column":46}}},"fnMap":{"1":{"name":"(anonymous_1)","decl":{"start":{"line":7,"column":2},"end":{"line":7,"column":3}},"loc":{"start":{"line":7,"column":25},"end":{"line":10,"column":3}}},"2":{"name":"(anonymous_2)","decl":{"start":{"line":14,"column":2},"end":{"line":14,"column":3}},"loc":{"start":{"line":14,"column":26},"end":{"line":17,"column":3}}},"3":{"name":"getLineInfo","decl":{"start":{"line":26,"column":16},"end":{"line":26,"column":27}},"loc":{"start":{"line":26,"column":43},"end":{"line":37,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":30,"column":4},"end":{"line":35,"column":5}},"type":"if","locations":[{"start":{"line":30,"column":4},"end":{"line":35,"column":5}},{"start":{"line":30,"column":4},"end":{"line":35,"column":5}}]},"2":{"loc":{"start":{"line":30,"column":8},"end":{"line":30,"column":37}},"type":"binary-expr","locations":[{"start":{"line":30,"column":8},"end":{"line":30,"column":13}},{"start":{"line":30,"column":17},"end":{"line":30,"column":37}}]}},"s":{"1":42329,"2":42329,"3":38978,"4":38978,"5":738,"6":816,"7":816,"8":816,"9":78,"10":78,"11":738},"f":{"1":42329,"2":38978,"3":738},"b":{"1":[78,738],"2":[816,109]},"hash":"e8e2e15730fb780657a053d74f59ca2ab68a2e93"}
+,"/home/travis/build/babel/babylon/src/util/whitespace.js": {"path":"/home/travis/build/babel/babylon/src/util/whitespace.js","statementMap":{"1":{"start":{"line":4,"column":25},"end":{"line":4,"column":49}},"2":{"start":{"line":5,"column":26},"end":{"line":5,"column":59}},"3":{"start":{"line":8,"column":2},"end":{"line":8,"column":74}},"4":{"start":{"line":11,"column":34},"end":{"line":11,"column":87}}},"fnMap":{"1":{"name":"isNewLine","decl":{"start":{"line":7,"column":16},"end":{"line":7,"column":25}},"loc":{"start":{"line":7,"column":49},"end":{"line":9,"column":1}}}},"branchMap":{"1":{"loc":{"start":{"line":8,"column":9},"end":{"line":8,"column":73}},"type":"binary-expr","locations":[{"start":{"line":8,"column":9},"end":{"line":8,"column":20}},{"start":{"line":8,"column":24},"end":{"line":8,"column":35}},{"start":{"line":8,"column":39},"end":{"line":8,"column":54}},{"start":{"line":8,"column":58},"end":{"line":8,"column":73}}]}},"s":{"1":1,"2":1,"3":3360,"4":1},"f":{"1":3360},"b":{"1":[3360,3345,3344,3344]},"hash":"02e24302f8f6bd7929dab961722ce11e0e53b15c"}
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node2-result.json b/apps/worker/services/report/languages/tests/unit/node/node2-result.json
new file mode 100644
index 0000000000..8b780b1115
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node2-result.json
@@ -0,0 +1,136 @@
+{
+ "archive": [
+ "{}\n<<<<< end_of_header >>>>>\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n[13,\"m\",[[0,13]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[12,null,[[0,12,null,[[4,28,12]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[12,null,[[0,12,null,[[4,34,12]],null]]]\n\n\n[13,null,[[0,13,null,[[2,32,13]],null]]]\n[13,null,[[0,13,null,[[2,23,13]],null]]]\n[13,null,[[0,13,null,[],null]]]\n\n\n\n\n\n\n\n[13,\"m\",[[0,13]]]\n[6,null,[[0,6,null,[],null]]]\n\n\n\n\n[6,\"m\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[3,null,[[0,3,null,[[8,28,3]],null]]]\n\n[3,null,[[0,3,null,[[6,13,3]],null]]]\n\n\n[6,\"m\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n\n\n[6,\"m\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,42,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,43,1]],null]]]\n\n\n[4,null,[[0,4,null,[[8,30,4]],null]]]\n\n[4,null,[[0,4,null,[],null]]]\n[8,null,[[0,8,null,[[10,42,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[14,41,8],[45,71,6]],null]]]\n[4,null,[[0,4,null,[[12,48,4]],null]]]\n\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[[10,17,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[12,29,1]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[3,null,[[0,3,null,[[12,45,3]],null]]]\n\n\n\n[4,null,[[0,4,null,[[8,39,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,43,1]],null]]]\n\n\n[3,null,[[0,3,null,[],null]]]\n[4,null,[[0,4,null,[[10,76,4]],null]]]\n\n\n\n\n\n\n\n\n[13,\"m\",[[0,13]]]\n[6,null,[[0,6,null,[],null]]]\n\n\n\n\n[6,\"m\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[3,null,[[0,3,null,[[8,28,3]],null]]]\n\n[3,null,[[0,3,null,[[6,13,3]],null]]]\n\n\n\n[6,\"m\",[[0,6]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,40,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,41,1]],null]]]\n\n\n[4,null,[[0,4,null,[[6,34,4]],null]]]\n\n\n[4,null,[[0,4,null,[],null]]]\n[8,null,[[0,8,null,[[8,40,8]],null]]]\n[8,null,[[0,8,null,[[8,40,8]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[12,39,8],[43,69,7],[73,91,5]],null]]]\n[4,null,[[0,4,null,[[10,49,4]],null]]]\n\n\n\n[4,null,[[0,4,null,[[6,43,4]],null]]]\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[[8,15,4]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[2,null,[[0,2,null,[[10,27,2]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[3,null,[[0,3,null,[[10,43,3]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,41,1]],null]]]\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[4,\"m\",[[0,4]]]\n[4,null,[[0,4,null,[],null]]]\n\n\n\n\n[3,null,[[0,3,null,[[6,65,3]],null]]]\n[3,null,[[0,3,null,[[6,74,3]],null]]]\n\n[3,null,[[0,3,null,[],null]]]\n[4,\"m\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[12,37,1]],null]]]\n\n\n[3,\"m\",[[0,3]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[14,39,1]],null]]]\n\n[2,null,[[0,2,null,[[12,31,2]],null]]]\n\n\n\n\n\n\n[13,null,[[0,13,null,[[2,14,13]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n\n[2,\"m\",[[0,2]]]\n[2,\"m\",[[0,2]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,23,0]],null]]]\n\n\n\n[2,\"m\",[[0,2]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,32,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n[0,null,[[0,0,null,[[6,25,0]],null]]]\n\n\n\n[2,\"m\",[[0,2]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,28,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[11,27,0],[31,79,0]],null]]]\n[0,null,[[0,0,null,[[10,19,0]],null]]]\n\n\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n[0,null,[[0,0,null,[[6,23,0]],null]]]\n\n\n\n[2,\"m\",[[0,2]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,25,0]],null]]]\n\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[6,54,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[6,60,0]],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[2,21,2]],null]]]\n\n[2,null,[[0,2,null,[[2,18,2]],null]]]\n\n[2,\"m\",[[0,2]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,53,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,23,0]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,32,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0,null,[],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,41,0]],null]]]\n[0,null,[[0,0,null,[[10,41,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[12,41,0]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[12,42,0]],null]]]\n\n\n\n[0,null,[[0,0,null,[[6,28,0]],null]]]\n\n\n\n[2,\"m\",[[0,2]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[],null]]]\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,35,0]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,36,0]],null]]]\n\n\n\n[0,null,[[0,0,null,[],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,72,0]],null]]]\n\n\n\n\n\n[2,\"m\",[[0,2]]]\n[0,null,[[0,0,null,[[4,28,0]],null]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,76,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n[0,null,[[0,0,null,[],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[14,24,0]],null]]]\n[0,null,[[0,0,null,[[14,21,0]],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[16,26,0]],null]]]\n[0,null,[[0,0,null,[[16,23,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[18,28,0]],null]]]\n[0,null,[[0,0,null,[[18,25,0]],null]]]\n\n[0,null,[[0,0,null,[[16,40,0]],null]]]\n\n\n\n\n\n\n\n[2,\"m\",[[0,2]]]\n[0,null,[[0,0,null,[[4,63,0]],null]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n[0,null,[[0,0,null,[[6,29,0]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[4,\"m\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[3,null,[[0,3,null,[[4,34,3]],null]]]\n\n\n[4,null,[[0,4,null,[[2,32,4]],null]]]\n[4,null,[[0,4,null,[[2,23,4]],null]]]\n[4,null,[[0,4,null,[],null]]]\n\n\n\n[4,\"m\",[[0,4]]]\n[3,null,[[0,3,null,[],null]]]\n\n\n\n[3,\"m\",[[0,3]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n\n[2,\"m\",[[0,2]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,27,1]],null]]]\n\n[1,null,[[0,1,null,[[8,15,1]],null]]]\n\n\n\n\n[4,null,[[0,4,null,[[2,14,4]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n[1,null,[[0,1,null,[],null]]]\n\n[5,\"m\",[[0,5]]]\n[5,null,[[0,5,null,[[2,35,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[4,26,1]],null]]]\n\n\n[5,null,[[0,5,null,[[2,43,5]],null]]]\n[5,\"m\",[[0,5]]]\n[2,null,[[0,2,null,[],null]]]\n[2,\"b\",[[0,2]]]\n[2,null,[[0,2,null,[[8,43,2]],null]]]\n\n[0,\"b\",[[0,0]]]\n[0,null,[[0,0,null,[[8,53,0]],null]]]\n\n\n\n\n[5,null,[[0,5,null,[],null]]]\n\n\n\n\n\n\n[5,\"m\",[[0,5]]]\n[2,null,[[0,2,null,[[4,89,2]],null]]]\n[2,null,[[0,2,null,[],null]]]\n[2,\"m\",[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[],null]]]\n[0,null,[[0,0,null,[[10,27,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,58,1]],null]]]\n\n\n[1,null,[[0,1,null,[[8,15,1]],null]]]\n\n\n\n\n[5,\"m\",[[0,5]]]\n[2,null,[[0,2,null,[[4,89,2]],null]]]\n[2,null,[[0,2,null,[],null]]]\n[2,\"m\",[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[],null]]]\n[0,null,[[0,0,null,[[10,27,0]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,58,1]],null]]]\n\n\n[1,null,[[0,1,null,[[8,25,1]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n[8,\"m\",[[0,8]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[7,null,[[0,7,null,[[4,20,7]],null]]]\n\n\n[8,null,[[0,8,null,[[2,32,8]],null]]]\n[8,null,[[0,8,null,[[2,21,8]],null]]]\n[8,null,[[0,8,null,[],null]]]\n\n\n\n[8,\"m\",[[0,8]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[6,41,1]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[6,50,1]],null]]]\n\n\n[5,null,[[0,5,null,[[4,40,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[7,24,5],[28,45,5]],null]]]\n[1,null,[[0,1,null,[[6,50,1]],null]]]\n\n\n[4,null,[[0,4,null,[[4,44,4]],null]]]\n[4,\"m\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[8,52,1]],null]]]\n\n\n[3,\"m\",[[0,3]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[10,101,1]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[12,65,1]],null]]]\n\n[1,null,[[0,1,null,[[10,17,1]],null]]]\n\n\n\n\n\n[8,null,[[0,8,null,[[2,14,8]],null]]]\n\n\n[5,\"m\",[[0,5]]]\n[5,null,[[0,5,null,[[2,8,5]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[4,17,1]],null]]]\n\n\n[4,null,[[0,4,null,[[2,24,4]],null]]]\n[4,null,[[0,4,null,[[2,23,4]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,\"m\",[[0,1]]]\n[1,\"m\",[[0,1]]]\n[0,null,[[0,0,null,[],null]]]\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,30,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,40,0]],null]]]\n[0,null,[[0,0,null,[[10,17,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,35,0]],null]]]\n\n\n\n[1,\"m\",[[0,1]]]\n[0,null,[[0,0,null,[],null]]]\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,38,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n\n\n\n[1,\"m\",[[0,1]]]\n[0,null,[[0,0,null,[],null]]]\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,19,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,null,[[0,0,null,[[2,35,0]],null]]]\n[0,null,[[0,0,null,[[2,19,0]],null]]]\n[0,null,[[0,0,null,[[2,12,0]],null]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",[\"1\"],[],null]]]\n[1,\"m\",[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[[15,25,1],[29,32,1]],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[13,23,0],[27,30,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n[1,\"m\",[[0,1]]]\n[1,null,[[0,1,null,[[2,26,1]],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,25,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,19,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[[6,25,0],[29,54,0]],null]]]\n[0,null,[[0,0,null,[[4,63,0]],null]]]\n[0,null,[[0,0,null,[[4,30,0]],null]]]\n[0,null,[[0,0,null,[[4,11,0]],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,32,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,18,0]],null]]]\n[0,null,[[0,0,null,[[8,15,0]],null]]]\n\n\n[0,null,[[0,0,null,[[6,21,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,27,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,\"m\",[[0,0]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[10,36,0]],null]]]\n[0,null,[[0,0,null,[[10,17,0]],null]]]\n\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[12,22,0]],null]]]\n[0,null,[[0,0,null,[[12,19,0]],null]]]\n\n\n[0,null,[[0,0,null,[[10,25,0]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,21,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,20,0]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[0,null,[[0,0,null,[[4,19,0]],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n[2,\"m\",[[0,2]]]\n[2,\"m\",[[0,2]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n[1,null,[[0,1,null,[[6,13,1]],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[1,\"m\",[[0,1]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n[4,\"m\",[[0,4]]]\n[4,\"m\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[[6,16,1]],null]]]\n[1,null,[[0,1,null,[[6,13,1]],null]]]\n\n\n[3,null,[[0,3,null,[[4,27,3]],null]]]\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[1,\"m\",[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",[\"0\"],[],null]]]\n[0,null,[[0,0,null,[[6,16,0]],null]]]\n[0,null,[[0,0,null,[[6,13,0]],null]]]\n\n\n[1,null,[[0,1,null,[[4,27,1]],null]]]\n\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n[1,\"m\",[[0,1]]]\n[1,null,[[0,1,null,[[2,24,1]],null]]]\n\n[1,null,[[0,1,null,[],null]]]\n[4,null,[[0,4,null,[],null]]]\n\n\n\n\n\n[1,null,[[0,1,null,[[2,27,1]],null]]]\n\n\n[2,\"m\",[[0,2]]]\n[2,null,[[0,2,null,[[2,24,2]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,null,[[0,1,null,[[4,11,1]],null]]]\n\n\n[1,null,[[0,1,null,[[2,47,1]],null]]]\n[1,null,[[0,1,null,[[2,9,1]],null]]]\n\n\n[4,\"m\",[[0,4]]]\n[\"2/2\",\"b\",[[0,\"2/2\",[],[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,null,[[0,1,null,[[4,11,1]],null]]]\n\n\n[3,\"m\",[[0,3]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[6,23,0]],null]]]\n\n\n[3,null,[[0,3,null,[[2,13,3]],null]]]\n\n\n[1,\"m\",[[0,1]]]\n[1,null,[[0,1,null,[],null]]]\n[4,\"m\",[[0,4]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[8,25,0]],null]]]\n\n\n\n[1,null,[[0,1,null,[[2,13,1]],null]]]\n\n\n[1,null,[[0,1,null,[],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1,null,[],null]]]\n\n[1,\"m\",[[0,1]]]\n[1,null,[[0,1,null,[[2,32,1]],null]]]\n[1,null,[[0,1,null,[[2,23,1]],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n[2,\"m\",[[0,2,null,[[2,45,1]],null]]]\n\n[1,\"m\",[[0,1,null,[[2,46,1]],null]]]\n\n[1,\"m\",[[0,1,null,[[2,42,1]],null]]]\n\n[1,\"m\",[[0,1,null,[[2,45,1]],null]]]\n\n[1,\"m\",[[0,1,null,[[2,45,1]],null]]]\n\n[1,null,[[0,1,null,[[2,14,1]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n[1,null,[[0,1,null,[],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]\n[1,null,[[0,1,null,[],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,\"m\",[[0,1]]]\n[0,null,[[0,0,null,[[4,26,0]],null]]]\n[0,null,[[0,0,null,[[4,87,0]],null]]]\n\n[0,\"m\",[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[[12,22,0]],null]]]\n[0,null,[[0,0,null,[[12,19,0]],null]]]\n\n[0,null,[[0,0,null,[[8,32,0]],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",[\"0\",\"1\"],[],null]]]\n[0,null,[[0,0,null,[],null]]]\n\n\n\n\n\n\n[0,null,[[0,0,null,[[8,23,0]],null]]]\n\n\n\n[1,null,[[0,1,null,[],null]]]"
+ ],
+ "totals": {
+ "f": 16,
+ "n": 503,
+ "h": 299,
+ "m": 200,
+ "p": 4,
+ "c": "59.44334",
+ "b": 84,
+ "d": 114,
+ "M": 0,
+ "s": 0,
+ "C": 0,
+ "N": 0,
+ "diff": null
+ },
+ "report": {
+ "files": {
+ "/src/hooks/docker.js": [
+ 0,
+ [0, 84, 84, 0, 0, "100", 21, 14, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/models/aws.js": [
+ 1,
+ [0, 39, 8, 31, 0, "20.51282", 6, 9, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/config/default.js": [
+ 2,
+ [0, 2, 2, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/models/docker.js": [
+ 3,
+ [0, 74, 10, 64, 0, "13.51351", 14, 17, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/hooks/github.js": [
+ 4,
+ [0, 17, 17, 0, 0, "100", 3, 4, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/models/github.js": [
+ 5,
+ [0, 31, 25, 4, 2, "80.64516", 7, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/hooks/jira.js": [
+ 6,
+ [0, 33, 33, 0, 0, "100", 8, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/models/jira.js": [
+ 7,
+ [0, 27, 6, 21, 0, "22.22222", 1, 12, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/app.js": [
+ 8,
+ [0, 35, 29, 5, 1, "82.85714", 3, 3, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/routes/index.js": [
+ 9,
+ [0, 5, 5, 0, 0, "100", 0, 1, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/routes/containers.js": [
+ 10,
+ [0, 50, 7, 43, 0, "14.00000", 9, 14, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/routes/images.js": [
+ 11,
+ [0, 20, 5, 15, 0, "25.00000", 3, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/routes/hooks.js": [
+ 12,
+ [0, 25, 22, 2, 1, "88.00000", 3, 8, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/hooks/hook.js": [
+ 13,
+ [0, 34, 30, 4, 0, "88.23529", 4, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/hooks/sample.js": [
+ 14,
+ [0, 11, 11, 0, 0, "100", 0, 6, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/src/routes/S3.js": [
+ 15,
+ [0, 16, 5, 11, 0, "31.25000", 2, 2, 0, 0, 0, 0, 0],
+ null,
+ null
+ ]
+ },
+ "sessions": {}
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node2.json b/apps/worker/services/report/languages/tests/unit/node/node2.json
new file mode 100644
index 0000000000..8000d00f60
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node2.json
@@ -0,0 +1 @@
+{"/src/hooks/docker.js":{"path":"/src/hooks/docker.js","s":{"1":1,"2":1,"3":13,"4":12,"5":13,"6":12,"7":13,"8":13,"9":13,"10":13,"11":6,"12":6,"13":6,"14":1,"15":6,"16":3,"17":3,"18":6,"19":6,"20":1,"21":6,"22":6,"23":1,"24":5,"25":1,"26":4,"27":4,"28":8,"29":8,"30":4,"31":4,"32":4,"33":4,"34":1,"35":4,"36":3,"37":4,"38":4,"39":1,"40":3,"41":4,"42":13,"43":6,"44":6,"45":6,"46":1,"47":6,"48":3,"49":3,"50":6,"51":6,"52":1,"53":5,"54":1,"55":4,"56":4,"57":8,"58":8,"59":8,"60":4,"61":4,"62":4,"63":4,"64":4,"65":2,"66":4,"67":3,"68":4,"69":1,"70":3,"71":4,"72":3,"73":4,"74":3,"75":3,"76":3,"77":4,"78":4,"79":1,"80":3,"81":3,"82":1,"83":2,"84":13},"b":{"1":[12,1],"2":[12,1],"3":[1,5],"4":[3,3],"5":[1,5],"6":[1,5],"7":[1,4],"8":[4,4],"9":[8,6],"10":[1,3],"11":[3,1],"12":[1,3],"13":[1,5],"14":[3,3],"15":[1,5],"16":[1,4],"17":[4,4],"18":[8,7,5],"19":[2,2],"20":[3,1],"21":[1,3],"22":[1,3],"23":[1,2]},"f":{"1":13,"2":6,"3":6,"4":6,"5":6,"6":4,"7":6,"8":6,"9":6,"10":4,"11":4,"12":4,"13":4,"14":3},"fnMap":{"1":{"name":"dockerHook","line":8,"loc":{"start":{"line":8,"column":17},"end":{"line":8,"column":68}}},"2":{"name":"(anonymous_2)","line":27,"loc":{"start":{"line":27,"column":26},"end":{"line":27,"column":48}}},"3":{"name":"(anonymous_3)","line":33,"loc":{"start":{"line":33,"column":24},"end":{"line":33,"column":45}}},"4":{"name":"(anonymous_4)","line":44,"loc":{"start":{"line":44,"column":40},"end":{"line":44,"column":55}}},"5":{"name":"(anonymous_5)","line":49,"loc":{"start":{"line":49,"column":32},"end":{"line":49,"column":59}}},"6":{"name":"(anonymous_6)","line":67,"loc":{"start":{"line":67,"column":27},"end":{"line":67,"column":42}}},"7":{"name":"(anonymous_7)","line":92,"loc":{"start":{"line":92,"column":26},"end":{"line":92,"column":48}}},"8":{"name":"(anonymous_8)","line":98,"loc":{"start":{"line":98,"column":24},"end":{"line":98,"column":45}}},"9":{"name":"(anonymous_9)","line":110,"loc":{"start":{"line":110,"column":30},"end":{"line":110,"column":57}}},"10":{"name":"(anonymous_10)","line":132,"loc":{"start":{"line":132,"column":25},"end":{"line":132,"column":40}}},"11":{"name":"(anonymous_11)","line":146,"loc":{"start":{"line":146,"column":27},"end":{"line":146,"column":48}}},"12":{"name":"(anonymous_12)","line":166,"loc":{"start":{"line":166,"column":31},"end":{"line":166,"column":52}}},"13":{"name":"(anonymous_13)","line":176,"loc":{"start":{"line":176,"column":69},"end":{"line":176,"column":89}}},"14":{"name":"(anonymous_14)","line":181,"loc":{"start":{"line":181,"column":56},"end":{"line":181,"column":75}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":5,"column":50}},"2":{"start":{"line":8,"column":0},"end":{"line":193,"column":2}},"3":{"start":{"line":9,"column":2},"end":{"line":11,"column":3}},"4":{"start":{"line":10,"column":4},"end":{"line":10,"column":28}},"5":{"start":{"line":13,"column":2},"end":{"line":15,"column":3}},"6":{"start":{"line":14,"column":4},"end":{"line":14,"column":34}},"7":{"start":{"line":17,"column":2},"end":{"line":17,"column":32}},"8":{"start":{"line":18,"column":2},"end":{"line":18,"column":23}},"9":{"start":{"line":19,"column":2},"end":{"line":22,"column":4}},"10":{"start":{"line":27,"column":2},"end":{"line":87,"column":5}},"11":{"start":{"line":28,"column":4},"end":{"line":31,"column":20}},"12":{"start":{"line":33,"column":4},"end":{"line":42,"column":6}},"13":{"start":{"line":34,"column":6},"end":{"line":36,"column":7}},"14":{"start":{"line":35,"column":8},"end":{"line":35,"column":25}},"15":{"start":{"line":38,"column":6},"end":{"line":40,"column":7}},"16":{"start":{"line":39,"column":8},"end":{"line":39,"column":28}},"17":{"start":{"line":41,"column":6},"end":{"line":41,"column":13}},"18":{"start":{"line":44,"column":4},"end":{"line":86,"column":7}},"19":{"start":{"line":45,"column":6},"end":{"line":47,"column":7}},"20":{"start":{"line":46,"column":8},"end":{"line":46,"column":25}},"21":{"start":{"line":49,"column":6},"end":{"line":85,"column":9}},"22":{"start":{"line":50,"column":8},"end":{"line":52,"column":9}},"23":{"start":{"line":51,"column":10},"end":{"line":51,"column":42}},"24":{"start":{"line":54,"column":8},"end":{"line":56,"column":9}},"25":{"start":{"line":55,"column":10},"end":{"line":55,"column":43}},"26":{"start":{"line":58,"column":8},"end":{"line":58,"column":30}},"27":{"start":{"line":60,"column":8},"end":{"line":65,"column":9}},"28":{"start":{"line":61,"column":10},"end":{"line":61,"column":42}},"29":{"start":{"line":62,"column":10},"end":{"line":64,"column":11}},"30":{"start":{"line":63,"column":12},"end":{"line":63,"column":48}},"31":{"start":{"line":67,"column":8},"end":{"line":75,"column":10}},"32":{"start":{"line":68,"column":10},"end":{"line":68,"column":17}},"33":{"start":{"line":69,"column":10},"end":{"line":71,"column":11}},"34":{"start":{"line":70,"column":12},"end":{"line":70,"column":29}},"35":{"start":{"line":72,"column":10},"end":{"line":74,"column":11}},"36":{"start":{"line":73,"column":12},"end":{"line":73,"column":45}},"37":{"start":{"line":77,"column":8},"end":{"line":77,"column":39}},"38":{"start":{"line":78,"column":8},"end":{"line":80,"column":9}},"39":{"start":{"line":79,"column":10},"end":{"line":79,"column":43}},"40":{"start":{"line":82,"column":8},"end":{"line":84,"column":9}},"41":{"start":{"line":83,"column":10},"end":{"line":83,"column":76}},"42":{"start":{"line":92,"column":2},"end":{"line":190,"column":5}},"43":{"start":{"line":93,"column":4},"end":{"line":96,"column":20}},"44":{"start":{"line":98,"column":4},"end":{"line":107,"column":6}},"45":{"start":{"line":99,"column":6},"end":{"line":101,"column":7}},"46":{"start":{"line":100,"column":8},"end":{"line":100,"column":25}},"47":{"start":{"line":103,"column":6},"end":{"line":105,"column":7}},"48":{"start":{"line":104,"column":8},"end":{"line":104,"column":28}},"49":{"start":{"line":106,"column":6},"end":{"line":106,"column":13}},"50":{"start":{"line":110,"column":4},"end":{"line":189,"column":7}},"51":{"start":{"line":111,"column":6},"end":{"line":113,"column":7}},"52":{"start":{"line":112,"column":8},"end":{"line":112,"column":40}},"53":{"start":{"line":115,"column":6},"end":{"line":117,"column":7}},"54":{"start":{"line":116,"column":8},"end":{"line":116,"column":41}},"55":{"start":{"line":119,"column":6},"end":{"line":119,"column":34}},"56":{"start":{"line":122,"column":6},"end":{"line":128,"column":7}},"57":{"start":{"line":123,"column":8},"end":{"line":123,"column":40}},"58":{"start":{"line":124,"column":8},"end":{"line":124,"column":40}},"59":{"start":{"line":125,"column":8},"end":{"line":127,"column":9}},"60":{"start":{"line":126,"column":10},"end":{"line":126,"column":49}},"61":{"start":{"line":130,"column":6},"end":{"line":130,"column":43}},"62":{"start":{"line":132,"column":6},"end":{"line":140,"column":8}},"63":{"start":{"line":133,"column":8},"end":{"line":133,"column":15}},"64":{"start":{"line":134,"column":8},"end":{"line":136,"column":9}},"65":{"start":{"line":135,"column":10},"end":{"line":135,"column":27}},"66":{"start":{"line":137,"column":8},"end":{"line":139,"column":9}},"67":{"start":{"line":138,"column":10},"end":{"line":138,"column":43}},"68":{"start":{"line":142,"column":6},"end":{"line":144,"column":7}},"69":{"start":{"line":143,"column":8},"end":{"line":143,"column":41}},"70":{"start":{"line":146,"column":6},"end":{"line":164,"column":8}},"71":{"start":{"line":147,"column":8},"end":{"line":163,"column":10}},"72":{"start":{"line":166,"column":6},"end":{"line":170,"column":8}},"73":{"start":{"line":167,"column":8},"end":{"line":169,"column":10}},"74":{"start":{"line":172,"column":6},"end":{"line":172,"column":65}},"75":{"start":{"line":173,"column":6},"end":{"line":173,"column":74}},"76":{"start":{"line":175,"column":6},"end":{"line":188,"column":7}},"77":{"start":{"line":176,"column":8},"end":{"line":187,"column":11}},"78":{"start":{"line":177,"column":10},"end":{"line":179,"column":11}},"79":{"start":{"line":178,"column":12},"end":{"line":178,"column":37}},"80":{"start":{"line":181,"column":10},"end":{"line":186,"column":13}},"81":{"start":{"line":182,"column":12},"end":{"line":184,"column":13}},"82":{"start":{"line":183,"column":14},"end":{"line":183,"column":39}},"83":{"start":{"line":185,"column":12},"end":{"line":185,"column":31}},"84":{"start":{"line":192,"column":2},"end":{"line":192,"column":14}}},"branchMap":{"1":{"line":9,"type":"if","locations":[{"start":{"line":9,"column":2},"end":{"line":9,"column":2}},{"start":{"line":9,"column":2},"end":{"line":9,"column":2}}]},"2":{"line":13,"type":"if","locations":[{"start":{"line":13,"column":2},"end":{"line":13,"column":2}},{"start":{"line":13,"column":2},"end":{"line":13,"column":2}}]},"3":{"line":34,"type":"if","locations":[{"start":{"line":34,"column":6},"end":{"line":34,"column":6}},{"start":{"line":34,"column":6},"end":{"line":34,"column":6}}]},"4":{"line":38,"type":"if","locations":[{"start":{"line":38,"column":6},"end":{"line":38,"column":6}},{"start":{"line":38,"column":6},"end":{"line":38,"column":6}}]},"5":{"line":45,"type":"if","locations":[{"start":{"line":45,"column":6},"end":{"line":45,"column":6}},{"start":{"line":45,"column":6},"end":{"line":45,"column":6}}]},"6":{"line":50,"type":"if","locations":[{"start":{"line":50,"column":8},"end":{"line":50,"column":8}},{"start":{"line":50,"column":8},"end":{"line":50,"column":8}}]},"7":{"line":54,"type":"if","locations":[{"start":{"line":54,"column":8},"end":{"line":54,"column":8}},{"start":{"line":54,"column":8},"end":{"line":54,"column":8}}]},"8":{"line":62,"type":"if","locations":[{"start":{"line":62,"column":10},"end":{"line":62,"column":10}},{"start":{"line":62,"column":10},"end":{"line":62,"column":10}}]},"9":{"line":62,"type":"binary-expr","locations":[{"start":{"line":62,"column":14},"end":{"line":62,"column":41}},{"start":{"line":62,"column":45},"end":{"line":62,"column":71}}]},"10":{"line":69,"type":"if","locations":[{"start":{"line":69,"column":10},"end":{"line":69,"column":10}},{"start":{"line":69,"column":10},"end":{"line":69,"column":10}}]},"11":{"line":72,"type":"if","locations":[{"start":{"line":72,"column":10},"end":{"line":72,"column":10}},{"start":{"line":72,"column":10},"end":{"line":72,"column":10}}]},"12":{"line":78,"type":"if","locations":[{"start":{"line":78,"column":8},"end":{"line":78,"column":8}},{"start":{"line":78,"column":8},"end":{"line":78,"column":8}}]},"13":{"line":99,"type":"if","locations":[{"start":{"line":99,"column":6},"end":{"line":99,"column":6}},{"start":{"line":99,"column":6},"end":{"line":99,"column":6}}]},"14":{"line":103,"type":"if","locations":[{"start":{"line":103,"column":6},"end":{"line":103,"column":6}},{"start":{"line":103,"column":6},"end":{"line":103,"column":6}}]},"15":{"line":111,"type":"if","locations":[{"start":{"line":111,"column":6},"end":{"line":111,"column":6}},{"start":{"line":111,"column":6},"end":{"line":111,"column":6}}]},"16":{"line":115,"type":"if","locations":[{"start":{"line":115,"column":6},"end":{"line":115,"column":6}},{"start":{"line":115,"column":6},"end":{"line":115,"column":6}}]},"17":{"line":125,"type":"if","locations":[{"start":{"line":125,"column":8},"end":{"line":125,"column":8}},{"start":{"line":125,"column":8},"end":{"line":125,"column":8}}]},"18":{"line":125,"type":"binary-expr","locations":[{"start":{"line":125,"column":12},"end":{"line":125,"column":39}},{"start":{"line":125,"column":43},"end":{"line":125,"column":69}},{"start":{"line":125,"column":73},"end":{"line":125,"column":91}}]},"19":{"line":134,"type":"if","locations":[{"start":{"line":134,"column":8},"end":{"line":134,"column":8}},{"start":{"line":134,"column":8},"end":{"line":134,"column":8}}]},"20":{"line":137,"type":"if","locations":[{"start":{"line":137,"column":8},"end":{"line":137,"column":8}},{"start":{"line":137,"column":8},"end":{"line":137,"column":8}}]},"21":{"line":142,"type":"if","locations":[{"start":{"line":142,"column":6},"end":{"line":142,"column":6}},{"start":{"line":142,"column":6},"end":{"line":142,"column":6}}]},"22":{"line":177,"type":"if","locations":[{"start":{"line":177,"column":10},"end":{"line":177,"column":10}},{"start":{"line":177,"column":10},"end":{"line":177,"column":10}}]},"23":{"line":182,"type":"if","locations":[{"start":{"line":182,"column":12},"end":{"line":182,"column":12}},{"start":{"line":182,"column":12},"end":{"line":182,"column":12}}]}}},"/src/models/aws.js":{"path":"/src/models/aws.js","s":{"1":1,"2":1,"3":1,"4":1,"5":2,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":2,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":2,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":2,"35":0,"36":0,"37":0,"38":0,"39":0},"b":{"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0]},"f":{"1":2,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0},"fnMap":{"1":{"name":"AWSModel","line":10,"loc":{"start":{"line":10,"column":17},"end":{"line":10,"column":37}}},"2":{"name":"(anonymous_2)","line":11,"loc":{"start":{"line":11,"column":21},"end":{"line":11,"column":43}}},"3":{"name":"(anonymous_3)","line":15,"loc":{"start":{"line":15,"column":33},"end":{"line":15,"column":53}}},"4":{"name":"(anonymous_4)","line":25,"loc":{"start":{"line":25,"column":20},"end":{"line":25,"column":35}}},"5":{"name":"(anonymous_5)","line":26,"loc":{"start":{"line":26,"column":29},"end":{"line":26,"column":49}}},"6":{"name":"(anonymous_6)","line":44,"loc":{"start":{"line":44,"column":18},"end":{"line":44,"column":58}}},"7":{"name":"(anonymous_7)","line":49,"loc":{"start":{"line":49,"column":27},"end":{"line":49,"column":46}}},"8":{"name":"(anonymous_8)","line":70,"loc":{"start":{"line":70,"column":19},"end":{"line":70,"column":46}}},"9":{"name":"(anonymous_9)","line":79,"loc":{"start":{"line":79,"column":33},"end":{"line":79,"column":53}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":4,"column":27}},"2":{"start":{"line":6,"column":0},"end":{"line":6,"column":49}},"3":{"start":{"line":8,"column":0},"end":{"line":8,"column":24}},"4":{"start":{"line":10,"column":0},"end":{"line":86,"column":2}},"5":{"start":{"line":11,"column":2},"end":{"line":23,"column":4}},"6":{"start":{"line":12,"column":4},"end":{"line":14,"column":6}},"7":{"start":{"line":15,"column":4},"end":{"line":22,"column":7}},"8":{"start":{"line":16,"column":6},"end":{"line":19,"column":7}},"9":{"start":{"line":17,"column":8},"end":{"line":17,"column":18}},"10":{"start":{"line":18,"column":8},"end":{"line":18,"column":15}},"11":{"start":{"line":21,"column":6},"end":{"line":21,"column":23}},"12":{"start":{"line":25,"column":2},"end":{"line":42,"column":4}},"13":{"start":{"line":26,"column":4},"end":{"line":41,"column":7}},"14":{"start":{"line":27,"column":6},"end":{"line":30,"column":7}},"15":{"start":{"line":28,"column":8},"end":{"line":28,"column":18}},"16":{"start":{"line":29,"column":8},"end":{"line":29,"column":15}},"17":{"start":{"line":32,"column":6},"end":{"line":32,"column":32}},"18":{"start":{"line":33,"column":6},"end":{"line":39,"column":7}},"19":{"start":{"line":34,"column":8},"end":{"line":38,"column":9}},"20":{"start":{"line":35,"column":10},"end":{"line":37,"column":13}},"21":{"start":{"line":40,"column":6},"end":{"line":40,"column":25}},"22":{"start":{"line":44,"column":2},"end":{"line":68,"column":4}},"23":{"start":{"line":45,"column":4},"end":{"line":47,"column":6}},"24":{"start":{"line":49,"column":4},"end":{"line":67,"column":7}},"25":{"start":{"line":50,"column":6},"end":{"line":53,"column":7}},"26":{"start":{"line":51,"column":8},"end":{"line":51,"column":18}},"27":{"start":{"line":52,"column":8},"end":{"line":52,"column":15}},"28":{"start":{"line":55,"column":6},"end":{"line":55,"column":28}},"29":{"start":{"line":56,"column":6},"end":{"line":65,"column":7}},"30":{"start":{"line":57,"column":8},"end":{"line":59,"column":9}},"31":{"start":{"line":58,"column":10},"end":{"line":58,"column":19}},"32":{"start":{"line":61,"column":8},"end":{"line":64,"column":11}},"33":{"start":{"line":66,"column":6},"end":{"line":66,"column":23}},"34":{"start":{"line":70,"column":2},"end":{"line":85,"column":4}},"35":{"start":{"line":71,"column":4},"end":{"line":78,"column":6}},"36":{"start":{"line":79,"column":4},"end":{"line":84,"column":7}},"37":{"start":{"line":80,"column":6},"end":{"line":82,"column":7}},"38":{"start":{"line":81,"column":8},"end":{"line":81,"column":25}},"39":{"start":{"line":83,"column":6},"end":{"line":83,"column":13}}},"branchMap":{"1":{"line":16,"type":"if","locations":[{"start":{"line":16,"column":6},"end":{"line":16,"column":6}},{"start":{"line":16,"column":6},"end":{"line":16,"column":6}}]},"2":{"line":27,"type":"if","locations":[{"start":{"line":27,"column":6},"end":{"line":27,"column":6}},{"start":{"line":27,"column":6},"end":{"line":27,"column":6}}]},"3":{"line":34,"type":"if","locations":[{"start":{"line":34,"column":8},"end":{"line":34,"column":8}},{"start":{"line":34,"column":8},"end":{"line":34,"column":8}}]},"4":{"line":50,"type":"if","locations":[{"start":{"line":50,"column":6},"end":{"line":50,"column":6}},{"start":{"line":50,"column":6},"end":{"line":50,"column":6}}]},"5":{"line":57,"type":"if","locations":[{"start":{"line":57,"column":8},"end":{"line":57,"column":8}},{"start":{"line":57,"column":8},"end":{"line":57,"column":8}}]},"6":{"line":57,"type":"binary-expr","locations":[{"start":{"line":57,"column":11},"end":{"line":57,"column":27}},{"start":{"line":57,"column":31},"end":{"line":57,"column":79}}]},"7":{"line":80,"type":"if","locations":[{"start":{"line":80,"column":6},"end":{"line":80,"column":6}},{"start":{"line":80,"column":6},"end":{"line":80,"column":6}}]}}},"/src/config/default.js":{"path":"/src/config/default.js","s":{"1":1,"2":1},"b":{},"f":{},"fnMap":{},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":3,"column":23}},"2":{"start":{"line":5,"column":0},"end":{"line":36,"column":2}}},"branchMap":{}},"/src/models/docker.js":{"path":"/src/models/docker.js","s":{"1":1,"2":0,"3":0,"4":1,"5":1,"6":2,"7":2,"8":2,"9":0,"10":0,"11":0,"12":2,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":2,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":2,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":0,"61":0,"62":0,"63":0,"64":2,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0},"b":{"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[0,0],"14":[0,0]},"f":{"1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0},"fnMap":{"1":{"name":"(anonymous_1)","line":9,"loc":{"start":{"line":9,"column":12},"end":{"line":9,"column":33}}},"2":{"name":"(anonymous_2)","line":12,"loc":{"start":{"line":12,"column":12},"end":{"line":12,"column":31}}},"3":{"name":"DockerModel","line":20,"loc":{"start":{"line":20,"column":17},"end":{"line":20,"column":40}}},"4":{"name":"(anonymous_4)","line":25,"loc":{"start":{"line":25,"column":19},"end":{"line":25,"column":30}}},"5":{"name":"(anonymous_5)","line":33,"loc":{"start":{"line":33,"column":26},"end":{"line":33,"column":46}}},"6":{"name":"(anonymous_6)","line":34,"loc":{"start":{"line":34,"column":46},"end":{"line":34,"column":66}}},"7":{"name":"(anonymous_7)","line":67,"loc":{"start":{"line":67,"column":23},"end":{"line":67,"column":38}}},"8":{"name":"(anonymous_8)","line":68,"loc":{"start":{"line":68,"column":42},"end":{"line":68,"column":69}}},"9":{"name":"(anonymous_9)","line":76,"loc":{"start":{"line":76,"column":31},"end":{"line":76,"column":51}}},"10":{"name":"(anonymous_10)","line":95,"loc":{"start":{"line":95,"column":25},"end":{"line":95,"column":55}}},"11":{"name":"(anonymous_11)","line":97,"loc":{"start":{"line":97,"column":34},"end":{"line":97,"column":54}}},"12":{"name":"(anonymous_12)","line":113,"loc":{"start":{"line":113,"column":8},"end":{"line":113,"column":30}}},"13":{"name":"(anonymous_13)","line":114,"loc":{"start":{"line":114,"column":56},"end":{"line":114,"column":70}}},"14":{"name":"(anonymous_14)","line":120,"loc":{"start":{"line":120,"column":60},"end":{"line":120,"column":85}}},"15":{"name":"(anonymous_15)","line":125,"loc":{"start":{"line":125,"column":30},"end":{"line":125,"column":44}}},"16":{"name":"(anonymous_16)","line":138,"loc":{"start":{"line":138,"column":32},"end":{"line":138,"column":60}}},"17":{"name":"(anonymous_17)","line":140,"loc":{"start":{"line":140,"column":23},"end":{"line":140,"column":37}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":15,"column":4}},"2":{"start":{"line":10,"column":6},"end":{"line":10,"column":54}},"3":{"start":{"line":13,"column":6},"end":{"line":13,"column":60}},"4":{"start":{"line":17,"column":0},"end":{"line":17,"column":49}},"5":{"start":{"line":20,"column":0},"end":{"line":149,"column":2}},"6":{"start":{"line":21,"column":2},"end":{"line":21,"column":21}},"7":{"start":{"line":23,"column":2},"end":{"line":23,"column":18}},"8":{"start":{"line":25,"column":2},"end":{"line":31,"column":4}},"9":{"start":{"line":26,"column":4},"end":{"line":28,"column":5}},"10":{"start":{"line":27,"column":6},"end":{"line":27,"column":53}},"11":{"start":{"line":30,"column":4},"end":{"line":30,"column":23}},"12":{"start":{"line":33,"column":2},"end":{"line":65,"column":4}},"13":{"start":{"line":34,"column":4},"end":{"line":64,"column":7}},"14":{"start":{"line":35,"column":6},"end":{"line":38,"column":7}},"15":{"start":{"line":36,"column":8},"end":{"line":36,"column":18}},"16":{"start":{"line":37,"column":8},"end":{"line":37,"column":15}},"17":{"start":{"line":40,"column":6},"end":{"line":40,"column":32}},"18":{"start":{"line":41,"column":6},"end":{"line":48,"column":8}},"19":{"start":{"line":50,"column":6},"end":{"line":62,"column":7}},"20":{"start":{"line":51,"column":8},"end":{"line":61,"column":9}},"21":{"start":{"line":52,"column":10},"end":{"line":52,"column":41}},"22":{"start":{"line":53,"column":10},"end":{"line":53,"column":41}},"23":{"start":{"line":54,"column":10},"end":{"line":56,"column":11}},"24":{"start":{"line":55,"column":12},"end":{"line":55,"column":41}},"25":{"start":{"line":58,"column":10},"end":{"line":60,"column":11}},"26":{"start":{"line":59,"column":12},"end":{"line":59,"column":42}},"27":{"start":{"line":63,"column":6},"end":{"line":63,"column":28}},"28":{"start":{"line":67,"column":2},"end":{"line":93,"column":4}},"29":{"start":{"line":68,"column":4},"end":{"line":92,"column":7}},"30":{"start":{"line":69,"column":6},"end":{"line":72,"column":7}},"31":{"start":{"line":70,"column":8},"end":{"line":70,"column":18}},"32":{"start":{"line":71,"column":8},"end":{"line":71,"column":15}},"33":{"start":{"line":74,"column":6},"end":{"line":85,"column":8}},"34":{"start":{"line":77,"column":8},"end":{"line":77,"column":18}},"35":{"start":{"line":78,"column":8},"end":{"line":80,"column":9}},"36":{"start":{"line":79,"column":10},"end":{"line":79,"column":35}},"37":{"start":{"line":82,"column":8},"end":{"line":84,"column":9}},"38":{"start":{"line":83,"column":10},"end":{"line":83,"column":36}},"39":{"start":{"line":87,"column":6},"end":{"line":91,"column":7}},"40":{"start":{"line":88,"column":8},"end":{"line":90,"column":9}},"41":{"start":{"line":89,"column":10},"end":{"line":89,"column":72}},"42":{"start":{"line":95,"column":2},"end":{"line":136,"column":4}},"43":{"start":{"line":96,"column":4},"end":{"line":96,"column":28}},"44":{"start":{"line":97,"column":4},"end":{"line":135,"column":7}},"45":{"start":{"line":98,"column":6},"end":{"line":101,"column":7}},"46":{"start":{"line":99,"column":8},"end":{"line":99,"column":18}},"47":{"start":{"line":100,"column":8},"end":{"line":100,"column":15}},"48":{"start":{"line":103,"column":6},"end":{"line":103,"column":76}},"49":{"start":{"line":104,"column":6},"end":{"line":108,"column":8}},"50":{"start":{"line":110,"column":6},"end":{"line":134,"column":11}},"51":{"start":{"line":114,"column":10},"end":{"line":133,"column":13}},"52":{"start":{"line":115,"column":12},"end":{"line":118,"column":13}},"53":{"start":{"line":116,"column":14},"end":{"line":116,"column":24}},"54":{"start":{"line":117,"column":14},"end":{"line":117,"column":21}},"55":{"start":{"line":120,"column":12},"end":{"line":132,"column":15}},"56":{"start":{"line":121,"column":14},"end":{"line":124,"column":15}},"57":{"start":{"line":122,"column":16},"end":{"line":122,"column":26}},"58":{"start":{"line":123,"column":16},"end":{"line":123,"column":23}},"59":{"start":{"line":125,"column":14},"end":{"line":131,"column":17}},"60":{"start":{"line":126,"column":16},"end":{"line":129,"column":17}},"61":{"start":{"line":127,"column":18},"end":{"line":127,"column":28}},"62":{"start":{"line":128,"column":18},"end":{"line":128,"column":25}},"63":{"start":{"line":130,"column":16},"end":{"line":130,"column":40}},"64":{"start":{"line":138,"column":2},"end":{"line":148,"column":4}},"65":{"start":{"line":139,"column":4},"end":{"line":139,"column":63}},"66":{"start":{"line":140,"column":4},"end":{"line":146,"column":7}},"67":{"start":{"line":141,"column":6},"end":{"line":144,"column":7}},"68":{"start":{"line":142,"column":8},"end":{"line":142,"column":18}},"69":{"start":{"line":143,"column":8},"end":{"line":143,"column":15}},"70":{"start":{"line":145,"column":6},"end":{"line":145,"column":29}}},"branchMap":{"1":{"line":26,"type":"if","locations":[{"start":{"line":26,"column":4},"end":{"line":26,"column":4}},{"start":{"line":26,"column":4},"end":{"line":26,"column":4}}]},"2":{"line":35,"type":"if","locations":[{"start":{"line":35,"column":6},"end":{"line":35,"column":6}},{"start":{"line":35,"column":6},"end":{"line":35,"column":6}}]},"3":{"line":51,"type":"if","locations":[{"start":{"line":51,"column":8},"end":{"line":51,"column":8}},{"start":{"line":51,"column":8},"end":{"line":51,"column":8}}]},"4":{"line":54,"type":"if","locations":[{"start":{"line":54,"column":10},"end":{"line":54,"column":10}},{"start":{"line":54,"column":10},"end":{"line":54,"column":10}}]},"5":{"line":58,"type":"if","locations":[{"start":{"line":58,"column":10},"end":{"line":58,"column":10}},{"start":{"line":58,"column":10},"end":{"line":58,"column":10}}]},"6":{"line":69,"type":"if","locations":[{"start":{"line":69,"column":6},"end":{"line":69,"column":6}},{"start":{"line":69,"column":6},"end":{"line":69,"column":6}}]},"7":{"line":78,"type":"if","locations":[{"start":{"line":78,"column":8},"end":{"line":78,"column":8}},{"start":{"line":78,"column":8},"end":{"line":78,"column":8}}]},"8":{"line":82,"type":"if","locations":[{"start":{"line":82,"column":8},"end":{"line":82,"column":8}},{"start":{"line":82,"column":8},"end":{"line":82,"column":8}}]},"9":{"line":88,"type":"if","locations":[{"start":{"line":88,"column":8},"end":{"line":88,"column":8}},{"start":{"line":88,"column":8},"end":{"line":88,"column":8}}]},"10":{"line":98,"type":"if","locations":[{"start":{"line":98,"column":6},"end":{"line":98,"column":6}},{"start":{"line":98,"column":6},"end":{"line":98,"column":6}}]},"11":{"line":115,"type":"if","locations":[{"start":{"line":115,"column":12},"end":{"line":115,"column":12}},{"start":{"line":115,"column":12},"end":{"line":115,"column":12}}]},"12":{"line":121,"type":"if","locations":[{"start":{"line":121,"column":14},"end":{"line":121,"column":14}},{"start":{"line":121,"column":14},"end":{"line":121,"column":14}}]},"13":{"line":126,"type":"if","locations":[{"start":{"line":126,"column":16},"end":{"line":126,"column":16}},{"start":{"line":126,"column":16},"end":{"line":126,"column":16}}]},"14":{"line":141,"type":"if","locations":[{"start":{"line":141,"column":6},"end":{"line":141,"column":6}},{"start":{"line":141,"column":6},"end":{"line":141,"column":6}}]}}},"/src/hooks/github.js":{"path":"/src/hooks/github.js","s":{"1":1,"2":1,"3":4,"4":3,"5":4,"6":4,"7":4,"8":4,"9":3,"10":3,"11":3,"12":1,"13":2,"14":2,"15":1,"16":1,"17":4},"b":{"1":[3,1],"2":[1,2],"3":[1,1]},"f":{"1":4,"2":3,"3":3,"4":2},"fnMap":{"1":{"name":"(anonymous_1)","line":7,"loc":{"start":{"line":7,"column":17},"end":{"line":7,"column":43}}},"2":{"name":"(anonymous_2)","line":18,"loc":{"start":{"line":18,"column":26},"end":{"line":18,"column":47}}},"3":{"name":"(anonymous_3)","line":23,"loc":{"start":{"line":23,"column":50},"end":{"line":23,"column":64}}},"4":{"name":"(anonymous_4)","line":27,"loc":{"start":{"line":27,"column":55},"end":{"line":27,"column":69}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":5,"column":52}},"2":{"start":{"line":7,"column":0},"end":{"line":37,"column":2}},"3":{"start":{"line":8,"column":2},"end":{"line":10,"column":3}},"4":{"start":{"line":9,"column":4},"end":{"line":9,"column":34}},"5":{"start":{"line":12,"column":2},"end":{"line":12,"column":32}},"6":{"start":{"line":13,"column":2},"end":{"line":13,"column":23}},"7":{"start":{"line":14,"column":2},"end":{"line":16,"column":4}},"8":{"start":{"line":18,"column":2},"end":{"line":34,"column":5}},"9":{"start":{"line":19,"column":4},"end":{"line":21,"column":27}},"10":{"start":{"line":23,"column":4},"end":{"line":33,"column":7}},"11":{"start":{"line":24,"column":6},"end":{"line":26,"column":7}},"12":{"start":{"line":25,"column":8},"end":{"line":25,"column":25}},"13":{"start":{"line":27,"column":6},"end":{"line":32,"column":9}},"14":{"start":{"line":28,"column":8},"end":{"line":30,"column":9}},"15":{"start":{"line":29,"column":10},"end":{"line":29,"column":27}},"16":{"start":{"line":31,"column":8},"end":{"line":31,"column":15}},"17":{"start":{"line":36,"column":2},"end":{"line":36,"column":14}}},"branchMap":{"1":{"line":8,"type":"if","locations":[{"start":{"line":8,"column":2},"end":{"line":8,"column":2}},{"start":{"line":8,"column":2},"end":{"line":8,"column":2}}]},"2":{"line":24,"type":"if","locations":[{"start":{"line":24,"column":6},"end":{"line":24,"column":6}},{"start":{"line":24,"column":6},"end":{"line":24,"column":6}}]},"3":{"line":28,"type":"if","locations":[{"start":{"line":28,"column":8},"end":{"line":28,"column":8}},{"start":{"line":28,"column":8},"end":{"line":28,"column":8}}]}}},"/src/models/github.js":{"path":"/src/models/github.js","s":{"1":1,"2":1,"3":5,"4":5,"5":1,"6":5,"7":5,"8":2,"9":2,"10":0,"11":5,"12":5,"13":2,"14":2,"15":2,"16":0,"17":2,"18":1,"19":1,"20":5,"21":2,"22":2,"23":2,"24":0,"25":2,"26":1,"27":1},"b":{"1":[1,4],"2":[2,0],"3":[0,2],"4":[1,1],"5":[0,2],"6":[1,1]},"f":{"1":5,"2":2,"3":2,"4":2,"5":2,"6":2},"fnMap":{"1":{"name":"GithubModel","line":4,"loc":{"start":{"line":4,"column":17},"end":{"line":4,"column":51}}},"2":{"name":"(anonymous_2)","line":11,"loc":{"start":{"line":11,"column":28},"end":{"line":11,"column":49}}},"3":{"name":"(anonymous_3)","line":29,"loc":{"start":{"line":29,"column":22},"end":{"line":29,"column":58}}},"4":{"name":"(anonymous_4)","line":32,"loc":{"start":{"line":32,"column":23},"end":{"line":32,"column":43}}},"5":{"name":"(anonymous_5)","line":46,"loc":{"start":{"line":46,"column":19},"end":{"line":46,"column":55}}},"6":{"name":"(anonymous_6)","line":49,"loc":{"start":{"line":49,"column":20},"end":{"line":49,"column":46}}}},"statementMap":{"1":{"start":{"line":2,"column":0},"end":{"line":2,"column":33}},"2":{"start":{"line":4,"column":0},"end":{"line":62,"column":2}},"3":{"start":{"line":5,"column":2},"end":{"line":5,"column":35}},"4":{"start":{"line":6,"column":2},"end":{"line":8,"column":3}},"5":{"start":{"line":7,"column":4},"end":{"line":7,"column":26}},"6":{"start":{"line":10,"column":2},"end":{"line":10,"column":43}},"7":{"start":{"line":11,"column":2},"end":{"line":20,"column":4}},"8":{"start":{"line":12,"column":4},"end":{"line":19,"column":5}},"9":{"start":{"line":14,"column":8},"end":{"line":14,"column":43}},"10":{"start":{"line":17,"column":8},"end":{"line":17,"column":53}},"11":{"start":{"line":22,"column":2},"end":{"line":27,"column":4}},"12":{"start":{"line":29,"column":2},"end":{"line":44,"column":4}},"13":{"start":{"line":30,"column":4},"end":{"line":30,"column":89}},"14":{"start":{"line":31,"column":4},"end":{"line":43,"column":77}},"15":{"start":{"line":33,"column":8},"end":{"line":35,"column":9}},"16":{"start":{"line":34,"column":10},"end":{"line":34,"column":27}},"17":{"start":{"line":37,"column":8},"end":{"line":39,"column":9}},"18":{"start":{"line":38,"column":10},"end":{"line":38,"column":58}},"19":{"start":{"line":41,"column":8},"end":{"line":41,"column":15}},"20":{"start":{"line":46,"column":2},"end":{"line":61,"column":4}},"21":{"start":{"line":47,"column":4},"end":{"line":47,"column":89}},"22":{"start":{"line":48,"column":4},"end":{"line":60,"column":77}},"23":{"start":{"line":50,"column":8},"end":{"line":52,"column":9}},"24":{"start":{"line":51,"column":10},"end":{"line":51,"column":27}},"25":{"start":{"line":54,"column":8},"end":{"line":56,"column":9}},"26":{"start":{"line":55,"column":10},"end":{"line":55,"column":58}},"27":{"start":{"line":58,"column":8},"end":{"line":58,"column":25}}},"branchMap":{"1":{"line":6,"type":"if","locations":[{"start":{"line":6,"column":2},"end":{"line":6,"column":2}},{"start":{"line":6,"column":2},"end":{"line":6,"column":2}}]},"2":{"line":12,"type":"switch","locations":[{"start":{"line":13,"column":6},"end":{"line":15,"column":7}},{"start":{"line":16,"column":6},"end":{"line":18,"column":7}}]},"3":{"line":33,"type":"if","locations":[{"start":{"line":33,"column":8},"end":{"line":33,"column":8}},{"start":{"line":33,"column":8},"end":{"line":33,"column":8}}]},"4":{"line":37,"type":"if","locations":[{"start":{"line":37,"column":8},"end":{"line":37,"column":8}},{"start":{"line":37,"column":8},"end":{"line":37,"column":8}}]},"5":{"line":50,"type":"if","locations":[{"start":{"line":50,"column":8},"end":{"line":50,"column":8}},{"start":{"line":50,"column":8},"end":{"line":50,"column":8}}]},"6":{"line":54,"type":"if","locations":[{"start":{"line":54,"column":8},"end":{"line":54,"column":8}},{"start":{"line":54,"column":8},"end":{"line":54,"column":8}}]}}},"/src/hooks/jira.js":{"path":"/src/hooks/jira.js","s":{"1":1,"2":1,"3":8,"4":7,"5":8,"6":8,"7":8,"8":8,"9":7,"10":1,"11":6,"12":1,"13":5,"14":5,"15":1,"16":4,"17":4,"18":4,"19":1,"20":3,"21":3,"22":1,"23":2,"24":2,"25":1,"26":1,"27":8,"28":1,"29":5,"30":5,"31":1,"32":4,"33":4},"b":{"1":[7,1],"2":[1,6],"3":[1,5],"4":[1,4],"5":[5,5],"6":[1,3],"7":[1,2],"8":[1,1],"9":[1,4]},"f":{"1":8,"2":7,"3":4,"4":3,"5":2,"6":5},"fnMap":{"1":{"name":"jiraHook","line":8,"loc":{"start":{"line":8,"column":17},"end":{"line":8,"column":45}}},"2":{"name":"(anonymous_2)","line":19,"loc":{"start":{"line":19,"column":26},"end":{"line":19,"column":47}}},"3":{"name":"(anonymous_3)","line":34,"loc":{"start":{"line":34,"column":26},"end":{"line":34,"column":51}}},"4":{"name":"(anonymous_4)","line":39,"loc":{"start":{"line":39,"column":63},"end":{"line":39,"column":91}}},"5":{"name":"(anonymous_5)","line":44,"loc":{"start":{"line":44,"column":50},"end":{"line":44,"column":64}}},"6":{"name":"isInt","line":57,"loc":{"start":{"line":57,"column":0},"end":{"line":57,"column":22}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":5,"column":43}},"2":{"start":{"line":8,"column":0},"end":{"line":55,"column":2}},"3":{"start":{"line":9,"column":2},"end":{"line":11,"column":3}},"4":{"start":{"line":10,"column":4},"end":{"line":10,"column":20}},"5":{"start":{"line":13,"column":2},"end":{"line":13,"column":32}},"6":{"start":{"line":14,"column":2},"end":{"line":14,"column":21}},"7":{"start":{"line":15,"column":2},"end":{"line":17,"column":4}},"8":{"start":{"line":19,"column":2},"end":{"line":52,"column":5}},"9":{"start":{"line":20,"column":4},"end":{"line":22,"column":5}},"10":{"start":{"line":21,"column":6},"end":{"line":21,"column":41}},"11":{"start":{"line":24,"column":4},"end":{"line":26,"column":5}},"12":{"start":{"line":25,"column":6},"end":{"line":25,"column":50}},"13":{"start":{"line":28,"column":4},"end":{"line":28,"column":40}},"14":{"start":{"line":29,"column":4},"end":{"line":31,"column":5}},"15":{"start":{"line":30,"column":6},"end":{"line":30,"column":50}},"16":{"start":{"line":33,"column":4},"end":{"line":33,"column":44}},"17":{"start":{"line":34,"column":4},"end":{"line":51,"column":7}},"18":{"start":{"line":35,"column":6},"end":{"line":37,"column":7}},"19":{"start":{"line":36,"column":8},"end":{"line":36,"column":52}},"20":{"start":{"line":39,"column":6},"end":{"line":50,"column":9}},"21":{"start":{"line":40,"column":8},"end":{"line":42,"column":9}},"22":{"start":{"line":41,"column":10},"end":{"line":41,"column":101}},"23":{"start":{"line":44,"column":8},"end":{"line":49,"column":11}},"24":{"start":{"line":45,"column":10},"end":{"line":47,"column":11}},"25":{"start":{"line":46,"column":12},"end":{"line":46,"column":65}},"26":{"start":{"line":48,"column":10},"end":{"line":48,"column":17}},"27":{"start":{"line":54,"column":2},"end":{"line":54,"column":14}},"28":{"start":{"line":57,"column":0},"end":{"line":65,"column":1}},"29":{"start":{"line":58,"column":2},"end":{"line":58,"column":8}},"30":{"start":{"line":59,"column":2},"end":{"line":61,"column":3}},"31":{"start":{"line":60,"column":4},"end":{"line":60,"column":17}},"32":{"start":{"line":63,"column":2},"end":{"line":63,"column":24}},"33":{"start":{"line":64,"column":2},"end":{"line":64,"column":23}}},"branchMap":{"1":{"line":9,"type":"if","locations":[{"start":{"line":9,"column":2},"end":{"line":9,"column":2}},{"start":{"line":9,"column":2},"end":{"line":9,"column":2}}]},"2":{"line":20,"type":"if","locations":[{"start":{"line":20,"column":4},"end":{"line":20,"column":4}},{"start":{"line":20,"column":4},"end":{"line":20,"column":4}}]},"3":{"line":24,"type":"if","locations":[{"start":{"line":24,"column":4},"end":{"line":24,"column":4}},{"start":{"line":24,"column":4},"end":{"line":24,"column":4}}]},"4":{"line":29,"type":"if","locations":[{"start":{"line":29,"column":4},"end":{"line":29,"column":4}},{"start":{"line":29,"column":4},"end":{"line":29,"column":4}}]},"5":{"line":29,"type":"binary-expr","locations":[{"start":{"line":29,"column":7},"end":{"line":29,"column":24}},{"start":{"line":29,"column":28},"end":{"line":29,"column":45}}]},"6":{"line":35,"type":"if","locations":[{"start":{"line":35,"column":6},"end":{"line":35,"column":6}},{"start":{"line":35,"column":6},"end":{"line":35,"column":6}}]},"7":{"line":40,"type":"if","locations":[{"start":{"line":40,"column":8},"end":{"line":40,"column":8}},{"start":{"line":40,"column":8},"end":{"line":40,"column":8}}]},"8":{"line":45,"type":"if","locations":[{"start":{"line":45,"column":10},"end":{"line":45,"column":10}},{"start":{"line":45,"column":10},"end":{"line":45,"column":10}}]},"9":{"line":59,"type":"if","locations":[{"start":{"line":59,"column":2},"end":{"line":59,"column":2}},{"start":{"line":59,"column":2},"end":{"line":59,"column":2}}]}}},"/src/models/jira.js":{"path":"/src/models/jira.js","s":{"1":1,"2":1,"3":1,"4":0,"5":0,"6":0,"7":1,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":1,"15":0,"16":0,"17":0,"18":1,"19":0,"20":0,"21":0},"b":{"1":[0,0]},"f":{"1":1,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0},"fnMap":{"1":{"name":"JiraModel","line":7,"loc":{"start":{"line":7,"column":17},"end":{"line":7,"column":38}}},"2":{"name":"(anonymous_2)","line":8,"loc":{"start":{"line":8,"column":19},"end":{"line":8,"column":41}}},"3":{"name":"(anonymous_3)","line":10,"loc":{"start":{"line":10,"column":12},"end":{"line":10,"column":32}}},"4":{"name":"(anonymous_4)","line":13,"loc":{"start":{"line":13,"column":13},"end":{"line":13,"column":27}}},"5":{"name":"(anonymous_5)","line":18,"loc":{"start":{"line":18,"column":23},"end":{"line":18,"column":60}}},"6":{"name":"(anonymous_6)","line":19,"loc":{"start":{"line":19,"column":32},"end":{"line":19,"column":54}}},"7":{"name":"(anonymous_7)","line":30,"loc":{"start":{"line":30,"column":25},"end":{"line":30,"column":47}}},"8":{"name":"(anonymous_8)","line":32,"loc":{"start":{"line":32,"column":12},"end":{"line":32,"column":34}}},"9":{"name":"(anonymous_9)","line":35,"loc":{"start":{"line":35,"column":13},"end":{"line":35,"column":27}}},"10":{"name":"(anonymous_10)","line":40,"loc":{"start":{"line":40,"column":25},"end":{"line":40,"column":61}}},"11":{"name":"(anonymous_11)","line":42,"loc":{"start":{"line":42,"column":12},"end":{"line":42,"column":29}}},"12":{"name":"(anonymous_12)","line":45,"loc":{"start":{"line":45,"column":13},"end":{"line":45,"column":27}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":5,"column":41}},"2":{"start":{"line":7,"column":0},"end":{"line":49,"column":2}},"3":{"start":{"line":8,"column":2},"end":{"line":16,"column":4}},"4":{"start":{"line":9,"column":4},"end":{"line":15,"column":9}},"5":{"start":{"line":11,"column":8},"end":{"line":11,"column":30}},"6":{"start":{"line":14,"column":8},"end":{"line":14,"column":18}},"7":{"start":{"line":18,"column":2},"end":{"line":28,"column":4}},"8":{"start":{"line":19,"column":4},"end":{"line":27,"column":7}},"9":{"start":{"line":20,"column":6},"end":{"line":25,"column":7}},"10":{"start":{"line":21,"column":8},"end":{"line":24,"column":9}},"11":{"start":{"line":22,"column":10},"end":{"line":22,"column":40}},"12":{"start":{"line":23,"column":10},"end":{"line":23,"column":17}},"13":{"start":{"line":26,"column":6},"end":{"line":26,"column":35}},"14":{"start":{"line":30,"column":2},"end":{"line":38,"column":4}},"15":{"start":{"line":31,"column":4},"end":{"line":37,"column":9}},"16":{"start":{"line":33,"column":8},"end":{"line":33,"column":38}},"17":{"start":{"line":36,"column":8},"end":{"line":36,"column":18}},"18":{"start":{"line":40,"column":2},"end":{"line":48,"column":4}},"19":{"start":{"line":41,"column":4},"end":{"line":47,"column":9}},"20":{"start":{"line":43,"column":8},"end":{"line":43,"column":19}},"21":{"start":{"line":46,"column":8},"end":{"line":46,"column":18}}},"branchMap":{"1":{"line":21,"type":"if","locations":[{"start":{"line":21,"column":8},"end":{"line":21,"column":8}},{"start":{"line":21,"column":8},"end":{"line":21,"column":8}}]}}},"/src/app.js":{"path":"/src/app.js","s":{"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":1,"11":1,"12":1,"13":1,"14":1,"15":1,"16":1,"17":1,"18":1,"19":1,"20":1,"21":1,"22":1,"23":1,"24":1,"25":0,"26":0,"27":0,"28":1,"29":1,"30":1,"31":1,"32":1,"33":0,"34":0,"35":1},"b":{"1":[1,0],"2":[1,1],"3":[0,0]},"f":{"1":0,"2":1,"3":0},"fnMap":{"1":{"name":"(anonymous_1)","line":42,"loc":{"start":{"line":42,"column":8},"end":{"line":42,"column":33}}},"2":{"name":"(anonymous_2)","line":53,"loc":{"start":{"line":53,"column":10},"end":{"line":53,"column":40}}},"3":{"name":"(anonymous_3)","line":64,"loc":{"start":{"line":64,"column":8},"end":{"line":64,"column":38}}}},"statementMap":{"1":{"start":{"line":1,"column":0},"end":{"line":1,"column":33}},"2":{"start":{"line":2,"column":0},"end":{"line":2,"column":27}},"3":{"start":{"line":3,"column":0},"end":{"line":3,"column":39}},"4":{"start":{"line":4,"column":0},"end":{"line":4,"column":31}},"5":{"start":{"line":5,"column":0},"end":{"line":5,"column":44}},"6":{"start":{"line":6,"column":0},"end":{"line":6,"column":40}},"7":{"start":{"line":8,"column":0},"end":{"line":8,"column":39}},"8":{"start":{"line":9,"column":0},"end":{"line":9,"column":54}},"9":{"start":{"line":10,"column":0},"end":{"line":10,"column":46}},"10":{"start":{"line":11,"column":0},"end":{"line":11,"column":38}},"11":{"start":{"line":12,"column":0},"end":{"line":12,"column":33}},"12":{"start":{"line":14,"column":0},"end":{"line":14,"column":20}},"13":{"start":{"line":17,"column":0},"end":{"line":17,"column":48}},"14":{"start":{"line":18,"column":0},"end":{"line":18,"column":30}},"15":{"start":{"line":22,"column":0},"end":{"line":22,"column":23}},"16":{"start":{"line":23,"column":0},"end":{"line":23,"column":27}},"17":{"start":{"line":24,"column":0},"end":{"line":24,"column":24}},"18":{"start":{"line":31,"column":0},"end":{"line":31,"column":56}},"19":{"start":{"line":33,"column":0},"end":{"line":33,"column":21}},"20":{"start":{"line":34,"column":0},"end":{"line":34,"column":40}},"21":{"start":{"line":35,"column":0},"end":{"line":35,"column":48}},"22":{"start":{"line":36,"column":0},"end":{"line":36,"column":25}},"23":{"start":{"line":37,"column":0},"end":{"line":37,"column":21}},"24":{"start":{"line":42,"column":0},"end":{"line":46,"column":3}},"25":{"start":{"line":43,"column":2},"end":{"line":43,"column":35}},"26":{"start":{"line":44,"column":2},"end":{"line":44,"column":19}},"27":{"start":{"line":45,"column":2},"end":{"line":45,"column":12}},"28":{"start":{"line":52,"column":0},"end":{"line":60,"column":1}},"29":{"start":{"line":53,"column":2},"end":{"line":59,"column":5}},"30":{"start":{"line":54,"column":4},"end":{"line":54,"column":34}},"31":{"start":{"line":55,"column":4},"end":{"line":58,"column":7}},"32":{"start":{"line":64,"column":0},"end":{"line":70,"column":3}},"33":{"start":{"line":65,"column":2},"end":{"line":65,"column":32}},"34":{"start":{"line":66,"column":2},"end":{"line":69,"column":5}},"35":{"start":{"line":73,"column":0},"end":{"line":73,"column":21}}},"branchMap":{"1":{"line":52,"type":"if","locations":[{"start":{"line":52,"column":0},"end":{"line":52,"column":0}},{"start":{"line":52,"column":0},"end":{"line":52,"column":0}}]},"2":{"line":54,"type":"binary-expr","locations":[{"start":{"line":54,"column":15},"end":{"line":54,"column":25}},{"start":{"line":54,"column":29},"end":{"line":54,"column":32}}]},"3":{"line":65,"type":"binary-expr","locations":[{"start":{"line":65,"column":13},"end":{"line":65,"column":23}},{"start":{"line":65,"column":27},"end":{"line":65,"column":30}}]}}},"/src/routes/index.js":{"path":"/src/routes/index.js","s":{"1":1,"2":1,"3":1,"4":1,"5":1},"b":{},"f":{"1":1},"fnMap":{"1":{"name":"(anonymous_1)","line":5,"loc":{"start":{"line":5,"column":16},"end":{"line":5,"column":41}}}},"statementMap":{"1":{"start":{"line":1,"column":0},"end":{"line":1,"column":33}},"2":{"start":{"line":2,"column":0},"end":{"line":2,"column":30}},"3":{"start":{"line":5,"column":0},"end":{"line":7,"column":3}},"4":{"start":{"line":6,"column":2},"end":{"line":6,"column":26}},"5":{"start":{"line":9,"column":0},"end":{"line":9,"column":24}}},"branchMap":{}},"/src/routes/containers.js":{"path":"/src/routes/containers.js","s":{"1":1,"2":1,"3":0,"4":0,"5":0,"6":0,"7":0,"8":1,"9":0,"10":0,"11":0,"12":0,"13":0,"14":1,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":1,"29":0,"30":0,"31":0,"32":0,"33":0,"34":1,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":1},"b":{"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0]},"f":{"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0},"fnMap":{"1":{"name":"(anonymous_1)","line":38,"loc":{"start":{"line":38,"column":16},"end":{"line":38,"column":41}}},"2":{"name":"(anonymous_2)","line":39,"loc":{"start":{"line":39,"column":23},"end":{"line":39,"column":49}}},"3":{"name":"(anonymous_3)","line":68,"loc":{"start":{"line":68,"column":26},"end":{"line":68,"column":51}}},"4":{"name":"(anonymous_4)","line":69,"loc":{"start":{"line":69,"column":48},"end":{"line":69,"column":68}}},"5":{"name":"(anonymous_5)","line":118,"loc":{"start":{"line":118,"column":17},"end":{"line":118,"column":42}}},"6":{"name":"(anonymous_6)","line":125,"loc":{"start":{"line":125,"column":45},"end":{"line":125,"column":72}}},"7":{"name":"(anonymous_7)","line":131,"loc":{"start":{"line":131,"column":41},"end":{"line":131,"column":61}}},"8":{"name":"(anonymous_8)","line":149,"loc":{"start":{"line":149,"column":29},"end":{"line":149,"column":54}}},"9":{"name":"(anonymous_9)","line":150,"loc":{"start":{"line":150,"column":54},"end":{"line":150,"column":74}}},"10":{"name":"(anonymous_10)","line":180,"loc":{"start":{"line":180,"column":35},"end":{"line":180,"column":60}}},"11":{"name":"(anonymous_11)","line":181,"loc":{"start":{"line":181,"column":48},"end":{"line":181,"column":68}}},"12":{"name":"(anonymous_12)","line":204,"loc":{"start":{"line":204,"column":56},"end":{"line":204,"column":76}}},"13":{"name":"(anonymous_13)","line":205,"loc":{"start":{"line":205,"column":40},"end":{"line":205,"column":67}}},"14":{"name":"(anonymous_14)","line":211,"loc":{"start":{"line":211,"column":45},"end":{"line":211,"column":65}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":6,"column":28}},"2":{"start":{"line":38,"column":0},"end":{"line":47,"column":3}},"3":{"start":{"line":39,"column":2},"end":{"line":46,"column":5}},"4":{"start":{"line":40,"column":4},"end":{"line":43,"column":5}},"5":{"start":{"line":41,"column":6},"end":{"line":41,"column":16}},"6":{"start":{"line":42,"column":6},"end":{"line":42,"column":13}},"7":{"start":{"line":45,"column":4},"end":{"line":45,"column":25}},"8":{"start":{"line":68,"column":0},"end":{"line":77,"column":3}},"9":{"start":{"line":69,"column":2},"end":{"line":76,"column":5}},"10":{"start":{"line":70,"column":4},"end":{"line":73,"column":5}},"11":{"start":{"line":71,"column":6},"end":{"line":71,"column":16}},"12":{"start":{"line":72,"column":6},"end":{"line":72,"column":13}},"13":{"start":{"line":75,"column":4},"end":{"line":75,"column":19}},"14":{"start":{"line":118,"column":0},"end":{"line":140,"column":3}},"15":{"start":{"line":119,"column":2},"end":{"line":123,"column":3}},"16":{"start":{"line":120,"column":4},"end":{"line":120,"column":63}},"17":{"start":{"line":121,"column":4},"end":{"line":121,"column":30}},"18":{"start":{"line":122,"column":4},"end":{"line":122,"column":11}},"19":{"start":{"line":125,"column":2},"end":{"line":139,"column":5}},"20":{"start":{"line":126,"column":4},"end":{"line":129,"column":5}},"21":{"start":{"line":127,"column":6},"end":{"line":127,"column":32}},"22":{"start":{"line":128,"column":6},"end":{"line":128,"column":13}},"23":{"start":{"line":131,"column":4},"end":{"line":138,"column":7}},"24":{"start":{"line":132,"column":6},"end":{"line":135,"column":7}},"25":{"start":{"line":133,"column":8},"end":{"line":133,"column":18}},"26":{"start":{"line":134,"column":8},"end":{"line":134,"column":15}},"27":{"start":{"line":137,"column":6},"end":{"line":137,"column":21}},"28":{"start":{"line":149,"column":0},"end":{"line":158,"column":3}},"29":{"start":{"line":150,"column":2},"end":{"line":157,"column":5}},"30":{"start":{"line":151,"column":4},"end":{"line":154,"column":5}},"31":{"start":{"line":152,"column":6},"end":{"line":152,"column":16}},"32":{"start":{"line":153,"column":6},"end":{"line":153,"column":13}},"33":{"start":{"line":156,"column":4},"end":{"line":156,"column":27}},"34":{"start":{"line":180,"column":0},"end":{"line":222,"column":3}},"35":{"start":{"line":181,"column":2},"end":{"line":221,"column":5}},"36":{"start":{"line":182,"column":4},"end":{"line":185,"column":5}},"37":{"start":{"line":183,"column":6},"end":{"line":183,"column":16}},"38":{"start":{"line":184,"column":6},"end":{"line":184,"column":13}},"39":{"start":{"line":186,"column":4},"end":{"line":202,"column":6}},"40":{"start":{"line":204,"column":4},"end":{"line":220,"column":7}},"41":{"start":{"line":205,"column":6},"end":{"line":219,"column":9}},"42":{"start":{"line":206,"column":8},"end":{"line":209,"column":9}},"43":{"start":{"line":207,"column":10},"end":{"line":207,"column":36}},"44":{"start":{"line":208,"column":10},"end":{"line":208,"column":17}},"45":{"start":{"line":211,"column":8},"end":{"line":218,"column":11}},"46":{"start":{"line":212,"column":10},"end":{"line":215,"column":11}},"47":{"start":{"line":213,"column":12},"end":{"line":213,"column":22}},"48":{"start":{"line":214,"column":12},"end":{"line":214,"column":19}},"49":{"start":{"line":217,"column":10},"end":{"line":217,"column":25}},"50":{"start":{"line":224,"column":0},"end":{"line":224,"column":24}}},"branchMap":{"1":{"line":40,"type":"if","locations":[{"start":{"line":40,"column":4},"end":{"line":40,"column":4}},{"start":{"line":40,"column":4},"end":{"line":40,"column":4}}]},"2":{"line":70,"type":"if","locations":[{"start":{"line":70,"column":4},"end":{"line":70,"column":4}},{"start":{"line":70,"column":4},"end":{"line":70,"column":4}}]},"3":{"line":119,"type":"if","locations":[{"start":{"line":119,"column":2},"end":{"line":119,"column":2}},{"start":{"line":119,"column":2},"end":{"line":119,"column":2}}]},"4":{"line":119,"type":"binary-expr","locations":[{"start":{"line":119,"column":6},"end":{"line":119,"column":25}},{"start":{"line":119,"column":29},"end":{"line":119,"column":54}}]},"5":{"line":126,"type":"if","locations":[{"start":{"line":126,"column":4},"end":{"line":126,"column":4}},{"start":{"line":126,"column":4},"end":{"line":126,"column":4}}]},"6":{"line":132,"type":"if","locations":[{"start":{"line":132,"column":6},"end":{"line":132,"column":6}},{"start":{"line":132,"column":6},"end":{"line":132,"column":6}}]},"7":{"line":151,"type":"if","locations":[{"start":{"line":151,"column":4},"end":{"line":151,"column":4}},{"start":{"line":151,"column":4},"end":{"line":151,"column":4}}]},"8":{"line":182,"type":"if","locations":[{"start":{"line":182,"column":4},"end":{"line":182,"column":4}},{"start":{"line":182,"column":4},"end":{"line":182,"column":4}}]},"9":{"line":206,"type":"if","locations":[{"start":{"line":206,"column":8},"end":{"line":206,"column":8}},{"start":{"line":206,"column":8},"end":{"line":206,"column":8}}]},"10":{"line":212,"type":"if","locations":[{"start":{"line":212,"column":10},"end":{"line":212,"column":10}},{"start":{"line":212,"column":10},"end":{"line":212,"column":10}}]}}},"/src/routes/images.js":{"path":"/src/routes/images.js","s":{"1":1,"2":1,"3":0,"4":0,"5":0,"6":0,"7":0,"8":1,"9":0,"10":0,"11":0,"12":0,"13":0,"14":1,"15":0,"16":0,"17":0,"18":0,"19":0,"20":1},"b":{"1":[0,0],"2":[0,0],"3":[0,0]},"f":{"1":0,"2":0,"3":0,"4":0,"5":0,"6":0},"fnMap":{"1":{"name":"(anonymous_1)","line":25,"loc":{"start":{"line":25,"column":16},"end":{"line":25,"column":41}}},"2":{"name":"(anonymous_2)","line":26,"loc":{"start":{"line":26,"column":22},"end":{"line":26,"column":44}}},"3":{"name":"(anonymous_3)","line":47,"loc":{"start":{"line":47,"column":17},"end":{"line":47,"column":42}}},"4":{"name":"(anonymous_4)","line":48,"loc":{"start":{"line":48,"column":39},"end":{"line":48,"column":64}}},"5":{"name":"(anonymous_5)","line":77,"loc":{"start":{"line":77,"column":27},"end":{"line":77,"column":52}}},"6":{"name":"(anonymous_6)","line":78,"loc":{"start":{"line":78,"column":44},"end":{"line":78,"column":64}}}},"statementMap":{"1":{"start":{"line":2,"column":0},"end":{"line":5,"column":28}},"2":{"start":{"line":25,"column":0},"end":{"line":34,"column":3}},"3":{"start":{"line":26,"column":2},"end":{"line":33,"column":5}},"4":{"start":{"line":27,"column":4},"end":{"line":30,"column":5}},"5":{"start":{"line":28,"column":6},"end":{"line":28,"column":16}},"6":{"start":{"line":29,"column":6},"end":{"line":29,"column":13}},"7":{"start":{"line":32,"column":4},"end":{"line":32,"column":21}},"8":{"start":{"line":47,"column":0},"end":{"line":56,"column":3}},"9":{"start":{"line":48,"column":2},"end":{"line":55,"column":5}},"10":{"start":{"line":49,"column":4},"end":{"line":52,"column":5}},"11":{"start":{"line":50,"column":6},"end":{"line":50,"column":16}},"12":{"start":{"line":51,"column":6},"end":{"line":51,"column":13}},"13":{"start":{"line":54,"column":4},"end":{"line":54,"column":20}},"14":{"start":{"line":77,"column":0},"end":{"line":86,"column":3}},"15":{"start":{"line":78,"column":2},"end":{"line":85,"column":5}},"16":{"start":{"line":79,"column":4},"end":{"line":82,"column":5}},"17":{"start":{"line":80,"column":6},"end":{"line":80,"column":16}},"18":{"start":{"line":81,"column":6},"end":{"line":81,"column":13}},"19":{"start":{"line":84,"column":4},"end":{"line":84,"column":19}},"20":{"start":{"line":88,"column":0},"end":{"line":88,"column":24}}},"branchMap":{"1":{"line":27,"type":"if","locations":[{"start":{"line":27,"column":4},"end":{"line":27,"column":4}},{"start":{"line":27,"column":4},"end":{"line":27,"column":4}}]},"2":{"line":49,"type":"if","locations":[{"start":{"line":49,"column":4},"end":{"line":49,"column":4}},{"start":{"line":49,"column":4},"end":{"line":49,"column":4}}]},"3":{"line":79,"type":"if","locations":[{"start":{"line":79,"column":4},"end":{"line":79,"column":4}},{"start":{"line":79,"column":4},"end":{"line":79,"column":4}}]}}},"/src/routes/hooks.js":{"path":"/src/routes/hooks.js","s":{"1":1,"2":1,"3":1,"4":1,"5":2,"6":2,"7":1,"8":1,"9":1,"10":1,"11":1,"12":1,"13":1,"14":4,"15":4,"16":1,"17":1,"18":3,"19":1,"20":1,"21":1,"22":0,"23":0,"24":1,"25":1},"b":{"1":[1,1],"2":[1,3],"3":[0,1]},"f":{"1":2,"2":2,"3":1,"4":1,"5":4,"6":4,"7":1,"8":1},"fnMap":{"1":{"name":"(anonymous_1)","line":12,"loc":{"start":{"line":12,"column":24},"end":{"line":12,"column":49}}},"2":{"name":"(anonymous_2)","line":13,"loc":{"start":{"line":13,"column":54},"end":{"line":13,"column":74}}},"3":{"name":"(anonymous_3)","line":36,"loc":{"start":{"line":36,"column":16},"end":{"line":36,"column":41}}},"4":{"name":"(anonymous_4)","line":37,"loc":{"start":{"line":37,"column":24},"end":{"line":37,"column":44}}},"5":{"name":"(anonymous_5)","line":49,"loc":{"start":{"line":49,"column":33},"end":{"line":49,"column":58}}},"6":{"name":"(anonymous_6)","line":50,"loc":{"start":{"line":50,"column":71},"end":{"line":50,"column":86}}},"7":{"name":"(anonymous_7)","line":65,"loc":{"start":{"line":65,"column":24},"end":{"line":65,"column":49}}},"8":{"name":"(anonymous_8)","line":66,"loc":{"start":{"line":66,"column":52},"end":{"line":66,"column":67}}}},"statementMap":{"1":{"start":{"line":2,"column":0},"end":{"line":2,"column":33}},"2":{"start":{"line":3,"column":0},"end":{"line":3,"column":30}},"3":{"start":{"line":4,"column":0},"end":{"line":4,"column":37}},"4":{"start":{"line":12,"column":0},"end":{"line":28,"column":3}},"5":{"start":{"line":13,"column":2},"end":{"line":27,"column":5}},"6":{"start":{"line":14,"column":4},"end":{"line":22,"column":5}},"7":{"start":{"line":15,"column":6},"end":{"line":20,"column":9}},"8":{"start":{"line":21,"column":6},"end":{"line":21,"column":13}},"9":{"start":{"line":24,"column":4},"end":{"line":26,"column":7}},"10":{"start":{"line":36,"column":0},"end":{"line":42,"column":3}},"11":{"start":{"line":37,"column":2},"end":{"line":41,"column":5}},"12":{"start":{"line":38,"column":4},"end":{"line":40,"column":7}},"13":{"start":{"line":49,"column":0},"end":{"line":58,"column":3}},"14":{"start":{"line":50,"column":2},"end":{"line":57,"column":5}},"15":{"start":{"line":51,"column":4},"end":{"line":54,"column":5}},"16":{"start":{"line":52,"column":6},"end":{"line":52,"column":16}},"17":{"start":{"line":53,"column":6},"end":{"line":53,"column":13}},"18":{"start":{"line":56,"column":4},"end":{"line":56,"column":27}},"19":{"start":{"line":65,"column":0},"end":{"line":74,"column":3}},"20":{"start":{"line":66,"column":2},"end":{"line":73,"column":5}},"21":{"start":{"line":67,"column":4},"end":{"line":70,"column":5}},"22":{"start":{"line":68,"column":6},"end":{"line":68,"column":16}},"23":{"start":{"line":69,"column":6},"end":{"line":69,"column":13}},"24":{"start":{"line":72,"column":4},"end":{"line":72,"column":27}},"25":{"start":{"line":78,"column":0},"end":{"line":78,"column":24}}},"branchMap":{"1":{"line":14,"type":"if","locations":[{"start":{"line":14,"column":4},"end":{"line":14,"column":4}},{"start":{"line":14,"column":4},"end":{"line":14,"column":4}}]},"2":{"line":51,"type":"if","locations":[{"start":{"line":51,"column":4},"end":{"line":51,"column":4}},{"start":{"line":51,"column":4},"end":{"line":51,"column":4}}]},"3":{"line":67,"type":"if","locations":[{"start":{"line":67,"column":4},"end":{"line":67,"column":4}},{"start":{"line":67,"column":4},"end":{"line":67,"column":4}}]}}},"/src/hooks/hook.js":{"path":"/src/hooks/hook.js","s":{"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":1,"11":4,"12":1,"13":1,"14":2,"15":2,"16":1,"17":1,"18":1,"19":1,"20":1,"21":4,"22":1,"23":1,"24":3,"25":0,"26":0,"27":3,"28":1,"29":1,"30":4,"31":0,"32":0,"33":1,"34":1},"b":{"1":[1,1],"2":[1,3],"3":[0,0],"4":[0,0]},"f":{"1":1,"2":2,"3":4,"4":0,"5":1,"6":0},"fnMap":{"1":{"name":"(anonymous_1)","line":18,"loc":{"start":{"line":18,"column":23},"end":{"line":18,"column":38}}},"2":{"name":"(anonymous_2)","line":31,"loc":{"start":{"line":31,"column":33},"end":{"line":31,"column":57}}},"3":{"name":"(anonymous_3)","line":45,"loc":{"start":{"line":45,"column":21},"end":{"line":45,"column":59}}},"4":{"name":"(anonymous_4)","line":54,"loc":{"start":{"line":54,"column":41},"end":{"line":54,"column":55}}},"5":{"name":"(anonymous_5)","line":62,"loc":{"start":{"line":62,"column":22},"end":{"line":62,"column":51}}},"6":{"name":"(anonymous_6)","line":64,"loc":{"start":{"line":64,"column":37},"end":{"line":64,"column":51}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":6,"column":39}},"2":{"start":{"line":8,"column":0},"end":{"line":10,"column":2}},"3":{"start":{"line":12,"column":0},"end":{"line":12,"column":16}},"4":{"start":{"line":13,"column":0},"end":{"line":13,"column":33}},"5":{"start":{"line":14,"column":0},"end":{"line":14,"column":29}},"6":{"start":{"line":15,"column":0},"end":{"line":15,"column":33}},"7":{"start":{"line":16,"column":0},"end":{"line":16,"column":33}},"8":{"start":{"line":18,"column":0},"end":{"line":29,"column":2}},"9":{"start":{"line":19,"column":2},"end":{"line":19,"column":24}},"10":{"start":{"line":21,"column":2},"end":{"line":26,"column":3}},"11":{"start":{"line":22,"column":4},"end":{"line":25,"column":7}},"12":{"start":{"line":28,"column":2},"end":{"line":28,"column":27}},"13":{"start":{"line":31,"column":0},"end":{"line":43,"column":2}},"14":{"start":{"line":32,"column":2},"end":{"line":32,"column":24}},"15":{"start":{"line":33,"column":2},"end":{"line":39,"column":3}},"16":{"start":{"line":34,"column":4},"end":{"line":37,"column":7}},"17":{"start":{"line":38,"column":4},"end":{"line":38,"column":11}},"18":{"start":{"line":41,"column":2},"end":{"line":41,"column":47}},"19":{"start":{"line":42,"column":2},"end":{"line":42,"column":9}},"20":{"start":{"line":45,"column":0},"end":{"line":60,"column":2}},"21":{"start":{"line":46,"column":2},"end":{"line":52,"column":3}},"22":{"start":{"line":47,"column":4},"end":{"line":50,"column":7}},"23":{"start":{"line":51,"column":4},"end":{"line":51,"column":11}},"24":{"start":{"line":54,"column":2},"end":{"line":58,"column":5}},"25":{"start":{"line":55,"column":4},"end":{"line":57,"column":5}},"26":{"start":{"line":56,"column":6},"end":{"line":56,"column":23}},"27":{"start":{"line":59,"column":2},"end":{"line":59,"column":13}},"28":{"start":{"line":62,"column":0},"end":{"line":71,"column":2}},"29":{"start":{"line":63,"column":2},"end":{"line":69,"column":3}},"30":{"start":{"line":64,"column":4},"end":{"line":68,"column":7}},"31":{"start":{"line":65,"column":6},"end":{"line":67,"column":7}},"32":{"start":{"line":66,"column":8},"end":{"line":66,"column":25}},"33":{"start":{"line":70,"column":2},"end":{"line":70,"column":13}},"34":{"start":{"line":73,"column":0},"end":{"line":73,"column":22}}},"branchMap":{"1":{"line":33,"type":"if","locations":[{"start":{"line":33,"column":2},"end":{"line":33,"column":2}},{"start":{"line":33,"column":2},"end":{"line":33,"column":2}}]},"2":{"line":46,"type":"if","locations":[{"start":{"line":46,"column":2},"end":{"line":46,"column":2}},{"start":{"line":46,"column":2},"end":{"line":46,"column":2}}]},"3":{"line":55,"type":"if","locations":[{"start":{"line":55,"column":4},"end":{"line":55,"column":4}},{"start":{"line":55,"column":4},"end":{"line":55,"column":4}}]},"4":{"line":65,"type":"if","locations":[{"start":{"line":65,"column":6},"end":{"line":65,"column":6}},{"start":{"line":65,"column":6},"end":{"line":65,"column":6}}]}}},"/src/hooks/sample.js":{"path":"/src/hooks/sample.js","s":{"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":1,"11":1},"b":{},"f":{"1":1,"2":2,"3":0,"4":0,"5":1,"6":0},"fnMap":{"1":{"name":"(anonymous_1)","line":5,"loc":{"start":{"line":5,"column":17},"end":{"line":5,"column":28}}},"2":{"name":"(anonymous_2)","line":16,"loc":{"start":{"line":16,"column":26},"end":{"line":16,"column":41}}},"3":{"name":"(anonymous_3)","line":18,"loc":{"start":{"line":18,"column":27},"end":{"line":18,"column":42}}},"4":{"name":"(anonymous_4)","line":20,"loc":{"start":{"line":20,"column":23},"end":{"line":20,"column":38}}},"5":{"name":"(anonymous_5)","line":22,"loc":{"start":{"line":22,"column":26},"end":{"line":22,"column":41}}},"6":{"name":"(anonymous_6)","line":24,"loc":{"start":{"line":24,"column":26},"end":{"line":24,"column":41}}}},"statementMap":{"1":{"start":{"line":3,"column":0},"end":{"line":3,"column":50}},"2":{"start":{"line":5,"column":0},"end":{"line":27,"column":2}},"3":{"start":{"line":6,"column":2},"end":{"line":6,"column":32}},"4":{"start":{"line":7,"column":2},"end":{"line":7,"column":23}},"5":{"start":{"line":8,"column":2},"end":{"line":14,"column":6}},"6":{"start":{"line":16,"column":2},"end":{"line":16,"column":45}},"7":{"start":{"line":18,"column":2},"end":{"line":18,"column":46}},"8":{"start":{"line":20,"column":2},"end":{"line":20,"column":42}},"9":{"start":{"line":22,"column":2},"end":{"line":22,"column":45}},"10":{"start":{"line":24,"column":2},"end":{"line":24,"column":45}},"11":{"start":{"line":26,"column":2},"end":{"line":26,"column":14}}},"branchMap":{}},"/src/routes/S3.js":{"path":"/src/routes/S3.js","s":{"1":1,"2":1,"3":1,"4":1,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":1},"b":{"1":[0,0],"2":[0,0]},"f":{"1":0,"2":0},"fnMap":{"1":{"name":"(anonymous_1)","line":36,"loc":{"start":{"line":36,"column":16},"end":{"line":36,"column":42}}},"2":{"name":"(anonymous_2)","line":40,"loc":{"start":{"line":40,"column":29},"end":{"line":40,"column":50}}}},"statementMap":{"1":{"start":{"line":2,"column":0},"end":{"line":4,"column":28}},"2":{"start":{"line":6,"column":0},"end":{"line":6,"column":29}},"3":{"start":{"line":7,"column":0},"end":{"line":7,"column":49}},"4":{"start":{"line":36,"column":0},"end":{"line":58,"column":3}},"5":{"start":{"line":37,"column":4},"end":{"line":37,"column":26}},"6":{"start":{"line":38,"column":4},"end":{"line":38,"column":87}},"7":{"start":{"line":40,"column":4},"end":{"line":57,"column":7}},"8":{"start":{"line":41,"column":8},"end":{"line":44,"column":9}},"9":{"start":{"line":42,"column":12},"end":{"line":42,"column":22}},"10":{"start":{"line":43,"column":12},"end":{"line":43,"column":19}},"11":{"start":{"line":45,"column":8},"end":{"line":45,"column":32}},"12":{"start":{"line":46,"column":8},"end":{"line":55,"column":9}},"13":{"start":{"line":48,"column":10},"end":{"line":54,"column":11}},"14":{"start":{"line":49,"column":12},"end":{"line":53,"column":15}},"15":{"start":{"line":56,"column":8},"end":{"line":56,"column":23}},"16":{"start":{"line":60,"column":0},"end":{"line":60,"column":24}}},"branchMap":{"1":{"line":41,"type":"if","locations":[{"start":{"line":41,"column":8},"end":{"line":41,"column":8}},{"start":{"line":41,"column":8},"end":{"line":41,"column":8}}]},"2":{"line":48,"type":"if","locations":[{"start":{"line":48,"column":10},"end":{"line":48,"column":10}},{"start":{"line":48,"column":10},"end":{"line":48,"column":10}}]}}}}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node3-result.json b/apps/worker/services/report/languages/tests/unit/node/node3-result.json
new file mode 100644
index 0000000000..12884b254a
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node3-result.json
@@ -0,0 +1,150 @@
+{
+ "archive": [
+ "{}\n<<<<< end_of_header >>>>>\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[24,88,\"1/2\"],[24,50,\"1/2\"],[54,88,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[431,446,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,24,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[346,null,[[0,346]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[55,66,\"1/2\"]],null]]]\n[12,null,[[0,12]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[161,168,\"0\"]],null]]]\n\n\n\n\n\n\n\n[152,null,[[0,152]]]\n\n\n[152,null,[[0,152]]]\n\n\n[152,null,[[0,152]]]\n\n\n\n\n[174,null,[[0,174]]]\n\n\n\n[12,null,[[0,12]]]\n[12,null,[[0,12]]]\n\n\n\n\n[8,null,[[0,8]]]\n\n\n\n[12,null,[[0,12]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[22,null,[[0,22]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[84,101,\"1/2\"],[84,90,\"1/2\"],[94,101,\"1/2\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,47,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[272,null,[[0,272]]]\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[179,206,\"1\"]],null]]]\n[17,null,[[0,17]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[67,87,\"1/2\"]],null]]]\n[17,null,[[0,17]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[393,451,\"1/2\"],[393,419,\"1\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[537,549,\"1\"]],null]]]\n[34,null,[[0,34]]]\n[34,null,[[0,34]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[591,601,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[57,91,\"1\"]],null]]]\n\n\n[166,null,[[0,166]]]\n[1569,null,[[0,1569]]]\n[1569,null,[[0,1569]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[98,113,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[205,302,\"1\"],[213,302,\"1\"],[223,300,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[12,23,\"1\"]],null]]]\n[10,null,[[0,10]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[42,70,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15,49,\"1/2\"]],null]]]\n\n\n\n[13,null,[[0,13]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[663,681,\"1\"]],null]]]\n[1548,null,[[0,1548]]]\n\n\n\n\n\n\n[272,null,[[0,272]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[10,43,\"1\"]],null]]]\n\n\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[10,43,\"1\"],[10,21,\"1/2\"],[25,43,\"1\"]],null]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[195,462,\"0\"],[195,233,\"0\"],[195,212,\"0\"],[216,233,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n[1627,null,[[0,1627]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[121,171,\"1\"],[129,171,\"1\"]],null]]]\n[1468,null,[[0,1468]]]\n\n\n[159,null,[[0,159]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[306,312,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[439,495,\"1/2\"]],null]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[506,584,\"1/2\"],[506,532,\"1/2\"],[536,584,\"1/2\"]],null]]]\n\n\n\n\n\n\n[138,null,[[0,138]]]\n\n[138,null,[[0,138]]]\n[86,null,[[0,86]]]\n\n[52,null,[[0,52]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[8,19,\"1\"]],null]]]\n[45,null,[[0,45]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[112,164,\"1\"],[112,135,\"1\"],[139,164,\"1\"]],null]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[292,null,[[0,292]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[10,77,\"1/2\"],[27,77,\"1/2\"]],null]]]\n\n\n\n[316,null,[[0,316]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[30,48,\"1\"]],null]]]\n[272,null,[[0,272]]]\n[\"1\",\"b\",[[0,\"1\",null,[[36,46,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,58,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[44,null,[[0,44]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,58,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[316,null,[[0,316]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,22,\"0\"]],null]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,26,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[36,47,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[9,37,\"1\"]],null]]]\n[19,null,[[0,19]]]\n\n\n\n\n[18,null,[[0,18]]]\n\n\n\n[37,null,[[0,37]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,21,\"1/2\"]],null]]]\n\n\n\n\n\n[230,null,[[0,230]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[71,78,\"1\"]],null]]]\n[249,null,[[0,249]]]\n\n\n[230,null,[[0,230]]]\n\n[230,null,[[0,230]]]\n\n\n\n[20,null,[[0,20]]]\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[204,214,\"1\"]],null]]]\n[63,null,[[0,63]]]\n[\"1\",\"b\",[[0,\"1\",null,[[58,92,\"1\"]],null]]]\n[61,null,[[0,61]]]\n\n\n\n[20,null,[[0,20]]]\n\n\n\n\n[57,null,[[0,57]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[130,150,\"1/2\"]],null]]]\n[57,null,[[0,57]]]\n[\"1\",\"b\",[[0,\"1\",null,[[38,48,\"1\"]],null]]]\n[46,null,[[0,46]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[55,68,\"1/2\"]],null]]]\n[46,null,[[0,46]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[55,68,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[57,null,[[0,57]]]\n\n\n\n\n\n\n\n\n[37,null,[[0,37]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[33,60,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[261,285,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[36,null,[[0,36]]]\n[36,null,[[0,36]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,36,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[596,620,\"1/2\"]],null]]]\n\n[36,null,[[0,36]]]\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11002,11030,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[10,null,[[0,10]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[216,254,\"1\"],[225,254,\"1\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[292,337,\"1\"],[292,311,\"1/2\"]],null]]]\n[18,null,[[0,18]]]\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[370,473,\"1\"],[370,386,\"1\"],[390,473,\"1\"],[390,402,\"1\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[8,15,\"1\"]],null]]]\n[4,null,[[0,4]]]\n\n[64,null,[[0,64]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[46,53,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,25,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[161,195,\"0\"],[161,174,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,19,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[11,72,\"0\"],[11,33,\"0\"],[38,71,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[18,28,\"1/2\"]],null]]]\n\n\n\n[90,null,[[0,90]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[84,107,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[111,null,[[0,111]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[222,235,\"1/2\"]],null]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[305,403,\"1/2\"],[305,333,\"1/2\"],[337,403,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[515,520,\"1\"]],null]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[11,83,\"1/2\"],[11,18,\"1/2\"],[21,53,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[131,150,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[160,174,\"1/2\"]],null]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[163,219,\"1\"],[163,178,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[31,44,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[37,51,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,48,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[151,164,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[147,260,\"0\"],[162,260,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[843,851,\"1\"]],null]]]\n[7,null,[[0,7]]]\n[7,null,[[0,7]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[988,1074,\"1/2\"],[1005,1074,\"1/2\"]],null]]]\n\n\n[28,null,[[0,28]]]\n[28,null,[[0,28]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1448,1550,\"1\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[11,25,\"1\"]],null]]]\n[8,null,[[0,8]]]\n[8,null,[[0,8]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[287,330,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[75,110,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[48,null,[[0,48]]]\n\n\n\n[48,null,[[0,48]]]\n[48,null,[[0,48]]]\n[\"1\",\"b\",[[0,\"1\",null,[[356,359,\"1\"]],null]]]\n[120,null,[[0,120]]]\n\n[48,null,[[0,48]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[535,614,\"1/2\"],[535,597,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[965,976,\"1/2\"]],null]]]\n[56,null,[[0,56]]]\n[56,null,[[0,56]]]\n\n\n[55,null,[[0,55]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[12,27,\"1\"]],null]]]\n[48,null,[[0,48]]]\n\n\n\n\n\n\n\n\n[21,null,[[0,21]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[3,null,[[0,3]]]\n\n[3,null,[[0,3]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[98,139,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[20,null,[[0,20]]]\n\n[3,null,[[0,3]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[12,null,[[0,12]]]\n[12,null,[[0,12]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[10,null,[[0,10]]]\n\n[10,null,[[0,10]]]\n[10,null,[[0,10]]]\n\n[2,null,[[0,2]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[47,60,\"1\"]],null]]]\n[3,null,[[0,3]]]\n\n\n[10,null,[[0,10]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[58,61,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,18,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[167,171,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[226,229,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,34,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,18,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[330,331,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[52,90,\"0\"],[52,68,\"0\"],[72,90,\"0\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[53,113,\"0\"],[53,90,\"0\"],[53,69,\"0\"],[73,90,\"0\"],[95,113,\"0\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[2,null,[[0,2]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[221,235,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[476,518,\"0\"],[495,518,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,87,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,37,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,51,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[14,40,\"0\"]],null]]]\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[376,567,\"0\"],[376,404,\"0\"]],null]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1107,1133,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1628,1643,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,37,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[7,null,[[0,7]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[156,159,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,39,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[9,82,\"0\"],[20,82,\"0\"],[20,71,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[137,189,\"1/2\"],[146,172,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[199,214,\"1/2\"],[217,252,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[149,211,\"1/2\"],[149,165,\"1/2\"],[169,211,\"1/2\"],[169,187,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[487,583,\"1/2\"],[487,512,\"1/2\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[35,61,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,197,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[52,128,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2000,2015,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,46,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,72,\"0\"],[9,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[57,61,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[16,96,\"0\"],[16,60,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[109,138,\"0\"],[117,138,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,72,\"0\"],[9,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[77,81,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[85,110,\"0\"],[93,110,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[257,275,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[54,79,\"0\"],[62,79,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3445,3473,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,60,\"1/2\"]],null]]]\n[5,null,[[0,5]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,187,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,229,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,34,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,30,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4201,4412,\"1/2\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[8,79,\"0\"],[8,61,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4973,5029,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[683,733,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[896,937,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1109,1164,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1404,1443,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1642,1694,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[454,492,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[717,761,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[978,1023,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[8114,8312,\"1/2\"],[8165,8308,\"1/2\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8685,8738,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8757,8818,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9092,9138,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[16,32,\"1/2\"]],null]]]\n\n[\"5/5\",\"b\",[[0,\"5/5\",null,[[97,272,\"1\"],[97,106,\"1/2\"],[114,271,\"1\"],[121,271,\"1\"],[121,139,\"1\"]],null]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,10,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,31,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,18,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9721,9731,\"1/2\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[41,48,\"1\"]],null]]]\n[19,null,[[0,19]]]\n[19,null,[[0,19]]]\n\n\n\n[57,null,[[0,57]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[251,258,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[368,419,\"1/2\"],[368,388,\"1/2\"],[397,417,\"1/2\"]],null]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[543,630,\"1/2\"]],null]]]\n\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[83,162,\"0\"],[83,97,\"0\"],[101,162,\"0\"],[101,133,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[195,274,\"0\"],[195,209,\"0\"],[213,274,\"0\"],[213,245,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[339,348,\"0\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1066,1077,\"1/2\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[51,58,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[269,281,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,25,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[527,538,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[669,690,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[743,764,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[856,871,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[898,899,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[16,null,[[0,16]]]\n\n\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[41,82,\"1/2\"],[41,67,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[19,null,[[0,19]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[223,404,\"1/2\"],[250,404,\"1/2\"]],null]]]\n\n\n\n\n[19,null,[[0,19]]]\n[19,null,[[0,19]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[116,293,\"1/2\"],[123,293,\"1/2\"]],null]]]\n\n\n\n[19,null,[[0,19]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[765,816,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[41,88,\"1/2\"],[41,73,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[68,null,[[0,68]]]\n\n\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[41,82,\"1/2\"],[41,67,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[81,null,[[0,81]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[361,378,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[20,null,[[0,20]]]\n\n\n\n\n\n[20,null,[[0,20]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[175,216,\"1/2\"]],null]]]\n[20,null,[[0,20]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[253,265,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,32,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[9,30,\"1\"]],null]]]\n[10,null,[[0,10]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[120,123,\"1\"]],null]]]\n[10,null,[[0,10]]]\n\n\n\n\n\n[20,null,[[0,20]]]\n\n[20,null,[[0,20]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[68,77,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,82,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[249,300,\"0\"],[249,263,\"0\"],[267,300,\"0\"],[267,281,\"0\"],[285,300,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[116,152,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[62,66,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[646,678,\"0\"],[646,660,\"0\"],[664,678,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[3,null,[[0,3]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[140,178,\"1\"],[152,178,\"1\"],[164,178,\"1\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[223,240,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[3,null,[[0,3]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[343,375,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[41,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[224,232,\"0\"],[247,260,\"0\"],[270,311,\"0\"],[270,289,\"0\"],[293,311,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[337,380,\"0\"],[362,380,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[817,825,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[2,null,[[0,2]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[63,98,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[170,178,\"1/2\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[16,42,\"0\"],[28,42,\"0\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[297,557,\"1/2\"],[309,557,\"0\"]],null]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[2,null,[[0,2]]]\n\n\n\n\n\n\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[93,117,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,70,\"0\"],[30,70,\"0\"]],null]]]\n\n\n\n\n[2,null,[[0,2]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[60,382,\"1/2\"]],null]]]\n\n\n[\"6/6\",\"b\",[[0,\"6/6\",null,[[27,159,\"1\"],[21,73,\"1\"],[22,56,\"1/2\"],[55,131,\"1/2\"],[78,148,\"1/2\"],[79,119,\"1/2\"]],null]]]\n\n\n\n\n[2,null,[[0,2]]]\n[63,null,[[0,63]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[55,69,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,30,\"1/2\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[120,129,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[189,205,\"0\"],[208,224,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[17,45,\"0\"]],null]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[134,159,\"0\"],[134,145,\"0\"],[149,159,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[283,289,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[57,63,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,19,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,58,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[15,102,\"0\"],[15,21,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[306,348,\"0\"],[306,321,\"0\"],[325,348,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[494,501,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[619,638,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[134,175,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[291,364,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[382,407,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[428,464,\"0\"],[428,450,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[480,503,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[519,562,\"0\"]],null]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[581,726,\"0\"],[588,725,\"0\"],[588,622,\"0\"],[603,622,\"0\"]],null]]]\n\n\n\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[74,120,\"0\"],[74,93,\"0\"],[97,120,\"0\"],[107,120,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,80,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[80,121,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[240,314,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[333,358,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[380,416,\"0\"],[380,402,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[643,657,\"0\"]],null]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[86,178,\"0\"],[93,177,\"0\"],[93,127,\"0\"],[108,127,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[18,127,\"0\"],[18,24,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[74,82,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,66,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[194,271,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[440,453,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[3205,3266,\"0\"],[3205,3219,\"0\"],[3225,3264,\"0\"],[3225,3243,\"0\"],[3247,3264,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[2,null,[[0,2]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[536,549,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[641,654,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[57,111,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[96,99,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[2,null,[[0,2]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[222,240,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[149,152,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,33,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[13,null,[[0,13]]]\n[13,null,[[0,13]]]\n\n[13,null,[[0,13]]]\n[13,null,[[0,13]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,47,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[14,90,\"0\"],[14,67,\"0\"],[34,67,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[53,82,\"0\"],[71,81,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[12,123,\"0\"],[23,37,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[56,113,\"0\"],[56,73,\"0\"],[77,113,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[15,54,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[66,101,\"0\"],[74,101,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,27,\"0\"]],null]]]\n\n\n\n[\"0/6\",\"b\",[[0,\"0/6\",null,[[11,137,\"0\"],[11,42,\"0\"],[47,137,\"0\"],[47,88,\"0\"],[96,136,\"0\"],[109,136,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[201,286,\"0\"],[201,239,\"0\"],[201,221,\"0\"],[245,285,\"0\"],[245,266,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[103,118,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[174,196,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[323,327,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[54,117,\"0\"],[54,96,\"0\"],[54,70,\"0\"],[74,96,\"0\"],[100,117,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[24,284,\"0\"],[24,63,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,25,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[26,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[26,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[12,24,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[68,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[12,24,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[68,80,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[60,null,[[0,60]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[113,119,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,19,\"1/2\"]],null]]]\n\n\n[10,null,[[0,10]]]\n[10,null,[[0,10]]]\n[10,null,[[0,10]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[245,250,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[34,76,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[9,14,\"1\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[59,98,\"1/2\"]],null]]]\n\n[13,null,[[0,13]]]\n\n\n[13,null,[[0,13]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[286,321,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[13,null,[[0,13]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[10,120,\"1\"],[55,119,\"1/2\"]],null]]]\n\n[14,null,[[0,14]]]\n[14,null,[[0,14]]]\n\n\n\n\n[14,null,[[0,14]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[872,880,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1282,1291,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[120,null,[[0,120]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[62,69,\"1\"]],null]]]\n[143,null,[[0,143]]]\n\n[120,null,[[0,120]]]\n\n\n[1,null,[[0,1]]]\n[17,null,[[0,17]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[156,172,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,32,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[10,49,\"0\"],[10,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[63,null,[[0,63]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[184,187,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,33,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[11,50,\"0\"],[11,30,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,33,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[11,50,\"1/2\"],[11,30,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[20,61,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[174,239,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[253,297,\"1/2\"],[261,297,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[15,34,\"0\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[357,453,\"1\"]],null]]]\n\n\n\n[58,null,[[0,58]]]\n\n\n[26,null,[[0,26]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[199,245,\"1\"]],null]]]\n[5,null,[[0,5]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,28,\"1/2\"]],null]]]\n\n[121,null,[[0,121]]]\n[\"1\",\"b\",[[0,\"1\",null,[[40,43,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[10,44,\"1\"]],null]]]\n[13,null,[[0,13]]]\n\n\n[108,null,[[0,108]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[47,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[102,109,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,29,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,48,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[41,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[98,134,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[807,814,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[904,914,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[182,185,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1228,1232,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,32,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,20,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[148,151,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,34,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[517,520,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,103,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[145,155,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[11,32,\"1\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,45,\"0\"]],null]]]\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[16,209,\"1/2\"],[16,75,\"1\"],[38,73,\"1\"],[45,73,\"1\"]],null]]]\n\n\n\n\n[108,null,[[0,108]]]\n[108,null,[[0,108]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[898,905,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,51,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[8,null,[[0,8]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[141,159,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[94,101,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,42,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[8,null,[[0,8]]]\n\n\n\n[8,null,[[0,8]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,36,\"1/2\"]],null]]]\n\n\n[34,null,[[0,34]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[463,472,\"1\"]],null]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[24,68,\"1/2\"],[24,44,\"1/2\"],[48,68,\"1/2\"]],null]]]\n\n\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[746,784,\"1\"],[746,755,\"1\"],[760,784,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,27,\"1/2\"]],null]]]\n[121,null,[[0,121]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[23,66,\"1/2\"],[35,66,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[148,179,\"1\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[12,52,\"1\"],[27,46,\"1/2\"]],null]]]\n[108,null,[[0,108]]]\n[108,null,[[0,108]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[308,317,\"1\"]],null]]]\n[108,null,[[0,108]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[455,460,\"1/2\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[70,94,\"0\"],[77,93,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,204,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[34,null,[[0,34]]]\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2136,2163,\"1/2\"],[2145,2163,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[126,130,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[81,97,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[15,18,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,45,\"0\"],[15,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[659,752,\"0\"],[672,752,\"0\"],[681,752,\"0\"],[681,702,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[3038,3047,\"1\"]],null]]]\n[21,null,[[0,21]]]\n[21,null,[[0,21]]]\n\n\n[34,null,[[0,34]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3309,3314,\"1/2\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n[23,null,[[0,23]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[104,111,\"1\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[95,101,\"1\"]],null]]]\n[7,null,[[0,7]]]\n\n[8,null,[[0,8]]]\n[\"1\",\"b\",[[0,\"1\",null,[[173,176,\"1\"]],null]]]\n[8,null,[[0,8]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[52,69,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[8,null,[[0,8]]]\n\n\n\n\n[8,null,[[0,8]]]\n\n\n[8,null,[[0,8]]]\n\n[23,null,[[0,23]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[21,null,[[0,21]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[180,193,\"1/2\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[335,353,\"1\"]],null]]]\n\n\n[5,null,[[0,5]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[114,251,\"1/2\"],[114,131,\"1/2\"],[136,251,\"0\"],[136,168,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[17,97,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[112,120,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[236,244,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[664,706,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[738,741,\"1\"]],null]]]\n[5,null,[[0,5]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[67,103,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[133,158,\"1/2\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[76,235,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[112,147,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[159,168,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1921,1959,\"1/2\"]],null]]]\n\n\n\n\n\n\n[21,null,[[0,21]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[62832,62888,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[63535,63651,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[45,87,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,14,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[36,65,\"0\"]],null]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[63908,64084,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[81,125,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[8,57,\"0\"],[18,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[64331,64403,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,44,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[19,25,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,32,\"0\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[66,109,\"0\"],[90,109,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,27,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[18,null,[[0,18]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[30,31,\"1\"]],null]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[8,38,\"1/2\"],[8,24,\"1/2\"],[28,38,\"1/2\"]],null]]]\n[74,null,[[0,74]]]\n\n\n\n[18,null,[[0,18]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[7,37,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[11,52,\"1\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[185,203,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,41,\"0\"],[13,31,\"0\"]],null]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[371,400,\"1\"]],null]]]\n[3,null,[[0,3]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[13,59,\"1/2\"],[13,49,\"1/2\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[606,633,\"1/2\"]],null]]]\n[5,null,[[0,5]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[12,81,\"0\"],[12,58,\"0\"],[12,48,\"0\"],[62,81,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[16,null,[[0,16]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[32,35,\"1\"]],null]]]\n[3,null,[[0,3]]]\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[81,122,\"1/2\"],[81,99,\"1/2\"],[103,122,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,51,\"0\"]],null]]]\n\n\n[16,null,[[0,16]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,29,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[65,null,[[0,65]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[61,89,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[18,25,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,45,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[65,null,[[0,65]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[336,343,\"1\"]],null]]]\n[95,null,[[0,95]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[411,418,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[40,54,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[40,54,\"1/2\"]],null]]]\n\n\n[2,null,[[0,2]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[346,null,[[0,346]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[80,89,\"1\"]],null]]]\n[153,null,[[0,153]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[224,242,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[277,305,\"1\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[9,105,\"1\"],[9,30,\"1\"]],null]]]\n\n\n\n\n[8,null,[[0,8]]]\n\n\n[49,null,[[0,49]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[367,402,\"1\"],[378,400,\"1/2\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[46,56,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[16,41,\"1/2\"]],null]]]\n\n\n\n[8,null,[[0,8]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[402,466,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[80,114,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[8,null,[[0,8]]]\n\n\n\n[17,null,[[0,17]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[63,67,\"1\"]],null]]]\n\n\n[7,null,[[0,7]]]\n[7,null,[[0,7]]]\n\n[17,null,[[0,17]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1575,1601,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,29,\"1/2\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[2170,2187,\"1\"]],null]]]\n[105,null,[[0,105]]]\n[105,null,[[0,105]]]\n[105,null,[[0,105]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[2327,2356,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,35,\"1/2\"]],null]]]\n\n\n\n\n\n\n[30,null,[[0,30]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[26,31,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[210,242,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[12,17,\"1/2\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[28,50,\"0\"],[35,50,\"0\"]],null]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[51,244,\"0\"],[51,68,\"0\"],[74,81,\"0\"]],null]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[686,704,\"1/2\"]],null]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[50,55,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,48,\"0\"],[13,46,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[181,205,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[5,null,[[0,5]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[20,36,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[12,52,\"0\"],[34,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[16,null,[[0,16]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[42,74,\"1/2\"],[52,74,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[22,43,\"0\"]],null]]]\n\n\n[18,null,[[0,18]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,69,\"0\"]],null]]]\n\n\n[12,null,[[0,12]]]\n[25,null,[[0,25]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[56,84,\"1/2\"]],null]]]\n[25,null,[[0,25]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[122,162,\"1\"],[134,162,\"1/2\"]],null]]]\n[11,null,[[0,11]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[228,243,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[34,59,\"1/2\"]],null]]]\n[3,null,[[0,3]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[166,191,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[25,null,[[0,25]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[30,null,[[0,30]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[33,69,\"1/2\"]],null]]]\n[50,null,[[0,50]]]\n\n[30,null,[[0,30]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[115,142,\"1/2\"]],null]]]\n\n\n\n[30,null,[[0,30]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[10,null,[[0,10]]]\n\n\n\n[10,null,[[0,10]]]\n[\"1\",\"b\",[[0,\"1\",null,[[205,217,\"1\"]],null]]]\n[10,null,[[0,10]]]\n[\"1\",\"b\",[[0,\"1\",null,[[41,68,\"1\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[65,157,\"1/2\"],[65,128,\"1/2\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[665,680,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[10,null,[[0,10]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[784,790,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[74,80,\"1/2\"]],null]]]\n[10,null,[[0,10]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,14,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[83,100,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[37,null,[[0,37]]]\n[37,null,[[0,37]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,37,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,49,\"1/2\"]],null]]]\n[57,null,[[0,57]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[152,204,\"0\"],[159,204,\"0\"],[173,204,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[567,584,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[37,null,[[0,37]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[32,81,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[75,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[12,14,\"0\"]],null]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,14,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[5,null,[[0,5]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[35,53,\"1/2\"]],null]]]\n[5,null,[[0,5]]]\n\n[5,null,[[0,5]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,17,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,23,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[48,58,\"1\"]],null]]]\n[10,null,[[0,10]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[119,126,\"1/2\"]],null]]]\n[10,null,[[0,10]]]\n\n\n[10,null,[[0,10]]]\n\n\n\n\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[30,null,[[0,30]]]\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[79,135,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[233,286,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[5,null,[[0,5]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[2,null,[[0,2]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[98,157,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,57,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[72,121,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[12,null,[[0,12]]]\n[4,null,[[0,4]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[190,206,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[400,431,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[714,1000,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1056,1081,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[83,90,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[124,144,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[309,340,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[18,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[316,337,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[130,149,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3402,3407,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[153,181,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[13,24,\"1\"]],null]]]\n\n\n\n\n\n[5,null,[[0,5]]]\n[15,null,[[0,15]]]\n\n\n\n\n\n[15,null,[[0,15]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[217,228,\"1\"]],null]]]\n[10,null,[[0,10]]]\n\n\n\n\n[5,null,[[0,5]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[15,null,[[0,15]]]\n\n\n\n\n[15,null,[[0,15]]]\n[\"0\",\"b\",[[0,\"0\",null,[[38,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[15,null,[[0,15]]]\n\n\n\n[5,null,[[0,5]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[8147,8151,\"1\"]],null]]]\n[4,null,[[0,4]]]\n\n\n\n[5,null,[[0,5]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[61,81,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[127,143,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[708,722,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[157,259,\"0\"],[157,185,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1108,1111,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[108,188,\"0\"],[126,188,\"0\"],[149,188,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[2,null,[[0,2]]]\n[0,null,[[0,0]]]\n\n\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,12,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[70,121,\"1/2\"],[70,83,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[282,321,\"1/2\"],[282,295,\"1/2\"],[299,321,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[105604,105718,\"1/2\"],[105604,105638,\"1/2\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[218,null,[[0,218]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[85,116,\"1\"]],null]]]\n[35,null,[[0,35]]]\n[35,null,[[0,35]]]\n[55,null,[[0,55]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[258,277,\"1\"]],null]]]\n[127,null,[[0,127]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[29,56,\"1/2\"]],null]]]\n[127,null,[[0,127]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[88,92,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[59,62,\"1/2\"]],null]]]\n[14,null,[[0,14]]]\n[14,null,[[0,14]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[394,396,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,19,\"1\"]],null]]]\n[187,null,[[0,187]]]\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[840,849,\"1\"]],null]]]\n[162,null,[[0,162]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[889,893,\"1\"]],null]]]\n[11,null,[[0,11]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[937,940,\"1\"]],null]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[122,190,\"1/2\"],[122,142,\"1\"],[146,190,\"1/2\"],[146,166,\"1\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[188,null,[[0,188]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[122,128,\"1\"]],null]]]\n[53,null,[[0,53]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[165,184,\"1/2\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[105,119,\"1\"]],null]]]\n[52,null,[[0,52]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[188,null,[[0,188]]]\n\n\n[23,null,[[0,23]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[133,157,\"1/2\"]],null]]]\n[23,null,[[0,23]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[23,null,[[0,23]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[10,27,\"1\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[332,418,\"1\"],[332,349,\"1/2\"]],null]]]\n\n\n[6,null,[[0,6]]]\n\n\n\n\n\n\n\n\n[6,null,[[0,6]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[825,844,\"1/2\"]],null]]]\n\n\n[26,null,[[0,26]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[52,71,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[99,116,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[64,85,\"1/2\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[26,null,[[0,26]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[156,168,\"1/2\"]],null]]]\n\n\n\n\n[26,null,[[0,26]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[497,500,\"1\"]],null]]]\n[52,null,[[0,52]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[720,770,\"1/2\"],[720,737,\"1/2\"]],null]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[242,256,\"1/2\"]],null]]]\n[26,null,[[0,26]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[70,null,[[0,70]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[47,100,\"1\"],[47,66,\"1\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7,22,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[52,68,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[99,114,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[205,224,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[255,274,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[114,155,\"1/2\"],[114,132,\"1/2\"],[136,155,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[112,136,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n[2,null,[[0,2]]]\n\n[2,null,[[0,2]]]\n\n\n[4,null,[[0,4]]]\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,62,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[25,null,[[0,25]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[108,125,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,20,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[44,104,\"1/2\"],[44,63,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[37,40,\"1\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[91,101,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n[\"1\",\"b\",[[0,\"1\",null,[[44,73,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[725,748,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[24,null,[[0,24]]]\n[24,null,[[0,24]]]\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[352,379,\"1\"],[360,379,\"1\"]],null]]]\n\n\n\n[10,null,[[0,10]]]\n[\"1\",\"b\",[[0,\"1\",null,[[140,158,\"1\"]],null]]]\n[8,null,[[0,8]]]\n\n\n\n\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[305,323,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[2,null,[[0,2]]]\n\n\n\n[14,null,[[0,14]]]\n\n\n[14,null,[[0,14]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[22,26,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,25,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[158,162,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,42,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[342,353,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,22,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[307,326,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[384,386,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[104,117,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[664,685,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[43,230,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[27,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[113,138,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,218,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[137,181,\"0\"],[137,150,\"0\"],[154,181,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[22,34,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,22,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,242,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[296,308,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[321,324,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[70,86,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[131,141,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[182,527,\"0\"],[182,211,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[219,229,\"1/2\"]],null]]]\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[504,548,\"0\"],[521,548,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[51,77,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[150,166,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[246,259,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[170,183,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1378,1388,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[19,50,\"0\"],[37,50,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[114,129,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[223,228,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[118,125,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[303,321,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[152,166,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[36,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[109,113,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[225,243,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[23,62,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[73,89,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[390,445,\"0\"],[390,415,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1067,1081,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,31,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[8,null,[[0,8]]]\n\n\n[1,null,[[0,1]]]\n\n[4,null,[[0,4]]]\n\n\n[4,null,[[0,4]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,34,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,16,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,35,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[100,null,[[0,100]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[130,181,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[39,49,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[253,300,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[35,45,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[385,444,\"1\"],[385,402,\"1\"],[406,444,\"1/2\"]],null]]]\n[12,null,[[0,12]]]\n\n\n[88,null,[[0,88]]]\n\n\n\n\n[1,null,[[0,1]]]\n[33,null,[[0,33]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[43,48,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[27,null,[[0,27]]]\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[141,146,\"1\"]],null]]]\n[27,null,[[0,27]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[30,48,\"1/2\"],[38,48,\"0\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[35,67,\"1\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[137,150,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[300,319,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,72,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[132,167,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[201,235,\"1/2\"]],null]]]\n[12,null,[[0,12]]]\n\n\n[12,null,[[0,12]]]\n[\"1\",\"b\",[[0,\"1\",null,[[396,399,\"1\"]],null]]]\n[6,null,[[0,6]]]\n\n\n\n\n[12,null,[[0,12]]]\n\n\n[12,null,[[0,12]]]\n\n\n[12,null,[[0,12]]]\n\n\n\n\n\n[27,null,[[0,27]]]\n\n[27,null,[[0,27]]]\n[\"1\",\"b\",[[0,\"1\",null,[[1463,1484,\"1\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[74,125,\"1/2\"],[87,125,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,16,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[45,null,[[0,45]]]\n\n\n[45,null,[[0,45]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[388,396,\"1\"]],null]]]\n[16,null,[[0,16]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[463,470,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,43,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[10,45,\"0\"],[28,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[27,null,[[0,27]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[53,null,[[0,53]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[77,null,[[0,77]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[68,93,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[47,75,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[41,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[376,402,\"1\"],[376,388,\"1\"],[392,402,\"1/2\"]],null]]]\n\n\n[37,null,[[0,37]]]\n[37,null,[[0,37]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[489,499,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[8,36,\"1\"]],null]]]\n\n\n[19,null,[[0,19]]]\n[19,null,[[0,19]]]\n\n\n\n[21,null,[[0,21]]]\n[21,null,[[0,21]]]\n[21,null,[[0,21]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[719,731,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[770,773,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[802,811,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[242,288,\"0\"]],null]]]\n\n[77,null,[[0,77]]]\n[108,null,[[0,108]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[108,null,[[0,108]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[241,250,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[350,365,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[635,643,\"1\"]],null]]]\n[19,null,[[0,19]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[799,812,\"1\"]],null]]]\n[22,null,[[0,22]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[941,970,\"1\"]],null]]]\n[47,null,[[0,47]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[1020,1054,\"1\"]],null]]]\n[47,null,[[0,47]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[134,200,\"1/2\"],[134,163,\"1/2\"],[167,200,\"1/2\"]],null]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[1449,1495,\"1/2\"],[1449,1460,\"1/2\"]],null]]]\n[108,null,[[0,108]]]\n[\"1\",\"b\",[[0,\"1\",null,[[1527,1530,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,49,\"1/2\"]],null]]]\n[125,null,[[0,125]]]\n[\"1\",\"b\",[[0,\"1\",null,[[100,114,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[214,219,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[342,376,\"1/2\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[473,533,\"1/2\"],[473,481,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[596,630,\"1/2\"]],null]]]\n\n\n[125,null,[[0,125]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1040,1070,\"1\"]],null]]]\n[99,null,[[0,99]]]\n[99,null,[[0,99]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[156,246,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[12,33,\"1/2\"]],null]]]\n[93,null,[[0,93]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1439,1450,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[52,75,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1658,1666,\"1\"]],null]]]\n[24,null,[[0,24]]]\n\n[101,null,[[0,101]]]\n\n\n\n[125,null,[[0,125]]]\n\n\n\n\n\n\n\n[58,null,[[0,58]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[174,216,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[313,359,\"1/2\"],[313,324,\"1/2\"]],null]]]\n[56,null,[[0,56]]]\n[\"1\",\"b\",[[0,\"1\",null,[[391,394,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,49,\"1/2\"]],null]]]\n[68,null,[[0,68]]]\n[\"1\",\"b\",[[0,\"1\",null,[[100,114,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[220,225,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[376,410,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[424,484,\"1/2\"],[424,432,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[500,520,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[531,619,\"1\"]],null]]]\n\n\n\n[68,null,[[0,68]]]\n[\"1\",\"b\",[[0,\"1\",null,[[698,701,\"1\"]],null]]]\n[69,null,[[0,69]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[44,306,\"1\"],[44,90,\"1/2\"],[59,90,\"1/2\"]],null]]]\n\n\n\n\n[60,null,[[0,60]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[42,60,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[114,128,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1395,1424,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,104,\"1/2\"]],null]]]\n\n\n[55,null,[[0,55]]]\n\n\n[55,null,[[0,55]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[2099,2129,\"1\"]],null]]]\n[26,null,[[0,26]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[448,468,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[632,704,\"1/2\"],[655,704,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[912,976,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[64,147,\"1\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[175,240,\"1/2\"]],null]]]\n\n[3,null,[[0,3]]]\n[3,null,[[0,3]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[-1,83,\"1/2\"],[-1,47,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[213,230,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1811,1831,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[185,649,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,25,\"0\"],[33,55,\"0\"]],null]]]\n\n\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[132,206,\"0\"],[132,150,\"0\"],[157,204,\"0\"],[157,179,\"0\"],[183,204,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[74,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[151,188,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[34,56,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[374,397,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[569,591,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1680,1711,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[30,null,[[0,30]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,41,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[10,52,\"0\"],[10,38,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[10,51,\"0\"],[10,38,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[10,84,\"0\"],[10,34,\"0\"],[38,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[105,154,\"0\"],[105,131,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[50,74,\"1/2\"]],null]]]\n[55,null,[[0,55]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[58,91,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[164,179,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[-1,130,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[521,562,\"1/2\"],[521,560,\"1/2\"],[535,560,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[7,null,[[0,7]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[991,996,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[1113,1149,\"1/2\"],[1113,1133,\"1\"]],null]]]\n\n\n[8,null,[[0,8]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[80,102,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,104,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[91,113,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[68,119,\"0\"],[68,87,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,33,\"0\"]],null]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[267,337,\"0\"],[283,337,\"0\"],[283,303,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,19,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[52,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[95,105,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[259,332,\"0\"],[273,330,\"0\"],[273,291,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[77,null,[[0,77]]]\n\n\n[0,null,[[0,0]]]\n\n\n[42,null,[[0,42]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[31,79,\"1/2\"],[40,79,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[381,406,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[554,606,\"1\"],[554,572,\"1/2\"],[576,606,\"1\"]],null]]]\n\n\n[32,null,[[0,32]]]\n[32,null,[[0,32]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[689,701,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[42,null,[[0,42]]]\n[58,null,[[0,58]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7,124,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,60,\"0\"]],null]]]\n\n\n[14,null,[[0,14]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[58,63,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[6,null,[[0,6]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[73,92,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[166,189,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[119,125,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[45,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[568,591,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[130,185,\"0\"],[130,150,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[326,373,\"0\"],[326,346,\"0\"],[350,373,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[20,null,[[0,20]]]\n\n[20,null,[[0,20]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[311,419,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[47,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[655,656,\"1/2\"]],null]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[135,167,\"1\"]],null]]]\n[14,null,[[0,14]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[295,311,\"1/2\"]],null]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[279,284,\"1\"]],null]]]\n[20,null,[[0,20]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[32,46,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,132,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[20,null,[[0,20]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[727,737,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[204,218,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[37,158,\"0\"],[55,70,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,21,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[87,102,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[20,null,[[0,20]]]\n\n\n[1,null,[[0,1]]]\n[10,null,[[0,10]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[94,121,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,40,\"1/2\"],[21,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[96,111,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,64,\"1/2\"]],null]]]\n[17,null,[[0,17]]]\n\n[17,null,[[0,17]]]\n\n\n\n[10,null,[[0,10]]]\n\n\n[1,null,[[0,1]]]\n\n[12,null,[[0,12]]]\n\n\n\n[6,null,[[0,6]]]\n\n\n\n\n[\"1/5\",\"b\",[[0,\"1/5\",null,[[167,273,\"1/2\"],[196,273,\"0\"],[196,239,\"0\"],[196,215,\"0\"],[219,239,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[202,207,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[616,629,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,26,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[19,48,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[69,100,\"1/2\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[144,149,\"1\"]],null]]]\n[6,null,[[0,6]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[6,null,[[0,6]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1036,1059,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,68,\"0\"]],null]]]\n\n\n\n[6,null,[[0,6]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[83,116,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,27,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,45,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,22,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[512,536,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[9,null,[[0,9]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,30,\"0\"]],null]]]\n\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[11,77,\"0\"],[11,30,\"0\"],[34,77,\"0\"],[34,54,\"0\"],[58,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[14,null,[[0,14]]]\n[\"2/5\",\"b\",[[0,\"2/5\",null,[[9,75,\"1/2\"],[9,28,\"1/2\"],[32,75,\"0\"],[32,52,\"0\"],[56,75,\"0\"]],null]]]\n[14,null,[[0,14]]]\n[14,null,[[0,14]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[9,75,\"0\"],[9,28,\"0\"],[32,75,\"0\"],[32,52,\"0\"],[56,75,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[6,null,[[0,6]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,24,\"1/2\"]],null]]]\n[6,null,[[0,6]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,24,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[36,62,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,28,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[19,40,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[88,113,\"1/2\"]],null]]]\n\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15,30,\"1/2\"]],null]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[73,115,\"1/2\"],[73,92,\"1/2\"],[96,115,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[220,355,\"0\"],[220,245,\"0\"],[249,355,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,19,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,29,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[98,117,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[783,787,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[43,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[50,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[5,null,[[0,5]]]\n[7,null,[[0,7]]]\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[109,118,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[12,22,\"1/2\"]],null]]]\n[7,null,[[0,7]]]\n\n\n\n[7,null,[[0,7]]]\n\n\n[7,null,[[0,7]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[72,null,[[0,72]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[240,261,\"1/2\"]],null]]]\n[72,null,[[0,72]]]\n\n\n[72,null,[[0,72]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[67,71,\"1\"]],null]]]\n[148,null,[[0,148]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[387,408,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[492,521,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[548,572,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[747,777,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1393,1403,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1638,1680,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[148,null,[[0,148]]]\n[148,null,[[0,148]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[148,null,[[0,148]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[73,102,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[200,208,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,62,\"1/2\"]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[72,130,\"1/2\"],[72,82,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[434,510,\"1/2\"],[465,510,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1118,1135,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[3,null,[[0,3]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,22,\"1/2\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[60,78,\"1/2\"]],null]]]\n[18,null,[[0,18]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[232,235,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[45,63,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[25,null,[[0,25]]]\n[\"1\",\"b\",[[0,\"1\",null,[[125,132,\"1\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[44,null,[[0,44]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[92,140,\"1\"],[104,115,\"1\"]],null]]]\n[20,null,[[0,20]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[7,23,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[273,278,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[70,88,\"1\"]],null]]]\n[16,null,[[0,16]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[173,184,\"1/2\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[77,96,\"1\"]],null]]]\n[32,null,[[0,32]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[257,275,\"1\"]],null]]]\n[32,null,[[0,32]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[208,227,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[44,null,[[0,44]]]\n\n\n[1,null,[[0,1]]]\n\n\n[24,null,[[0,24]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[343,371,\"1/2\"]],null]]]\n[24,null,[[0,24]]]\n\n\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[648,671,\"1/2\"],[648,656,\"1/2\"],[660,671,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[107,129,\"0\"],[107,114,\"0\"],[118,129,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[231,252,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[442,521,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[579,601,\"0\"]],null]]]\n\n\n\n[24,null,[[0,24]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,18,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[112,122,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"5/5\",\"b\",[[0,\"5/5\",null,[[57,123,\"1/2\"],[66,123,\"1/2\"],[66,85,\"1/2\"],[89,123,\"1/2\"],[89,108,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[35,null,[[0,35]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[295,401,\"1/2\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[479,533,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[579,598,\"1/2\"]],null]]]\n[35,null,[[0,35]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[89,153,\"1/2\"],[89,106,\"1\"],[112,153,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[319,351,\"1/2\"],[319,332,\"1/2\"],[336,351,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[464,481,\"1\"]],null]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[14,77,\"1\"],[14,29,\"1/2\"],[35,63,\"1\"]],null]]]\n\n\n\n[\"1/4\",\"b\",[[0,\"1/4\",null,[[634,712,\"1/2\"],[662,712,\"0\"],[662,674,\"0\"],[678,712,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[847,941,\"1/2\"],[857,941,\"1/2\"]],null]]]\n\n\n[35,null,[[0,35]]]\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[77,163,\"0\"],[86,163,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[172,null,[[0,172]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[128,234,\"1/2\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[302,356,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[428,451,\"1\"]],null]]]\n[28,null,[[0,28]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[577,594,\"1\"]],null]]]\n[144,null,[[0,144]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[690,736,\"1/2\"],[690,706,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[868,889,\"1\"],[868,880,\"1/2\"]],null]]]\n[104,null,[[0,104]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[39,72,\"1/2\"],[39,53,\"1\"],[75,83,\"1\"]],null]]]\n\n[68,null,[[0,68]]]\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,17,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[-1,410,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[25,null,[[0,25]]]\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[290,378,\"1/2\"],[304,378,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[25,null,[[0,25]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,16,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,204,\"1/2\"]],null]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[3,null,[[0,3]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[166,171,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[392,415,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n\n\n[1,null,[[0,1]]]\n\n[11,null,[[0,11]]]\n[15,null,[[0,15]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[55,77,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[69,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[276,295,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[57,89,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[187,235,\"0\"],[197,221,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[55,73,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[63,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[336,353,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[426,444,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[163,270,\"0\"],[163,188,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[719,747,\"0\"],[730,747,\"0\"]],null]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[151,179,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[243,375,\"0\"],[243,268,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,14,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[3,null,[[0,3]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[194,206,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[225,230,\"1\"]],null]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[359,371,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[3,null,[[0,3]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[154,168,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,70,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[336,347,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[52,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,25,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[32,66,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[38,66,\"0\"],[48,66,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[76,114,\"0\"],[88,94,\"0\"]],null]]]\n\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[115,177,\"0\"],[115,131,\"0\"],[135,177,\"0\"],[147,177,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[423,481,\"0\"],[423,451,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1554,1596,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1688,1716,\"0\"],[1697,1716,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[346,374,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[383,405,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[517,535,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,23,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[118,154,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[897,973,\"0\"],[897,917,\"0\"],[921,973,\"0\"],[921,947,\"0\"],[951,973,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,47,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[93,103,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[88,110,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[55,73,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[3127,3140,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,75,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,17,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,30,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[269,275,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[367,373,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[123,130,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[910,916,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[962,983,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[48,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[104,127,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[208,222,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[326,352,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,16,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,87,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[422,436,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[578,599,\"0\"],[578,589,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[185,192,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[253,267,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[402,409,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[2279,2293,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[97,103,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[2655,2696,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,214,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[59,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,15,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[12,46,\"0\"],[21,46,\"0\"]],null]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[323,355,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,40,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[665,704,\"0\"],[665,682,\"0\"],[686,704,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[78,87,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[210,249,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[464,495,\"0\"],[473,495,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[120,144,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[228,256,\"0\"],[242,256,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,28,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[143,148,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,45,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[11,68,\"0\"],[28,68,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[438,445,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[10,98,\"0\"],[10,39,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[852,871,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,22,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,23,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[333,352,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[484,491,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[10,73,\"0\"],[10,39,\"0\"],[43,73,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[741,755,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,49,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[3,null,[[0,3]]]\n[3,null,[[0,3]]]\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[10,53,\"1/2\"],[10,23,\"1/2\"],[27,53,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[6,null,[[0,6]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,99,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[83,116,\"0\"],[95,116,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[271,285,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[37,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,15,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,41,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,18,\"0\"],[21,53,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[70,82,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[289,307,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[622,641,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[50,70,\"1\"]],null]]]\n\n\n\n[8,null,[[0,8]]]\n[16,null,[[0,16]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[170,null,[[0,170]]]\n\n\n\n[\"5/5\",\"b\",[[0,\"5/5\",null,[[120,161,\"1/2\"],[120,131,\"1/2\"],[135,161,\"1/2\"],[135,146,\"1/2\"],[150,161,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[245,285,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[450,489,\"1/2\"],[450,461,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,120,\"1\"]],null]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[627,646,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,23,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[91,176,\"1/2\"],[100,176,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[152,null,[[0,152]]]\n[152,null,[[0,152]]]\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[924,993,\"1/2\"],[933,993,\"1/2\"],[953,993,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[18,null,[[0,18]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1136,1147,\"1\"]],null]]]\n\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[10,91,\"0\"],[33,91,\"0\"],[33,50,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[79,82,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[16,null,[[0,16]]]\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[221,253,\"1/2\"],[234,253,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[14,39,\"1\"]],null]]]\n[16,null,[[0,16]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,23,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15,53,\"1/2\"]],null]]]\n\n[16,null,[[0,16]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[67,73,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[173,208,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[50,70,\"1/2\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,47,\"0\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"5/5\",\"b\",[[0,\"5/5\",null,[[120,161,\"1/2\"],[120,131,\"1/2\"],[135,161,\"1/2\"],[135,146,\"1/2\"],[150,161,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[189,228,\"1/2\"],[189,200,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[44,74,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[357,376,\"1/2\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,94,\"0\"],[18,94,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"1/3\",\"b\",[[0,\"1/3\",null,[[549,618,\"1/2\"],[558,618,\"0\"],[578,618,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[403,411,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[201023,201043,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[89,116,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[89,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[37,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n[10,null,[[0,10]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[16,50,\"1/2\"]],null]]]\n[179,null,[[0,179]]]\n\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[9,64,\"1\"],[9,58,\"1\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[41,null,[[0,41]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[77,103,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[237,271,\"1/2\"],[237,262,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,48,\"1/2\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[64,84,\"1\"]],null]]]\n[46,null,[[0,46]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[44,111,\"1/2\"],[44,63,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[123,126,\"1/2\"]],null]]]\n[46,null,[[0,46]]]\n[\"1\",\"b\",[[0,\"1\",null,[[28,52,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[12,48,\"1/2\"]],null]]]\n[46,null,[[0,46]]]\n\n\n\n\n[46,null,[[0,46]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[267,290,\"1/2\"]],null]]]\n[46,null,[[0,46]]]\n\n\n\n\n\n[41,null,[[0,41]]]\n\n\n\n[30,null,[[0,30]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[77,103,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[240,257,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[310,344,\"1/2\"],[310,335,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[14,48,\"1/2\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[64,84,\"1\"]],null]]]\n[42,null,[[0,42]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[118,185,\"1/2\"],[118,137,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[197,200,\"1/2\"]],null]]]\n[42,null,[[0,42]]]\n[\"1\",\"b\",[[0,\"1\",null,[[28,52,\"1\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[48,85,\"1\"]],null]]]\n[34,null,[[0,34]]]\n\n\n\n\n[42,null,[[0,42]]]\n[\"1\",\"b\",[[0,\"1\",null,[[329,352,\"1\"]],null]]]\n[23,null,[[0,23]]]\n\n\n\n\n\n[30,null,[[0,30]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[36,86,\"0\"],[36,65,\"0\"],[69,86,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,19,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,202,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[49,66,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[94,128,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[145,176,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[69,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[495,536,\"0\"],[495,514,\"0\"],[518,536,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[44,53,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[443,460,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[3,null,[[0,3]]]\n\n\n[3,null,[[0,3]]]\n[\"1\",\"b\",[[0,\"1\",null,[[83,103,\"1\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[9,114,\"1/2\"],[9,28,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[3,null,[[0,3]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[5,null,[[0,5]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[60,77,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,13,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,96,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[108,195,\"1/2\"]],null]]]\n\n\n\n[5,null,[[0,5]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[299,322,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[441,452,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[22,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,82,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[245,256,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[292,315,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[352,373,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,26,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[494,572,\"0\"]],null]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[643,721,\"0\"],[653,721,\"0\"],[676,721,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[62,73,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[214,223,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,13,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[346,353,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[134,372,\"0\"],[134,164,\"0\"],[153,164,\"0\"]],null]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[147,150,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[136,139,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,173,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[490,500,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,32,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,79,\"0\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[200,216,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,48,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[7,null,[[0,7]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[265,281,\"1/2\"]],null]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[338,380,\"1/2\"],[338,357,\"1/2\"],[361,380,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[486,535,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[563,587,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[769,807,\"1/2\"],[769,792,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[904,927,\"1/2\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1103,1115,\"1/2\"]],null]]]\n[7,null,[[0,7]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1190,1205,\"1/2\"]],null]]]\n\n\n\n\n[7,null,[[0,7]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1382,1395,\"1/2\"]],null]]]\n[7,null,[[0,7]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1520,1532,\"1/2\"]],null]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1655,1689,\"1/2\"]],null]]]\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[1698,1779,\"1/2\"],[1715,1779,\"1/2\"],[1734,1779,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[1974,2036,\"1/2\"],[1991,2036,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[18,46,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[56,94,\"1/2\"]],null]]]\n[7,null,[[0,7]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[141,144,\"1\"]],null]]]\n[28,null,[[0,28]]]\n[28,null,[[0,28]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[309,351,\"1/2\"],[319,349,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,66,\"1/2\"],[35,61,\"0\"]],null]]]\n\n\n\n\n[7,null,[[0,7]]]\n[\"1\",\"b\",[[0,\"1\",null,[[2533,2590,\"1\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[18,23,\"1\"]],null]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[109,197,\"1/2\"],[109,144,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[207,213,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[288,311,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[321,364,\"1/2\"],[331,364,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[56,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[7,null,[[0,7]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3173,3217,\"1/2\"]],null]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[12,122,\"1/2\"],[12,94,\"1/2\"]],null]]]\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[174,245,\"1/2\"],[184,245,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[110,113,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[347,350,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[7,null,[[0,7]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[8,null,[[0,8]]]\n[7,null,[[0,7]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[32,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[22,null,[[0,22]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,30,\"1/2\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[48,63,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[216002,216018,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[2,null,[[0,2]]]\n[0,null,[[0,0]]]\n\n\n[2,null,[[0,2]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15,41,\"1/2\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[98,107,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[201,214,\"1\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[15,41,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[102,111,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[17,50,\"0\"],[26,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[280,336,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[19,40,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,47,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[474,521,\"0\"],[490,521,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,118,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[428,493,\"0\"],[453,491,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[132,140,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[99,276,\"0\"],[112,276,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[45,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[92,113,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[2,null,[[0,2]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[9,47,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n[5,null,[[0,5]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[228,253,\"1/2\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[65,94,\"1\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[39,60,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,43,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[76,103,\"0\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[32,59,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[64,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[93,194,\"0\"],[93,132,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[324,340,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[669,741,\"0\"],[706,741,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,32,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[6,24,\"1/2\"],[38,59,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[228,232,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n\n\n[2,null,[[0,2]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[170,192,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,45,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,64,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[378,380,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[600,627,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[126,140,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[288,318,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1120,1133,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[227,241,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[427,434,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[143,177,\"0\"],[152,177,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[296,303,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,87,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[207,239,\"0\"],[207,219,\"0\"],[223,239,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[44,110,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[156,161,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[79,99,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[69,145,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,163,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[58,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[190,218,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[873,886,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[79,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,18,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[62,85,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[180,193,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[27,36,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[47,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[326,339,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,22,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[11,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[26,29,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,21,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[22,44,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[56,65,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[-1,28,\"0\"],[6,28,\"0\"]],null]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[3528,3580,\"0\"],[3546,3580,\"0\"],[3562,3580,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[3629,3695,\"0\"],[3629,3646,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[3795,3816,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[193,293,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[4561,4614,\"0\"],[4571,4614,\"0\"],[4588,4614,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[4830,4839,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[5021,5045,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[5092,5128,\"0\"],[5107,5128,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[5579,5592,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[150,156,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[19,42,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[377,394,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[71,94,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[6336,6442,\"0\"],[6346,6442,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[6581,6593,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[142,165,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[6907,6979,\"0\"],[6907,6956,\"0\"],[6917,6956,\"0\"],[6933,6956,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[7517,7609,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8021,8031,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[59,70,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[206,215,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[266,290,\"0\"],[277,290,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[53,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[131,140,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[226,238,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[487,500,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[546,556,\"0\"]],null]]]\n\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[612,659,\"0\"],[612,641,\"0\"],[612,625,\"0\"],[629,641,\"0\"],[645,659,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[694,703,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[958,967,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[96,108,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[71,79,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[202,210,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[413,448,\"0\"],[413,427,\"0\"],[431,448,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[525,539,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[101,122,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[38,48,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[2042,2072,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2110,2119,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[2397,2408,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,42,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[2638,2649,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[113,133,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[59,84,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[6,null,[[0,6]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,30,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,34,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[6,null,[[0,6]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[207,227,\"1/2\"]],null]]]\n[6,null,[[0,6]]]\n\n\n\n[6,null,[[0,6]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[35,57,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n\n\n[6,null,[[0,6]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,33,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,87,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[3,null,[[0,3]]]\n\n[3,null,[[0,3]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[28,38,\"1/2\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,82,\"0\"],[33,82,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[244511,244566,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[104,156,\"0\"],[120,156,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[211,228,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[371,411,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[803,857,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,20,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[128,144,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[190,206,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,189,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2494,2519,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[68,88,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,22,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[75,117,\"0\"],[10,44,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[86,94,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,18,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,20,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[61,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[58,61,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[18,38,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,18,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[18,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[373,413,\"0\"],[385,413,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[110,146,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[243,251,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[344,361,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[15,35,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[59,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[273,290,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[575,628,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[124,152,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7,31,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[58,86,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[8,null,[[0,8]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[174,182,\"1/2\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,148,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[8,null,[[0,8]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[746,764,\"1/2\"]],null]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[788,794,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[7,null,[[0,7]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[916,941,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[7,null,[[0,7]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,90,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[208,235,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[373,409,\"0\"],[383,409,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[492,507,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[91,99,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[896,1053,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[27,76,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[6,null,[[0,6]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,26,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[9,32,\"0\"],[42,81,\"0\"],[42,61,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[258,279,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[466,571,\"0\"],[466,513,\"0\"],[466,489,\"0\"],[493,513,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[700,717,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,41,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[56,85,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[929,957,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1130,1149,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1222,1242,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1320,1338,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[43,59,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,32,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[264,269,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[409,438,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[579,604,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[8,18,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[254,296,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[133,178,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[54,121,\"0\"],[70,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[188,219,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,34,\"1\"]],null]]]\n\n[2,null,[[0,2]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[42,59,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,15,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[125,128,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,17,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[106,132,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n\n[6,null,[[0,6]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[20,87,\"1\"],[42,85,\"1\"],[58,85,\"1/2\"]],null]]]\n\n\n[32,null,[[0,32]]]\n[44,null,[[0,44]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24,47,\"1/2\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[94,127,\"0\"]],null]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[320,339,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[690,709,\"1\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[60,82,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[265349,265391,\"1/2\"],[265349,265377,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[7,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[58,90,\"0\"],[66,90,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[265940,265949,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n[\"36/540\",\"b\",[[0,\"36/540\",null,[[1145,1182,\"1/2\"],[1145,1170,\"1/2\"],[1272,1285,\"0\"],[1328,1340,\"0\"],[1367,1392,\"0\"],[1377,1392,\"0\"],[1398,1514,\"0\"],[1419,1514,\"0\"],[1419,1462,\"0\"],[1419,1433,\"0\"],[1435,1462,\"0\"],[1435,1449,\"0\"],[1451,1462,\"0\"],[1497,1513,\"0\"],[1508,1513,\"0\"],[3770,3868,\"0\"],[3770,3819,\"0\"],[3869,3967,\"0\"],[3869,3918,\"0\"],[4003,4391,\"0\"],[4038,4046,\"0\"],[4199,4294,\"0\"],[4199,4248,\"0\"],[4295,4390,\"0\"],[4295,4344,\"0\"],[4437,4460,\"1/2\"],[4437,4447,\"1/2\"],[4567,4614,\"0\"],[4567,4589,\"0\"],[4621,4629,\"1/2\"],[4745,4759,\"0\"],[4790,4832,\"0\"],[4793,4832,\"0\"],[4960,4979,\"1/2\"],[4980,5033,\"1\"],[5101,5109,\"1/2\"],[5151,5169,\"1/2\"],[5171,5212,\"1/2\"],[5389,5404,\"1\"],[5766,5767,\"1\"],[5768,5790,\"1/2\"],[5855,5856,\"1\"],[6129,6132,\"1\"],[6161,6295,\"1\"],[6185,6295,\"1\"],[6185,6195,\"1/2\"],[6203,6221,\"1\"],[6222,6243,\"1\"],[6342,6371,\"1/2\"],[6398,6416,\"0\"],[6453,6454,\"0\"],[6455,6482,\"0\"],[6468,6482,\"0\"],[6533,6547,\"0\"],[6557,6558,\"0\"],[6559,6596,\"0\"],[6579,6596,\"0\"],[6616,6633,\"0\"],[6616,6621,\"0\"],[6623,6633,\"0\"],[6637,6648,\"0\"],[6857,6912,\"0\"],[6955,6956,\"0\"],[6967,6972,\"0\"],[6974,6992,\"0\"],[7244,7272,\"0\"],[5,30,\"0\"],[7444,7697,\"0\"],[7444,7452,\"0\"],[7535,7563,\"0\"],[7535,7547,\"0\"],[7583,7590,\"0\"],[7607,7620,\"0\"],[7636,7695,\"0\"],[7786,7855,\"0\"],[8405,8425,\"0\"],[8469,8487,\"0\"],[8491,8531,\"0\"],[8584,8596,\"0\"],[8609,8620,\"0\"],[8634,8664,\"0\"],[8644,8664,\"0\"],[8672,8685,\"0\"],[8711,8731,\"0\"],[8739,8763,\"0\"],[8934,8974,\"0\"],[8934,8947,\"0\"],[8993,9035,\"0\"],[8993,9007,\"0\"],[9124,9269,\"0\"],[9124,9154,\"0\"],[9156,9269,\"0\"],[9159,9269,\"0\"],[9376,9492,\"0\"],[9664,9674,\"0\"],[9681,9714,\"0\"],[9717,9722,\"0\"],[9838,9881,\"0\"],[9841,9881,\"0\"],[9946,9970,\"0\"],[10029,10067,\"0\"],[10039,10063,\"0\"],[10068,10105,\"0\"],[10079,10104,\"0\"],[10209,10289,\"0\"],[10209,10235,\"0\"],[10453,10472,\"0\"],[10483,10511,\"0\"],[10483,10501,\"0\"],[10503,10511,\"0\"],[10521,10522,\"0\"],[10532,10533,\"0\"],[10546,10547,\"0\"],[10658,10693,\"0\"],[10658,10677,\"0\"],[10694,10695,\"0\"],[10822,10888,\"0\"],[10825,10888,\"0\"],[10825,10848,\"0\"],[10890,10908,\"0\"],[10943,11003,\"0\"],[10943,10961,\"0\"],[10978,11002,\"0\"],[10986,11002,\"0\"],[11071,11072,\"0\"],[11120,11125,\"0\"],[11382,11400,\"0\"],[11459,11463,\"0\"],[11995,12135,\"0\"],[11997,12002,\"0\"],[12024,12050,\"0\"],[12147,12166,\"0\"],[12202,12286,\"0\"],[12202,12262,\"0\"],[12219,12262,\"0\"],[12384,12418,\"0\"],[12384,12402,\"0\"],[12427,12428,\"0\"],[12429,12455,\"0\"],[12429,12435,\"0\"],[12437,12455,\"0\"],[12458,12469,\"0\"],[12474,12479,\"0\"],[12480,12516,\"0\"],[12480,12498,\"0\"],[12552,12577,\"0\"],[12578,12611,\"0\"],[12581,12611,\"0\"],[12620,12631,\"0\"],[12620,12625,\"0\"],[12693,12708,\"0\"],[12781,12793,\"0\"],[12821,12833,\"0\"],[12867,12893,\"0\"],[12926,12940,\"0\"],[12999,13012,\"0\"],[13096,13112,\"0\"],[13401,13411,\"0\"],[13676,13703,\"0\"],[13676,13681,\"0\"],[13754,13778,\"0\"],[13812,13836,\"0\"],[13870,13928,\"0\"],[13870,13882,\"0\"],[13884,13928,\"0\"],[13884,13894,\"0\"],[13896,13928,\"0\"],[13931,13991,\"0\"],[13931,13943,\"0\"],[13945,13991,\"0\"],[13945,13955,\"0\"],[13957,13991,\"0\"],[14005,14006,\"0\"],[14044,14045,\"0\"],[14112,14121,\"0\"],[14144,14169,\"0\"],[14152,14169,\"0\"],[14172,14178,\"0\"],[14227,14228,\"0\"],[14384,14393,\"0\"],[14538,14557,\"0\"],[14589,14627,\"0\"],[14718,14729,\"0\"],[14742,14847,\"0\"],[14742,14754,\"0\"],[14759,14771,\"0\"],[14793,14805,\"0\"],[14853,14865,\"0\"],[14885,14897,\"0\"],[14951,14952,\"0\"],[14960,14961,\"0\"],[15014,15039,\"0\"],[15014,15026,\"0\"],[15040,15057,\"0\"],[15068,15101,\"0\"],[15068,15086,\"0\"],[15102,15120,\"0\"],[15130,15162,\"0\"],[15130,15148,\"0\"],[15453,15470,\"0\"],[15481,15514,\"0\"],[15481,15499,\"0\"],[15515,15533,\"0\"],[15543,15575,\"0\"],[15543,15561,\"0\"],[15666,15884,\"0\"],[15887,16328,\"0\"],[16092,16095,\"0\"],[16103,16106,\"0\"],[16133,16136,\"0\"],[16143,16146,\"0\"],[16166,16204,\"0\"],[16166,16169,\"0\"],[16171,16204,\"0\"],[16171,16179,\"0\"],[16205,16241,\"0\"],[16205,16208,\"0\"],[16210,16241,\"0\"],[16210,16218,\"0\"],[16254,16279,\"0\"],[16422,16432,\"0\"],[16541,16559,\"0\"],[16560,16569,\"0\"],[16560,16563,\"0\"],[16565,16569,\"0\"],[16623,16632,\"0\"],[16623,16626,\"0\"],[16628,16632,\"0\"],[16635,16638,\"0\"],[16662,16665,\"0\"],[16676,16679,\"0\"],[16754,16764,\"0\"],[16878,16897,\"0\"],[16898,16907,\"0\"],[16898,16901,\"0\"],[16903,16907,\"0\"],[16959,16968,\"0\"],[16959,16962,\"0\"],[16964,16968,\"0\"],[16971,16974,\"0\"],[16999,17002,\"0\"],[17012,17015,\"0\"],[17135,17145,\"0\"],[17246,17262,\"0\"],[17276,17293,\"0\"],[17310,17326,\"0\"],[17341,17358,\"0\"],[17393,17396,\"0\"],[17435,17464,\"0\"],[17435,17446,\"0\"],[17435,17438,\"0\"],[17440,17446,\"0\"],[17466,17551,\"0\"],[17466,17469,\"0\"],[17521,17550,\"0\"],[17521,17532,\"0\"],[17521,17524,\"0\"],[17526,17532,\"0\"],[17630,17640,\"0\"],[17738,17753,\"0\"],[17756,17757,\"0\"],[17772,17790,\"0\"],[17808,17823,\"0\"],[17839,17857,\"0\"],[17893,17896,\"0\"],[17935,17963,\"0\"],[17935,17946,\"0\"],[17935,17938,\"0\"],[17940,17946,\"0\"],[17965,18047,\"0\"],[17965,17968,\"0\"],[18018,18046,\"0\"],[18018,18029,\"0\"],[18018,18021,\"0\"],[18023,18029,\"0\"],[18318,18337,\"1/2\"],[18501,18548,\"1/2\"],[18869,18882,\"0\"],[18890,18897,\"0\"],[18898,18907,\"0\"],[18922,18929,\"0\"],[18948,18956,\"0\"],[18963,18968,\"0\"],[18985,18988,\"0\"],[18991,18998,\"0\"],[19111,19124,\"0\"],[19127,19142,\"0\"],[19150,19151,\"0\"],[19217,19225,\"0\"],[19227,19274,\"0\"],[19227,19247,\"0\"],[19318,19323,\"0\"],[19336,19341,\"0\"],[19344,19349,\"0\"],[20764,20806,\"1/2\"],[20938,20943,\"0\"],[20990,21032,\"0\"],[20990,21010,\"0\"],[21079,21109,\"0\"],[21079,21084,\"0\"],[21110,21122,\"0\"],[21134,21150,\"0\"],[21152,21163,\"0\"],[21225,21237,\"0\"],[21239,21253,\"0\"],[21272,21315,\"0\"],[21377,21388,\"0\"],[21393,21416,\"0\"],[21393,21404,\"0\"],[21406,21416,\"0\"],[21470,21553,\"0\"],[21476,21553,\"0\"],[21476,21509,\"0\"],[21522,21552,\"0\"],[21654,21768,\"0\"],[21660,21695,\"0\"],[21672,21695,\"0\"],[21672,21691,\"0\"],[21727,21741,\"0\"],[21744,21763,\"0\"],[21835,21856,\"0\"],[21926,21942,\"0\"],[21967,21992,\"0\"],[22077,22090,\"0\"],[22091,22194,\"0\"],[22091,22099,\"0\"],[22102,22110,\"0\"],[22119,22172,\"0\"],[22127,22138,\"0\"],[22148,22171,\"0\"],[22148,22159,\"0\"],[22230,22247,\"0\"],[22442,22449,\"0\"],[22450,22453,\"0\"],[22469,22497,\"0\"],[22469,22477,\"0\"],[22593,22613,\"0\"],[22593,22600,\"0\"],[22605,22608,\"0\"],[22614,22647,\"0\"],[22617,22647,\"0\"],[22617,22620,\"0\"],[22659,22687,\"0\"],[22659,22667,\"0\"],[22770,22790,\"0\"],[22825,22829,\"0\"],[22844,22856,\"0\"],[22904,22921,\"0\"],[23010,23044,\"0\"],[23010,23020,\"0\"],[23022,23044,\"0\"],[23022,23032,\"0\"],[23034,23044,\"0\"],[23185,23190,\"0\"],[23193,23198,\"0\"],[23214,23219,\"0\"],[23252,23257,\"0\"],[23260,23265,\"0\"],[23301,23308,\"0\"],[23342,23376,\"0\"],[23342,23352,\"0\"],[23354,23376,\"0\"],[23354,23364,\"0\"],[23366,23376,\"0\"],[23443,23448,\"0\"],[23648,23690,\"0\"],[23648,23684,\"0\"],[23651,23684,\"0\"],[23685,23690,\"0\"],[23734,23759,\"0\"],[23734,23745,\"0\"],[23747,23759,\"0\"],[23823,23835,\"0\"],[23845,23866,\"0\"],[23845,23852,\"0\"],[23885,23886,\"0\"],[23932,24236,\"1/2\"],[23982,23993,\"0\"],[23994,24004,\"0\"],[24050,24065,\"0\"],[24069,24115,\"0\"],[24069,24083,\"0\"],[24116,24132,\"0\"],[24116,24123,\"0\"],[24139,24210,\"0\"],[24139,24151,\"0\"],[24166,24209,\"0\"],[24192,24202,\"0\"],[24346,24397,\"0\"],[24346,24363,\"0\"],[24366,24396,\"0\"],[24366,24386,\"0\"],[24402,24435,\"0\"],[24406,24410,\"0\"],[24412,24435,\"0\"],[24421,24435,\"0\"],[24443,24464,\"0\"],[24481,24519,\"0\"],[24481,24506,\"0\"],[24481,24487,\"0\"],[24489,24506,\"0\"],[24509,24519,\"0\"],[24589,24609,\"0\"],[24592,24609,\"0\"],[24698,24762,\"0\"],[25333,25360,\"0\"],[25437,25463,\"0\"],[25440,25463,\"0\"],[25450,25463,\"0\"],[25479,25482,\"0\"],[25491,25538,\"0\"],[25491,25512,\"0\"],[25555,25589,\"0\"],[25555,25576,\"0\"],[25646,25711,\"0\"],[25646,25654,\"0\"],[25657,25710,\"0\"],[25664,25710,\"0\"],[25664,25699,\"0\"],[25990,26080,\"0\"],[25990,26039,\"0\"],[25990,26016,\"0\"],[25990,26004,\"0\"],[26018,26039,\"0\"],[26018,26027,\"0\"],[26084,26192,\"1/2\"],[26140,26147,\"0\"],[26302,26321,\"0\"],[26324,26334,\"0\"],[26457,26481,\"0\"],[26977,26978,\"1/2\"],[27124,27142,\"1/2\"],[27295,27326,\"0\"],[27295,27314,\"0\"],[27316,27326,\"0\"],[27327,27328,\"0\"],[27362,27363,\"0\"],[37,73,\"0\"],[87,102,\"0\"],[87,94,\"0\"],[103,137,\"0\"],[139,190,\"0\"],[139,173,\"0\"],[139,157,\"0\"],[191,220,\"0\"],[221,237,\"0\"],[240,253,\"0\"],[265,273,\"0\"],[276,294,\"0\"],[297,313,\"0\"],[361,374,\"0\"],[397,435,\"1/2\"],[401,435,\"0\"],[401,419,\"0\"],[439,478,\"0\"],[439,457,\"0\"],[479,494,\"0\"],[498,526,\"0\"],[498,516,\"0\"],[731,754,\"0\"],[766,785,\"0\"],[792,805,\"0\"],[832,845,\"0\"],[870,889,\"0\"],[891,1050,\"1/2\"],[899,1050,\"1/2\"],[915,1050,\"1/2\"],[1002,1022,\"0\"],[1051,2390,\"1/2\"],[1051,1070,\"1/2\"],[1134,1137,\"0\"],[1142,1186,\"0\"],[1142,1153,\"0\"],[1235,1238,\"0\"],[1243,1288,\"0\"],[1243,1254,\"0\"],[1318,1365,\"0\"],[1318,1330,\"0\"],[1335,1350,\"0\"],[1398,1434,\"0\"],[1778,1830,\"0\"],[1778,1806,\"0\"],[1778,1786,\"0\"],[1844,1872,\"0\"],[2066,2105,\"0\"],[2266,2385,\"0\"],[2332,2384,\"0\"],[2332,2360,\"0\"],[2332,2340,\"0\"],[2458,2477,\"1/2\"],[2566,2571,\"0\"],[2630,2646,\"0\"],[2648,2654,\"0\"],[2664,2678,\"0\"],[2680,2686,\"0\"],[2965,3015,\"0\"],[2965,2968,\"0\"],[3125,3138,\"0\"],[3203,3236,\"0\"],[3203,3215,\"0\"],[3220,3221,\"0\"],[3238,3274,\"0\"],[3238,3239,\"0\"],[3240,3250,\"0\"],[3251,3261,\"0\"],[3807,4259,\"0\"],[3906,3945,\"0\"],[4369,4400,\"0\"],[4521,4526,\"0\"],[4569,4595,\"0\"],[4569,4575,\"0\"],[4698,4723,\"0\"],[4698,4713,\"0\"],[4741,4770,\"0\"],[4771,4791,\"0\"],[4823,4845,\"0\"],[4823,4842,\"0\"],[4846,4861,\"0\"],[4893,4931,\"0\"],[4893,4908,\"0\"],[4909,4919,\"0\"],[4920,4930,\"0\"],[5042,5049,\"0\"],[5107,5129,\"0\"],[5153,5197,\"0\"],[5157,5197,\"0\"],[5157,5186,\"0\"],[5157,5167,\"0\"],[5169,5186,\"0\"],[5169,5174,\"0\"],[5176,5186,\"0\"],[5198,5235,\"0\"],[5198,5211,\"0\"],[5201,5211,\"0\"],[5236,5256,\"0\"],[5265,5277,\"0\"],[5278,5279,\"0\"],[5323,5338,\"0\"],[5341,5347,\"0\"],[33,37,\"1/2\"]],null]]]\n[\"5/406\",\"b\",[[0,\"5/406\",null,[[985,989,\"1/2\"],[1151,1176,\"0\"],[1157,1176,\"0\"],[1393,1430,\"0\"],[1393,1407,\"0\"],[1463,1464,\"0\"],[1619,1646,\"0\"],[1661,1662,\"0\"],[1681,1682,\"0\"],[1970,1971,\"0\"],[2045,2065,\"0\"],[5822,5925,\"0\"],[5863,5910,\"0\"],[5863,5885,\"0\"],[16085,16097,\"0\"],[16085,16090,\"0\"],[16092,16097,\"0\"],[16230,16256,\"0\"],[16458,16462,\"0\"],[34297,34314,\"0\"],[34418,34496,\"0\"],[34418,34433,\"0\"],[34454,34486,\"0\"],[34497,34553,\"0\"],[34698,34708,\"0\"],[34711,34721,\"0\"],[34724,34741,\"0\"],[34757,34767,\"0\"],[34775,34779,\"0\"],[34813,34833,\"0\"],[34813,34821,\"0\"],[34823,34833,\"0\"],[34849,34869,\"0\"],[34849,34857,\"0\"],[34859,34869,\"0\"],[34939,34989,\"0\"],[34947,34956,\"0\"],[34990,35067,\"0\"],[35040,35041,\"0\"],[35068,35091,\"0\"],[35104,35107,\"0\"],[35123,35124,\"0\"],[35172,35173,\"0\"],[35182,35238,\"0\"],[35206,35207,\"0\"],[35347,35370,\"0\"],[35373,35383,\"0\"],[35386,35405,\"0\"],[35389,35405,\"0\"],[35408,35425,\"0\"],[35411,35425,\"0\"],[35452,35453,\"0\"],[35485,35486,\"0\"],[35521,35522,\"0\"],[35556,35557,\"0\"],[35616,35661,\"0\"],[35616,35631,\"0\"],[35807,35817,\"0\"],[35820,35839,\"0\"],[35842,35862,\"0\"],[35842,35850,\"0\"],[35852,35862,\"0\"],[35878,35898,\"0\"],[35878,35886,\"0\"],[35888,35898,\"0\"],[35911,35919,\"0\"],[35977,36034,\"0\"],[35991,36000,\"0\"],[36044,36078,\"0\"],[36226,36245,\"0\"],[36226,36240,\"0\"],[36325,36333,\"0\"],[36393,36403,\"0\"],[36525,36528,\"0\"],[36565,36568,\"0\"],[36820,36821,\"0\"],[36836,36837,\"0\"],[36853,36854,\"0\"],[36878,36879,\"0\"],[36894,36895,\"0\"],[36911,36912,\"0\"],[36918,36933,\"0\"],[37001,37016,\"0\"],[37039,37040,\"0\"],[37063,37064,\"0\"],[37201,37211,\"0\"],[37214,37224,\"0\"],[37227,37237,\"0\"],[37279,37280,\"0\"],[37468,37505,\"0\"],[37498,37499,\"0\"],[37551,37615,\"0\"],[37573,37605,\"0\"],[37636,37726,\"0\"],[37922,37952,\"0\"],[37922,37937,\"0\"],[38022,38040,\"0\"],[38338,38350,\"0\"],[38353,38368,\"0\"],[38371,38400,\"0\"],[38470,38479,\"0\"],[38482,38519,\"0\"],[38551,38576,\"0\"],[38551,38561,\"0\"],[38670,38901,\"0\"],[38670,38691,\"0\"],[38670,38679,\"0\"],[38681,38691,\"0\"],[38695,38797,\"0\"],[38695,38712,\"0\"],[38798,38900,\"0\"],[38798,38815,\"0\"],[38903,39033,\"0\"],[38903,38928,\"0\"],[38903,38916,\"0\"],[38918,38928,\"0\"],[38931,39033,\"0\"],[38931,38948,\"0\"],[39034,39251,\"0\"],[39262,40003,\"0\"],[39262,39287,\"0\"],[39262,39275,\"0\"],[39277,39287,\"0\"],[39686,39788,\"0\"],[39686,39703,\"0\"],[39789,39891,\"0\"],[39789,39806,\"0\"],[39892,39917,\"0\"],[39970,39998,\"0\"],[40099,40140,\"0\"],[40099,40112,\"0\"],[40141,40222,\"0\"],[40162,40174,\"0\"],[40296,40369,\"0\"],[40321,40347,\"0\"],[40350,40362,\"0\"],[40454,40473,\"0\"],[40482,40511,\"0\"],[40516,40557,\"0\"],[40682,40709,\"0\"],[40825,40835,\"0\"],[40838,40848,\"0\"],[40851,40855,\"0\"],[40861,40871,\"0\"],[40874,40875,\"0\"],[40928,40982,\"0\"],[40928,40948,\"0\"],[40798,40799,\"0\"],[40928,40982,\"0\"],[130,150,\"0\"],[41164,41183,\"0\"],[41186,41200,\"0\"],[41203,41213,\"0\"],[41251,41271,\"0\"],[41251,41259,\"0\"],[41261,41271,\"0\"],[41287,41307,\"0\"],[41287,41295,\"0\"],[41297,41307,\"0\"],[41383,41384,\"0\"],[41404,41405,\"0\"],[41427,41428,\"0\"],[41468,41471,\"0\"],[41774,41793,\"0\"],[41796,41816,\"0\"],[41796,41804,\"0\"],[41806,41816,\"0\"],[41832,41852,\"0\"],[41832,41840,\"0\"],[41842,41852,\"0\"],[41855,41910,\"0\"],[41869,41878,\"0\"],[41985,41986,\"0\"],[42048,42109,\"0\"],[42048,42058,\"0\"],[42189,42279,\"1/2\"],[42189,42208,\"1/2\"],[42356,42366,\"0\"],[42392,42435,\"0\"],[42400,42435,\"0\"],[42403,42435,\"0\"],[42465,42493,\"0\"],[42465,42475,\"0\"],[42500,42549,\"0\"],[42565,42618,\"0\"],[42599,42617,\"0\"],[42622,42629,\"0\"],[42630,42639,\"0\"],[42642,42673,\"0\"],[42645,42673,\"0\"],[42744,42770,\"0\"],[42817,42846,\"0\"],[43087,43133,\"0\"],[43141,43186,\"0\"],[43187,43255,\"0\"],[43353,43369,\"0\"],[43449,43457,\"0\"],[43580,44395,\"1/2\"],[43580,43614,\"1/2\"],[43707,43745,\"0\"],[43746,43799,\"0\"],[43753,43798,\"0\"],[43800,43845,\"0\"],[43807,43844,\"0\"],[43856,43867,\"0\"],[44071,44081,\"0\"],[44196,44214,\"0\"],[44343,44350,\"0\"],[44728,44765,\"0\"],[44846,45001,\"0\"],[44888,44896,\"0\"],[45081,45095,\"0\"],[45098,45099,\"0\"],[45195,45226,\"0\"],[45198,45226,\"0\"],[45310,45331,\"0\"],[45310,45321,\"0\"],[45336,45367,\"0\"],[45442,45449,\"0\"],[45457,45487,\"0\"],[45457,45465,\"0\"],[45461,45465,\"0\"],[45578,45610,\"0\"],[45671,45724,\"0\"],[46531,46589,\"0\"],[46546,46589,\"0\"],[46546,46575,\"0\"],[46546,46559,\"0\"],[46561,46575,\"0\"],[46612,46655,\"0\"],[46612,46622,\"0\"],[46736,46754,\"0\"],[46833,47102,\"0\"],[47526,47582,\"0\"],[47526,47562,\"0\"],[47615,47627,\"0\"],[47656,47754,\"0\"],[47656,47667,\"0\"],[47670,47732,\"0\"],[47772,47837,\"0\"],[47772,47789,\"0\"],[47791,47837,\"0\"],[47794,47837,\"0\"],[47794,47818,\"0\"],[47838,47896,\"0\"],[47838,47849,\"0\"],[47873,47895,\"0\"],[48148,48169,\"0\"],[48496,48597,\"0\"],[48626,48708,\"0\"],[48626,48653,\"0\"],[48655,48708,\"0\"],[48770,48825,\"0\"],[48770,48803,\"0\"],[48770,48783,\"0\"],[48785,48803,\"0\"],[48856,48869,\"0\"],[48888,48951,\"0\"],[48952,49020,\"0\"],[49551,49616,\"0\"],[50315,50333,\"0\"],[50533,50543,\"0\"],[50642,50693,\"0\"],[50642,50656,\"0\"],[50658,50693,\"0\"],[50658,50669,\"0\"],[50885,51063,\"0\"],[50885,50895,\"0\"],[50967,50984,\"0\"],[51033,51050,\"0\"],[51116,51226,\"0\"],[51116,51134,\"0\"],[51139,51156,\"0\"],[51258,51276,\"0\"],[51353,51412,\"0\"],[51690,51701,\"0\"],[51704,51720,\"0\"],[51723,51724,\"0\"],[51785,51786,\"0\"],[51824,52465,\"0\"],[51824,51841,\"0\"],[51843,52465,\"0\"],[51843,51883,\"0\"],[51895,51896,\"0\"],[51934,51935,\"0\"],[52026,52158,\"0\"],[52159,52464,\"0\"],[52276,52408,\"0\"],[52506,52526,\"0\"],[52629,52649,\"0\"],[52811,52829,\"0\"],[52883,52994,\"0\"],[52931,52972,\"0\"],[53168,53210,\"0\"],[53179,53209,\"0\"],[53190,53209,\"0\"],[53213,53237,\"0\"],[53240,53252,\"0\"],[53240,53249,\"0\"],[53294,53319,\"0\"],[53294,53312,\"0\"],[53320,53345,\"0\"],[53320,53338,\"0\"],[53348,53369,\"0\"],[53351,53369,\"0\"],[53372,53397,\"0\"],[53375,53397,\"0\"],[53398,53406,\"0\"],[53407,53415,\"0\"],[53633,53650,\"0\"],[53651,53680,\"0\"],[53651,53668,\"0\"],[53681,53759,\"0\"],[53681,53714,\"0\"],[54038,54097,\"0\"],[54222,54235,\"0\"],[54236,54258,\"0\"],[54842,55180,\"0\"],[54862,55180,\"0\"],[54914,54962,\"0\"],[54963,54987,\"0\"],[55003,55179,\"0\"],[55031,55179,\"0\"],[55097,55178,\"0\"],[55110,55178,\"0\"],[55110,55152,\"0\"],[55224,55244,\"0\"],[55310,55424,\"0\"],[55310,55321,\"0\"],[55514,55570,\"0\"],[55571,55589,\"0\"],[55707,55729,\"0\"],[55811,55861,\"0\"],[56305,56348,\"0\"],[56783,56849,\"0\"],[56796,56849,\"0\"],[56991,57014,\"0\"],[57022,57051,\"0\"],[57022,57035,\"0\"],[57037,57051,\"0\"],[57132,57137,\"0\"],[57178,57213,\"0\"],[57181,57213,\"0\"],[57253,57324,\"0\"],[57325,57333,\"0\"],[57474,57495,\"0\"],[57519,57656,\"0\"],[57532,57656,\"0\"],[57572,57625,\"0\"],[58343,58412,\"0\"],[58671,58740,\"0\"],[58684,58740,\"0\"],[58852,58863,\"0\"],[59182,59201,\"0\"],[15,31,\"0\"],[59338,59412,\"0\"],[59536,59557,\"0\"],[59539,59557,\"0\"],[59658,59715,\"0\"],[59668,59715,\"0\"],[59671,59715,\"0\"],[59822,60160,\"0\"],[59844,59901,\"0\"],[59904,59957,\"0\"],[60083,60086,\"0\"],[60118,60159,\"0\"],[60118,60123,\"0\"],[60181,60208,\"0\"],[60209,60371,\"0\"],[60424,60532,\"0\"],[60424,60454,\"0\"],[60891,60892,\"0\"],[60908,60919,\"0\"],[60960,60986,\"0\"],[61135,61187,\"0\"],[61141,61152,\"0\"],[61448,61519,\"0\"],[61520,61564,\"0\"],[61523,61564,\"0\"],[61591,61670,\"0\"],[61671,61749,\"0\"],[61674,61749,\"0\"],[61878,61935,\"0\"],[61966,62023,\"0\"],[62053,62206,\"0\"],[62069,62092,\"0\"],[62069,62080,\"0\"],[62082,62092,\"0\"],[62105,62116,\"0\"],[62207,62282,\"0\"],[62207,62231,\"0\"],[62210,62231,\"0\"],[62338,62349,\"0\"],[62351,62636,\"0\"],[62371,62388,\"0\"],[62511,62531,\"0\"],[62605,62616,\"0\"],[62710,62721,\"0\"],[62723,62987,\"0\"],[62744,62761,\"0\"],[62884,62904,\"0\"],[63048,63108,\"0\"],[63141,63190,\"0\"],[63216,63274,\"0\"],[63953,63967,\"0\"],[63970,63981,\"0\"]],null]]]\n[\"3/447\",\"b\",[[0,\"3/447\",null,[[100,144,\"0\"],[103,144,\"0\"],[175,179,\"0\"],[338,367,\"0\"],[668,731,\"0\"],[749,786,\"0\"],[807,916,\"0\"],[842,882,\"0\"],[985,986,\"0\"],[1000,1072,\"0\"],[1000,1051,\"0\"],[1083,1085,\"0\"],[1332,1333,\"0\"],[1493,1508,\"0\"],[1917,2006,\"0\"],[1917,1975,\"0\"],[2049,2137,\"0\"],[2084,2136,\"0\"],[2283,2395,\"0\"],[2283,2321,\"0\"],[2323,2395,\"0\"],[2340,2395,\"0\"],[2398,2432,\"0\"],[2433,2534,\"0\"],[2436,2534,\"0\"],[2627,2795,\"0\"],[2627,2685,\"0\"],[2796,2857,\"0\"],[2796,2835,\"0\"],[3685,3717,\"0\"],[3685,3697,\"0\"],[3718,3778,\"0\"],[3718,3732,\"0\"],[3779,3824,\"0\"],[3779,3793,\"0\"],[3795,3824,\"0\"],[3798,3824,\"0\"],[3895,3959,\"0\"],[3895,3921,\"0\"],[3923,3959,\"0\"],[3923,3935,\"0\"],[3994,4038,\"0\"],[4096,4156,\"0\"],[4102,4122,\"0\"],[4157,4211,\"0\"],[4157,4164,\"0\"],[4212,4247,\"0\"],[4289,4319,\"0\"],[4409,4445,\"0\"],[4495,4515,\"0\"],[4746,4771,\"0\"],[4809,4851,\"0\"],[4819,4851,\"0\"],[4830,4851,\"0\"],[4853,4913,\"0\"],[4853,4866,\"0\"],[4857,4866,\"0\"],[4860,4866,\"0\"],[4966,4973,\"0\"],[5014,5045,\"0\"],[5060,5090,\"0\"],[5327,5368,\"0\"],[5327,5348,\"0\"],[5384,5442,\"0\"],[5474,5499,\"0\"],[5543,5598,\"0\"],[5567,5598,\"0\"],[5570,5598,\"0\"],[5766,5886,\"0\"],[5908,5989,\"0\"],[5908,5937,\"0\"],[6021,6053,\"0\"],[6031,6053,\"0\"],[6082,6100,\"0\"],[6140,6156,\"0\"],[6163,6179,\"0\"],[6388,6428,\"0\"],[6907,6939,\"0\"],[6940,7025,\"0\"],[6940,6984,\"0\"],[6986,7025,\"0\"],[7027,7067,\"0\"],[7290,7380,\"0\"],[7290,7341,\"0\"],[7416,7425,\"0\"],[7473,7486,\"0\"],[7523,7533,\"0\"],[29171,29190,\"0\"],[23,33,\"0\"],[29323,29326,\"0\"],[29470,29678,\"0\"],[29493,29678,\"0\"],[29515,29526,\"0\"],[72535,72688,\"0\"],[72788,72789,\"0\"],[72790,72813,\"0\"],[72867,72997,\"0\"],[73096,73493,\"0\"],[73108,73126,\"0\"],[73263,73308,\"0\"],[73263,73275,\"0\"],[73310,73338,\"0\"],[73339,73390,\"0\"],[73455,73456,\"0\"],[73712,73727,\"0\"],[73715,73727,\"0\"],[73994,74029,\"0\"],[74096,74097,\"0\"],[74131,74132,\"0\"],[74533,74568,\"0\"],[74582,74583,\"0\"],[74725,74726,\"0\"],[74783,74784,\"0\"],[74834,74835,\"0\"],[74898,74899,\"0\"],[75101,75125,\"0\"],[75226,75303,\"0\"],[75226,75241,\"0\"],[75321,75335,\"0\"],[75359,75360,\"0\"],[75520,75600,\"0\"],[75520,75557,\"0\"],[75641,75691,\"0\"],[75692,76024,\"0\"],[75786,75818,\"0\"],[75856,75868,\"0\"],[76275,76292,\"0\"],[76369,76438,\"0\"],[76537,76554,\"0\"],[76587,76635,\"0\"],[76663,76686,\"0\"],[76663,76670,\"0\"],[76768,76845,\"0\"],[76768,76795,\"0\"],[77035,77113,\"0\"],[77035,77054,\"0\"],[77114,77173,\"0\"],[77134,77173,\"0\"],[77194,77253,\"0\"],[77270,77405,\"0\"],[77346,77404,\"0\"],[77708,77846,\"0\"],[77708,77753,\"0\"],[77708,77719,\"0\"],[78055,78056,\"0\"],[78060,78076,\"0\"],[78142,78167,\"0\"],[78331,78490,\"0\"],[78350,78490,\"0\"],[78350,78372,\"0\"],[78491,78663,\"0\"],[78491,78510,\"0\"],[78590,78661,\"0\"],[78716,78771,\"0\"],[78804,78818,\"0\"],[78804,78815,\"0\"],[78837,78851,\"0\"],[79038,79055,\"0\"],[79057,79187,\"0\"],[79188,79210,\"0\"],[79215,79216,\"0\"],[79299,79300,\"0\"],[79406,79475,\"0\"],[79531,79612,\"0\"],[79531,79549,\"0\"],[79687,79721,\"0\"],[79722,79766,\"0\"],[79990,80036,\"0\"],[79990,80015,\"0\"],[80037,80094,\"0\"],[80037,80062,\"0\"],[80341,80358,\"0\"],[80431,80454,\"0\"],[80431,80438,\"0\"],[80474,80486,\"0\"],[80526,80574,\"0\"],[80597,80667,\"0\"],[80668,80749,\"0\"],[80668,80695,\"0\"],[80720,80748,\"0\"],[80793,80943,\"0\"],[80813,80943,\"0\"],[80813,80852,\"0\"],[80855,80867,\"0\"],[81050,81231,\"0\"],[81100,81228,\"0\"],[81100,81130,\"0\"],[81152,81173,\"0\"],[81253,81314,\"0\"],[81315,81400,\"0\"],[81466,81547,\"0\"],[81490,81547,\"0\"],[81581,81599,\"0\"],[81602,81603,\"0\"],[81634,81657,\"0\"],[81634,81643,\"0\"],[81645,81657,\"0\"],[81658,81667,\"0\"],[81668,81722,\"0\"],[81799,81860,\"0\"],[81862,81897,\"0\"],[81918,81919,\"0\"],[81975,82014,\"0\"],[82017,82123,\"0\"],[82259,82288,\"0\"],[82289,82328,\"0\"],[82329,82376,\"0\"],[82417,82442,\"0\"],[82515,82540,\"0\"],[82603,82623,\"0\"],[82661,82676,\"0\"],[82702,82724,\"0\"],[82705,82724,\"0\"],[82766,82875,\"0\"],[82766,82776,\"0\"],[82779,82780,\"0\"],[82803,82874,\"0\"],[82834,82873,\"0\"],[82876,82917,\"0\"],[82876,82894,\"0\"],[82918,83007,\"0\"],[82918,82933,\"0\"],[83008,83184,\"0\"],[83008,83019,\"0\"],[83022,83034,\"0\"],[83077,83182,\"0\"],[83202,83315,\"0\"],[83202,83216,\"0\"],[83292,83314,\"0\"],[83342,83374,\"0\"],[83444,83501,\"0\"],[83444,83469,\"0\"],[83527,85392,\"1/2\"],[83527,83546,\"1/2\"],[83656,83742,\"0\"],[83680,83742,\"0\"],[83743,83829,\"0\"],[83768,83829,\"0\"],[83830,83907,\"0\"],[83849,83907,\"0\"],[83908,83973,\"0\"],[83974,84000,\"0\"],[84173,84183,\"0\"],[84221,84259,\"0\"],[84221,84236,\"0\"],[84260,84302,\"0\"],[84260,84270,\"0\"],[84303,84480,\"0\"],[84303,84314,\"0\"],[84317,84326,\"0\"],[84399,84479,\"0\"],[84568,84721,\"0\"],[84582,84721,\"0\"],[84582,84620,\"0\"],[84595,84620,\"0\"],[84622,84721,\"0\"],[84635,84721,\"0\"],[84635,84660,\"0\"],[84662,84721,\"0\"],[84662,84692,\"0\"],[84694,84721,\"0\"],[84747,84806,\"0\"],[84807,84827,\"0\"],[84948,85005,\"0\"],[85006,85067,\"0\"],[85006,85029,\"0\"],[85031,85067,\"0\"],[85031,85053,\"0\"],[85141,85188,\"0\"],[85141,85164,\"0\"],[85166,85188,\"0\"],[85250,85349,\"0\"],[85250,85279,\"0\"],[85281,85349,\"0\"],[85618,85623,\"1/2\"],[85702,85723,\"0\"],[85702,85711,\"0\"],[85713,85723,\"0\"],[85724,85764,\"0\"],[85812,85817,\"0\"],[85819,85830,\"0\"],[85860,85890,\"0\"],[86071,86072,\"0\"],[86286,86556,\"0\"],[86508,86555,\"0\"],[86646,86673,\"0\"],[86674,86773,\"0\"],[86743,86744,\"0\"],[86810,86839,\"0\"],[86865,86925,\"0\"],[86865,86888,\"0\"],[86865,86876,\"0\"],[86878,86888,\"0\"],[86927,87580,\"0\"],[86927,86951,\"0\"],[86927,86939,\"0\"],[86941,86951,\"0\"],[87024,87054,\"0\"],[87195,87196,\"0\"],[87243,87244,\"0\"],[87313,87376,\"0\"],[87346,87376,\"0\"],[87408,87471,\"0\"],[87441,87471,\"0\"],[87607,87641,\"0\"],[87706,87969,\"0\"],[87754,87764,\"0\"],[87769,87803,\"0\"],[87769,87782,\"0\"],[87839,87852,\"0\"],[87911,87924,\"0\"],[88065,88348,\"0\"],[88269,88316,\"0\"],[88428,88767,\"0\"],[88781,88786,\"0\"],[88790,88813,\"0\"],[88793,88813,\"0\"],[88873,88874,\"0\"],[88875,88883,\"0\"],[88909,89158,\"0\"],[89002,89063,\"0\"],[89066,89125,\"0\"],[89361,89393,\"0\"],[89513,89901,\"0\"],[89604,89615,\"0\"],[89817,89884,\"0\"],[89817,89838,\"0\"],[89817,89826,\"0\"],[89828,89838,\"0\"],[89885,89900,\"0\"],[89885,89890,\"0\"],[89973,90451,\"0\"],[90035,90046,\"0\"],[90177,90368,\"0\"],[90177,90198,\"0\"],[90177,90186,\"0\"],[90188,90198,\"0\"],[90436,90441,\"0\"],[90524,91058,\"0\"],[90586,90597,\"0\"],[90735,90923,\"0\"],[90735,90756,\"0\"],[90735,90744,\"0\"],[90746,90756,\"0\"],[90991,90996,\"0\"],[91097,91099,\"0\"],[91121,91150,\"0\"],[91158,91185,\"0\"],[91392,91432,\"0\"],[91392,91412,\"0\"],[91414,91432,\"0\"],[91433,91447,\"0\"],[91484,91485,\"0\"],[91486,91495,\"0\"],[91543,91548,\"0\"],[91549,91582,\"0\"],[91549,91567,\"0\"],[91583,92127,\"0\"],[91587,91628,\"0\"],[91587,91604,\"0\"],[91739,91834,\"0\"],[91739,91747,\"0\"],[91749,91834,\"0\"],[91749,91770,\"0\"],[91772,91834,\"0\"],[91772,91790,\"0\"],[91835,91930,\"0\"],[91835,91843,\"0\"],[91845,91930,\"0\"],[91845,91866,\"0\"],[91868,91930,\"0\"],[91868,91886,\"0\"],[91931,92012,\"0\"],[91948,91958,\"0\"],[92256,92284,\"0\"],[92342,92416,\"0\"],[92481,92522,\"0\"],[92484,92522,\"0\"],[92523,92524,\"0\"],[92658,92704,\"0\"],[92873,92948,\"0\"],[92983,92984,\"0\"],[93024,93031,\"0\"],[93177,93186,\"0\"],[93313,93322,\"0\"],[93416,93472,\"0\"],[93416,93436,\"0\"],[93475,93495,\"0\"],[93511,93567,\"0\"],[93511,93531,\"0\"],[93570,93590,\"0\"],[93606,93674,\"0\"],[93606,93626,\"0\"],[93663,93664,\"0\"],[93677,93697,\"0\"],[93698,93843,\"0\"],[93756,93765,\"0\"],[93859,93923,\"0\"],[93859,93879,\"0\"],[93926,93946,\"0\"],[93962,94030,\"0\"],[93962,93982,\"0\"],[94019,94020,\"0\"],[94033,94053,\"0\"],[94054,94199,\"0\"],[94112,94121,\"0\"],[94215,94278,\"0\"],[94215,94235,\"0\"],[94281,94301,\"0\"],[94326,94351,\"0\"],[94326,94340,\"0\"],[94392,94435,\"0\"],[94510,94547,\"0\"],[94636,94652,\"0\"],[94675,94723,\"0\"],[94686,94723,\"0\"],[94697,94723,\"0\"],[94697,94702,\"0\"],[94704,94723,\"0\"],[94708,94723,\"0\"],[94797,94822,\"0\"],[94886,94893,\"0\"],[94947,95052,\"0\"],[95104,95252,\"0\"],[95106,95117,\"0\"],[95118,95184,\"0\"],[95118,95152,\"0\"],[95185,95252,\"0\"],[95225,95252,\"0\"],[95299,95487,\"0\"],[95322,95487,\"0\"],[95322,95347,\"0\"],[95390,95486,\"0\"],[95393,95486,\"0\"],[95526,95527,\"0\"],[95548,96579,\"0\"],[95548,95554,\"0\"],[95648,95684,\"0\"],[95685,95785,\"0\"],[95832,95865,\"0\"],[96121,96154,\"0\"],[96164,96165,\"0\"],[96234,96578,\"0\"],[96381,96411,\"0\"],[96473,96482,\"0\"],[96484,96485,\"0\"],[96494,96553,\"0\"]],null]]]\n[\"1/518\",\"b\",[[0,\"1/518\",null,[[11313,11342,\"0\"],[11313,11323,\"0\"],[11442,11511,\"0\"],[11442,11445,\"0\"],[11521,11539,\"0\"],[11521,11529,\"0\"],[11531,11539,\"0\"],[11597,11617,\"0\"],[11664,11783,\"0\"],[11664,11689,\"0\"],[11691,11783,\"0\"],[11724,11783,\"0\"],[11784,11960,\"0\"],[11834,11936,\"0\"],[11834,11849,\"0\"],[11851,11936,\"0\"],[11999,12079,\"0\"],[12008,12079,\"0\"],[12032,12079,\"0\"],[12163,12170,\"0\"],[12196,12203,\"0\"],[12268,12269,\"0\"],[12339,12340,\"0\"],[12383,12403,\"0\"],[12418,12451,\"0\"],[12421,12451,\"0\"],[12486,12519,\"0\"],[12489,12519,\"0\"],[12570,12585,\"0\"],[12570,12580,\"0\"],[12582,12585,\"0\"],[12632,12646,\"0\"],[12632,12641,\"0\"],[12643,12646,\"0\"],[12740,12804,\"0\"],[12744,12803,\"0\"],[12744,12761,\"0\"],[12763,12803,\"0\"],[12763,12777,\"0\"],[12810,12811,\"0\"],[12940,13578,\"0\"],[12944,13578,\"0\"],[12944,12973,\"0\"],[12947,12973,\"0\"],[12975,13578,\"0\"],[13095,13141,\"0\"],[13107,13140,\"0\"],[13205,13220,\"0\"],[13231,13243,\"0\"],[13262,13263,\"0\"],[13274,13280,\"0\"],[13333,13397,\"0\"],[13344,13351,\"0\"],[13369,13376,\"0\"],[13419,13559,\"0\"],[13503,13558,\"0\"],[13710,13731,\"0\"],[13782,14134,\"0\"],[13782,14101,\"0\"],[13782,14032,\"0\"],[13782,13815,\"0\"],[13817,14032,\"0\"],[13817,13866,\"0\"],[13868,14032,\"0\"],[13911,14032,\"0\"],[13962,14032,\"0\"],[13996,14031,\"0\"],[14034,14101,\"0\"],[14076,14101,\"0\"],[14198,14331,\"0\"],[14259,14266,\"0\"],[14391,14431,\"0\"],[14789,14796,\"0\"],[14824,14831,\"0\"],[14980,15266,\"0\"],[15020,15266,\"0\"],[15389,15396,\"0\"],[15419,15442,\"0\"],[15494,15495,\"0\"],[15504,15511,\"0\"],[15535,15569,\"0\"],[15570,15578,\"0\"],[15656,15709,\"0\"],[15656,15683,\"0\"],[15798,15935,\"0\"],[15804,15855,\"0\"],[15984,15992,\"0\"],[7,10,\"0\"],[4,7,\"0\"],[16084,16097,\"0\"],[16211,16227,\"0\"],[16211,16218,\"0\"],[16220,16227,\"0\"],[16256,16293,\"0\"],[16258,16276,\"0\"],[16287,16293,\"0\"],[16325,16382,\"0\"],[16325,16326,\"0\"],[16385,16403,\"0\"],[16453,16506,\"0\"],[16453,16454,\"0\"],[16510,16553,\"0\"],[16510,16511,\"0\"],[16557,16614,\"0\"],[16557,16558,\"0\"],[16618,16665,\"0\"],[16618,16619,\"0\"],[16711,16742,\"0\"],[16711,16723,\"0\"],[16725,16742,\"0\"],[16750,16756,\"0\"],[16787,16794,\"0\"],[16798,16805,\"0\"],[16809,16819,\"0\"],[16809,16816,\"0\"],[16822,16829,\"0\"],[16836,16843,\"0\"],[16907,16909,\"0\"],[17023,17027,\"0\"],[17119,17252,\"0\"],[17160,17212,\"0\"],[17246,17252,\"0\"],[17316,17341,\"0\"],[17396,17406,\"0\"],[17414,17415,\"0\"],[17416,17441,\"0\"],[17416,17433,\"0\"],[17816,17822,\"0\"],[17853,17896,\"0\"],[17853,17863,\"0\"],[17951,18069,\"0\"],[17951,17957,\"0\"],[17985,18064,\"0\"],[17985,17990,\"0\"],[18051,18055,\"0\"],[18065,18069,\"0\"],[18088,18122,\"0\"],[18118,18122,\"0\"],[18141,18245,\"0\"],[18189,18245,\"0\"],[18189,18208,\"0\"],[18210,18245,\"0\"],[18210,18228,\"0\"],[18230,18245,\"0\"],[18606,18608,\"0\"],[18627,18680,\"0\"],[18627,18628,\"0\"],[18684,18727,\"0\"],[18684,18685,\"0\"],[18731,18788,\"0\"],[18731,18732,\"0\"],[18792,18839,\"0\"],[18792,18793,\"0\"],[18860,18891,\"0\"],[18860,18872,\"0\"],[18874,18891,\"0\"],[18899,18905,\"0\"],[18941,18945,\"0\"],[18951,18961,\"0\"],[19008,19012,\"0\"],[19037,19038,\"0\"],[19047,19057,\"0\"],[19065,19066,\"0\"],[19067,19092,\"0\"],[19067,19084,\"0\"],[19455,19461,\"0\"],[19479,19501,\"0\"],[19624,19630,\"0\"],[19745,19776,\"0\"],[19745,19757,\"0\"],[19759,19776,\"0\"],[19784,19790,\"0\"],[19802,19812,\"0\"],[19820,19821,\"0\"],[19822,19847,\"0\"],[19822,19839,\"0\"],[19983,19989,\"0\"],[20067,20089,\"0\"],[20158,20183,\"0\"],[20229,20236,\"0\"],[20319,20343,\"0\"],[20355,20356,\"0\"],[20484,20485,\"0\"],[20515,20516,\"0\"],[20546,20547,\"0\"],[20949,21017,\"0\"],[20949,20976,\"0\"],[21118,21119,\"0\"],[21129,21138,\"0\"],[21442,21457,\"0\"],[21442,21449,\"0\"],[21451,21457,\"0\"],[21460,21478,\"0\"],[21484,21502,\"0\"],[21503,21511,\"0\"],[21550,21573,\"0\"],[21553,21573,\"0\"],[21578,21649,\"0\"],[21722,21723,\"0\"],[21736,21751,\"0\"],[22059,22124,\"0\"],[22059,22098,\"0\"],[22059,22078,\"0\"],[22080,22098,\"0\"],[22100,22124,\"0\"],[22149,22195,\"0\"],[22170,22171,\"0\"],[22224,22267,\"0\"],[22240,22267,\"0\"],[22249,22267,\"0\"],[23494,23512,\"0\"],[23494,23502,\"0\"],[23504,23512,\"0\"],[23542,23554,\"0\"],[23722,23741,\"0\"],[23722,23739,\"0\"],[23722,23725,\"0\"],[23841,23847,\"0\"],[23844,23847,\"0\"],[23852,23898,\"0\"],[23904,23920,\"0\"],[23904,23907,\"0\"],[23981,23982,\"0\"],[24078,24109,\"0\"],[24256,24257,\"0\"],[24288,24289,\"0\"],[24418,24419,\"0\"],[24478,24479,\"0\"],[24575,24605,\"0\"],[24752,24753,\"0\"],[24784,24785,\"0\"],[24914,24915,\"0\"],[24977,25017,\"0\"],[25024,25025,\"0\"],[25076,25084,\"0\"],[25270,25271,\"0\"],[25332,25333,\"0\"],[25341,25361,\"0\"],[25529,25530,\"0\"],[25590,25598,\"0\"],[25879,25885,\"0\"],[25919,25925,\"0\"],[25934,26018,\"0\"],[26023,26066,\"0\"],[26060,26066,\"0\"],[26133,26134,\"0\"],[26217,26218,\"0\"],[26381,26406,\"0\"],[26401,26406,\"0\"],[26407,26408,\"0\"],[26418,26444,\"0\"],[26439,26444,\"0\"],[26445,26446,\"0\"],[26496,26504,\"0\"],[194,197,\"0\"],[4,7,\"0\"],[26573,26574,\"0\"],[26654,26657,\"0\"],[26695,26706,\"0\"],[26856,26939,\"0\"],[26856,26875,\"0\"],[26877,26939,\"0\"],[26877,26896,\"0\"],[27004,27005,\"0\"],[27006,27020,\"0\"],[27106,27109,\"0\"],[27131,27132,\"0\"],[27218,27221,\"0\"],[27228,27229,\"0\"],[27238,27245,\"0\"],[27277,27293,\"0\"],[27296,27324,\"0\"],[27296,27301,\"0\"],[27303,27324,\"0\"],[27310,27324,\"0\"],[27310,27316,\"0\"],[27313,27316,\"0\"],[27318,27324,\"0\"],[27321,27324,\"0\"],[27344,27355,\"0\"],[27387,27388,\"0\"],[27423,27536,\"0\"],[27423,27482,\"0\"],[27423,27448,\"0\"],[27450,27482,\"0\"],[27450,27469,\"0\"],[27484,27536,\"0\"],[27484,27509,\"0\"],[27511,27536,\"0\"],[27565,27566,\"0\"],[27621,27626,\"0\"],[27640,27665,\"0\"],[27694,27719,\"0\"],[27753,27765,\"0\"],[27753,27758,\"0\"],[27813,27814,\"0\"],[27933,27938,\"0\"],[27948,27949,\"0\"],[28036,28061,\"0\"],[28089,28114,\"0\"],[28139,28140,\"0\"],[28286,28302,\"0\"],[28286,28290,\"0\"],[28326,28327,\"0\"],[28338,28356,\"0\"],[28338,28344,\"0\"],[28346,28356,\"0\"],[28661,28666,\"0\"],[28733,28755,\"0\"],[28736,28755,\"0\"],[28758,28780,\"0\"],[28761,28780,\"0\"],[28874,28878,\"0\"],[28884,29012,\"0\"],[28884,28903,\"0\"],[28888,28903,\"0\"],[28907,29012,\"0\"],[28907,28926,\"0\"],[28911,28926,\"0\"],[28958,28963,\"0\"],[29031,29076,\"0\"],[29031,29063,\"0\"],[29041,29049,\"0\"],[29045,29049,\"0\"],[29080,29100,\"0\"],[29095,29100,\"0\"],[29246,29265,\"0\"],[29296,29314,\"0\"],[29354,29362,\"0\"],[29393,29401,\"0\"],[29406,29407,\"0\"],[29440,29441,\"0\"],[29569,29573,\"0\"],[29616,29621,\"0\"],[29760,29792,\"0\"],[29768,29776,\"0\"],[29772,29776,\"0\"],[29860,29867,\"0\"],[29892,29899,\"0\"],[29958,29965,\"0\"],[30151,30191,\"0\"],[30151,30167,\"0\"],[30151,30158,\"0\"],[30160,30167,\"0\"],[30291,30297,\"0\"],[30294,30297,\"0\"],[30309,30315,\"0\"],[30312,30315,\"0\"],[30386,30458,\"0\"],[30397,30404,\"0\"],[30534,30541,\"0\"],[30548,30566,\"0\"],[30936,30939,\"0\"],[30964,31030,\"0\"],[30964,30967,\"0\"],[31196,31342,\"0\"],[31282,31311,\"0\"],[31312,31341,\"0\"],[31344,31457,\"0\"],[31344,31372,\"0\"],[31348,31372,\"0\"],[31376,31457,\"0\"],[31376,31404,\"0\"],[31380,31404,\"0\"],[31408,31457,\"0\"],[31408,31430,\"0\"],[31412,31430,\"0\"],[31434,31456,\"0\"],[31438,31456,\"0\"],[31534,31552,\"0\"],[31810,31901,\"0\"],[31908,31909,\"0\"],[31910,31928,\"0\"],[32187,32199,\"0\"],[32212,32328,\"0\"],[32329,32408,\"0\"],[32329,32370,\"0\"],[32461,32526,\"0\"],[32461,32479,\"0\"],[32481,32526,\"0\"],[32481,32497,\"0\"],[32499,32526,\"0\"],[32499,32512,\"0\"],[32514,32526,\"0\"],[32527,32592,\"0\"],[32527,32539,\"0\"],[32541,32592,\"0\"],[32541,32561,\"0\"],[32563,32592,\"0\"],[32687,32705,\"0\"],[118872,118927,\"0\"],[119108,119258,\"0\"],[119286,119288,\"0\"],[119310,119346,\"0\"],[119383,119394,\"0\"],[119397,119452,\"0\"],[119397,119433,\"0\"],[119511,119539,\"0\"],[119514,119539,\"0\"],[119580,119685,\"0\"],[119686,119802,\"0\"],[119713,119802,\"0\"],[119757,119781,\"0\"],[119828,119947,\"0\"],[119828,119886,\"0\"],[120248,120264,\"0\"],[120269,120338,\"0\"],[120279,120338,\"0\"],[120279,120326,\"0\"],[120303,120326,\"0\"],[120366,120374,\"0\"],[120378,120476,\"0\"],[120402,120476,\"0\"],[120427,120476,\"0\"],[120512,120536,\"0\"],[120568,120609,\"0\"],[120568,120585,\"0\"],[120610,120628,\"0\"],[120670,120842,\"0\"],[120697,120842,\"0\"],[120741,120786,\"0\"],[120787,120805,\"0\"],[121012,121171,\"0\"],[121055,121151,\"0\"],[121055,121093,\"0\"],[121172,121261,\"0\"],[121351,121473,\"0\"],[121721,121737,\"1/2\"],[121814,121884,\"0\"],[121818,121883,\"0\"],[121821,121883,\"0\"],[121846,121883,\"0\"],[121893,121903,\"0\"],[121908,121954,\"0\"],[121983,122041,\"0\"],[121986,122041,\"0\"],[121986,122019,\"0\"],[122514,122575,\"0\"],[122514,122546,\"0\"],[122576,122631,\"0\"],[122720,122792,\"0\"],[122720,122732,\"0\"],[122821,122876,\"0\"],[122821,122846,\"0\"],[123022,123099,\"0\"],[123035,123099,\"0\"],[123047,123099,\"0\"],[123135,123146,\"0\"],[123193,123209,\"0\"],[123508,123580,\"0\"],[123674,123711,\"0\"],[123880,123925,\"0\"],[124106,124196,\"0\"],[124152,124185,\"0\"],[124379,124431,\"0\"],[124455,124484,\"0\"],[124536,124607,\"0\"],[124552,124607,\"0\"],[124630,124678,\"0\"],[124996,125162,\"0\"],[124996,125063,\"0\"],[125188,125218,\"0\"],[125399,125442,\"0\"],[125493,125569,\"0\"],[125509,125569,\"0\"],[125570,125616,\"0\"],[125617,125789,\"0\"],[125617,125652,\"0\"],[125617,125648,\"0\"],[125654,125789,\"0\"],[125654,125686,\"0\"],[125654,125683,\"0\"],[125688,125789,\"0\"],[125688,125712,\"0\"],[125714,125789,\"0\"],[125887,125924,\"0\"],[125887,125912,\"0\"],[125927,125970,\"0\"],[125927,125955,\"0\"],[126025,126072,\"0\"],[126073,126135,\"0\"],[126205,126245,\"0\"],[126359,126378,\"0\"],[126502,126521,\"0\"],[126799,126800,\"0\"],[126840,126858,\"0\"],[126917,127014,\"0\"],[126954,126975,\"0\"],[127015,127069,\"0\"],[127018,127069,\"0\"],[127018,127040,\"0\"],[127070,127166,\"0\"],[127070,127092,\"0\"],[127094,127166,\"0\"],[127202,127294,\"0\"],[127332,127368,\"0\"],[127332,127350,\"0\"],[127369,127412,\"0\"],[127402,127410,\"0\"],[127413,127474,\"0\"],[127475,127567,\"0\"],[127568,127625,\"0\"],[127626,127719,\"0\"],[127751,127803,\"0\"],[127783,127803,\"0\"],[127891,128080,\"0\"],[127891,127920,\"0\"],[127922,128080,\"0\"],[127922,127946,\"0\"],[127948,128080,\"0\"],[128081,128139,\"0\"],[128152,128207,\"0\"],[128222,128278,\"0\"],[128315,128344,\"0\"],[128455,128493,\"0\"],[128496,128497,\"0\"],[128544,128583,\"0\"],[128586,128587,\"0\"]],null]]]\n[\"0/428\",\"b\",[[0,\"0/428\",null,[[6590,6636,\"0\"],[6641,6686,\"0\"],[6693,6740,\"0\"],[6748,6796,\"0\"],[7030,7043,\"0\"],[7044,7068,\"0\"],[7342,7392,\"0\"],[7450,7476,\"0\"],[7561,7606,\"0\"],[7664,7697,\"0\"],[7739,7806,\"0\"],[7739,7763,\"0\"],[7833,8503,\"0\"],[7898,7938,\"0\"],[7941,7977,\"0\"],[7980,8019,\"0\"],[8022,8057,\"0\"],[8060,8061,\"0\"],[8116,8157,\"0\"],[8160,8197,\"0\"],[8266,8267,\"0\"],[8325,8367,\"0\"],[8370,8408,\"0\"],[8577,8597,\"0\"],[8604,8618,\"0\"],[8735,8761,\"0\"],[8786,8787,\"0\"],[8883,8909,\"0\"],[8935,8936,\"0\"],[9095,9210,\"0\"],[9095,9116,\"0\"],[9211,10236,\"0\"],[9215,9694,\"0\"],[9234,9256,\"0\"],[9422,9490,\"0\"],[9422,9457,\"0\"],[9491,9557,\"0\"],[9491,9525,\"0\"],[9558,9626,\"0\"],[9558,9593,\"0\"],[9627,9693,\"0\"],[9627,9661,\"0\"],[9695,10161,\"0\"],[9706,9715,\"0\"],[9811,9812,\"0\"],[9813,9872,\"0\"],[9813,9842,\"0\"],[9844,9872,\"0\"],[9875,9904,\"0\"],[9933,9942,\"0\"],[10038,10039,\"0\"],[10040,10101,\"0\"],[10040,10070,\"0\"],[10072,10101,\"0\"],[10104,10134,\"0\"],[10162,10198,\"0\"],[10162,10174,\"0\"],[10199,10235,\"0\"],[10199,10211,\"0\"],[10315,10341,\"0\"],[10366,10367,\"0\"],[10475,10501,\"0\"],[10527,10528,\"0\"],[10631,10711,\"0\"],[10631,10663,\"0\"],[10665,10711,\"0\"],[10757,10792,\"0\"],[10828,10845,\"0\"],[10885,10995,\"0\"],[14920,15013,\"0\"],[14923,15013,\"0\"],[15142,15150,\"0\"],[15603,15896,\"0\"],[15790,15891,\"0\"],[15790,15798,\"0\"],[15800,15891,\"0\"],[15843,15891,\"0\"],[15897,15898,\"0\"],[15900,16521,\"0\"],[16522,16576,\"0\"],[16578,17025,\"0\"],[16802,16839,\"0\"],[17119,17163,\"0\"],[17227,17271,\"0\"],[17366,17413,\"0\"],[17479,17528,\"0\"],[17592,17668,\"0\"],[17669,17809,\"0\"],[17669,17711,\"0\"],[17713,17809,\"0\"],[17713,17756,\"0\"],[17898,17923,\"0\"],[17898,17903,\"0\"],[17905,17923,\"0\"],[17925,18144,\"0\"],[17925,17945,\"0\"],[17933,17945,\"0\"],[17948,18011,\"0\"],[18052,18143,\"0\"],[18052,18100,\"0\"],[18145,18369,\"0\"],[18145,18165,\"0\"],[18153,18165,\"0\"],[18168,18231,\"0\"],[18274,18368,\"0\"],[18274,18323,\"0\"],[18372,18607,\"0\"],[18372,18392,\"0\"],[18380,18392,\"0\"],[18395,18439,\"0\"],[18489,18606,\"0\"],[18489,18554,\"0\"],[18608,18848,\"0\"],[18608,18628,\"0\"],[18616,18628,\"0\"],[18631,18676,\"0\"],[18728,18847,\"0\"],[18728,18793,\"0\"],[18850,18926,\"0\"],[18850,18856,\"0\"],[18858,18926,\"0\"],[18874,18926,\"0\"],[19024,19051,\"0\"],[19052,19087,\"0\"],[19139,19258,\"0\"],[19139,19158,\"0\"],[19459,19463,\"0\"],[19603,19703,\"0\"],[19603,19608,\"0\"],[19610,19703,\"0\"],[19610,19615,\"0\"],[19617,19703,\"0\"],[19617,19622,\"0\"],[19624,19703,\"0\"],[19624,19629,\"0\"],[19705,19853,\"0\"],[19733,19853,\"0\"],[19886,20330,\"0\"],[19886,19906,\"0\"],[19911,19927,\"0\"],[19930,19946,\"0\"],[19949,19965,\"0\"],[19968,19984,\"0\"],[19985,20083,\"0\"],[20084,20155,\"0\"],[20156,20255,\"0\"],[20256,20329,\"0\"],[20333,20343,\"0\"],[20336,20343,\"0\"],[20339,20343,\"0\"],[20344,20788,\"0\"],[20344,20364,\"0\"],[20369,20385,\"0\"],[20388,20404,\"0\"],[20407,20423,\"0\"],[20426,20442,\"0\"],[20443,20514,\"0\"],[20515,20613,\"0\"],[20614,20687,\"0\"],[20688,20787,\"0\"],[20789,20949,\"0\"],[20819,20949,\"0\"],[20819,20832,\"0\"],[20822,20832,\"0\"],[20825,20832,\"0\"],[20828,20832,\"0\"],[20835,20949,\"0\"],[20977,20990,\"0\"],[20980,20990,\"0\"],[20983,20990,\"0\"],[20986,20990,\"0\"],[21124,21158,\"0\"],[21161,21195,\"0\"],[21199,21330,\"0\"],[21212,21249,\"0\"],[21424,21468,\"0\"],[21532,21578,\"0\"],[22032,22048,\"0\"],[22129,22160,\"0\"],[22176,22189,\"0\"],[22179,22189,\"0\"],[22227,22233,\"0\"],[22247,22253,\"0\"],[22483,22511,\"0\"],[22493,22511,\"0\"],[22512,22543,\"0\"],[22551,23532,\"0\"],[23554,23792,\"0\"],[23609,23680,\"0\"],[23710,23790,\"0\"],[23722,23790,\"0\"],[23993,24209,\"0\"],[24490,24792,\"0\"],[24503,24740,\"0\"],[24515,24560,\"0\"],[24759,24792,\"0\"],[24797,24855,\"0\"],[24797,24817,\"0\"],[24902,24912,\"0\"],[25134,25149,\"0\"],[25172,25208,\"0\"],[25280,25418,\"0\"],[25280,25328,\"0\"],[25419,25756,\"0\"],[25442,25756,\"0\"],[25556,25581,\"0\"],[25626,25643,\"0\"],[25650,25667,\"0\"],[25677,25690,\"0\"],[25986,26104,\"0\"],[25999,26078,\"0\"],[26086,26093,\"0\"],[26107,26181,\"0\"],[26319,26364,\"0\"],[26319,26355,\"0\"],[26319,26331,\"0\"],[26371,26396,\"0\"],[26579,26666,\"0\"],[26598,26630,\"0\"],[26634,26665,\"0\"],[26739,26751,\"0\"],[26862,26874,\"0\"],[27146,27176,\"0\"],[27191,27242,\"0\"],[27312,27322,\"0\"],[27491,27508,\"0\"],[27511,27527,\"0\"],[27582,27583,\"0\"],[27652,27710,\"0\"],[27652,27681,\"0\"],[27808,27894,\"0\"],[27823,27894,\"0\"],[27895,28002,\"0\"],[28098,28663,\"0\"],[28152,28193,\"0\"],[28196,28227,\"0\"],[28250,28251,\"0\"],[28329,28410,\"0\"],[28413,28491,\"0\"],[28492,28547,\"0\"],[28608,28662,\"0\"],[28622,28662,\"0\"],[28764,28798,\"0\"],[29006,29079,\"0\"],[29006,29047,\"0\"],[29080,29157,\"0\"],[29080,29123,\"0\"],[29158,29227,\"0\"],[29158,29195,\"0\"],[29228,29301,\"0\"],[29228,29267,\"0\"],[29402,29428,\"0\"],[29451,29477,\"0\"],[29503,29530,\"0\"],[29555,29582,\"0\"],[29601,29867,\"0\"],[29601,29621,\"0\"],[29747,29775,\"0\"],[29747,29759,\"0\"],[29776,29806,\"0\"],[29776,29789,\"0\"],[29807,29835,\"0\"],[29807,29819,\"0\"],[29836,29866,\"0\"],[29836,29849,\"0\"],[29947,29998,\"0\"],[29999,30047,\"0\"],[30048,30101,\"0\"],[30102,30152,\"0\"],[30230,30254,\"0\"],[30289,30349,\"0\"],[30350,30404,\"0\"],[30350,30358,\"0\"],[30405,30480,\"0\"],[30405,30413,\"0\"],[30547,30602,\"0\"],[30572,30602,\"0\"],[30584,30602,\"0\"],[30605,30664,\"0\"],[30631,30664,\"0\"],[30644,30664,\"0\"],[30667,30722,\"0\"],[30692,30722,\"0\"],[30704,30722,\"0\"],[30725,30784,\"0\"],[30751,30784,\"0\"],[30764,30784,\"0\"],[30938,30961,\"0\"],[30962,30987,\"0\"],[30988,31011,\"0\"],[31012,31037,\"0\"],[31038,31065,\"0\"],[31041,31065,\"0\"],[31066,31093,\"0\"],[31069,31093,\"0\"],[31094,31121,\"0\"],[31097,31121,\"0\"],[31122,31149,\"0\"],[31125,31149,\"0\"],[31150,31183,\"0\"],[31159,31183,\"0\"],[31169,31183,\"0\"],[31184,31232,\"0\"],[31193,31232,\"0\"],[31203,31232,\"0\"],[31210,31232,\"0\"],[31504,31507,\"0\"],[31517,31536,\"0\"],[31543,31562,\"0\"],[31641,31682,\"0\"],[31699,31724,\"0\"],[31725,31768,\"0\"],[31813,31897,\"0\"],[31912,31953,\"0\"],[31960,31999,\"0\"],[32091,32103,\"0\"],[32117,32171,\"0\"],[33313,33355,\"0\"],[33313,33325,\"0\"],[33718,33759,\"0\"],[33762,33790,\"0\"],[33813,33814,\"0\"],[33884,33965,\"0\"],[33968,34046,\"0\"],[34076,34080,\"0\"],[34335,34392,\"0\"],[34338,34392,\"0\"],[34594,34608,\"0\"],[34618,34634,\"0\"],[34655,35372,\"0\"],[34683,34715,\"0\"],[34703,34715,\"0\"],[34865,34924,\"0\"],[35233,35255,\"0\"],[35274,35289,\"0\"],[35484,35510,\"0\"],[35554,35610,\"0\"],[35554,35569,\"0\"],[35571,35610,\"0\"],[35611,35810,\"0\"],[35611,35638,\"0\"],[35619,35628,\"0\"],[35668,35677,\"0\"],[35725,35775,\"0\"],[35792,35800,\"0\"],[35811,35999,\"0\"],[35811,35836,\"0\"],[35818,35827,\"0\"],[35868,35877,\"0\"],[35915,35965,\"0\"],[35981,35990,\"0\"],[36002,36055,\"0\"],[36119,36123,\"0\"],[36324,36333,\"0\"],[36408,36417,\"0\"],[36458,36580,\"0\"],[36458,36492,\"0\"],[36529,36579,\"0\"],[36581,36707,\"0\"],[36581,36617,\"0\"],[36656,36706,\"0\"],[36708,36844,\"0\"],[37070,37186,\"0\"],[37081,37186,\"0\"],[37093,37186,\"0\"],[37187,37301,\"0\"],[37198,37301,\"0\"],[37210,37301,\"0\"],[37732,37757,\"0\"],[37764,37787,\"0\"],[37792,37815,\"0\"],[37821,37846,\"0\"],[37943,37981,\"0\"],[38063,38070,\"0\"],[38073,38080,\"0\"],[38082,38105,\"0\"],[38085,38105,\"0\"],[38085,38089,\"0\"],[38097,38104,\"0\"],[38475,38566,\"0\"],[38475,38494,\"0\"],[38496,38566,\"0\"],[38496,38528,\"0\"],[38649,38732,\"0\"],[38786,38848,\"0\"],[38795,38848,\"0\"],[39014,39037,\"0\"],[39063,39070,\"0\"],[39073,39080,\"0\"],[39182,39206,\"0\"],[39194,39206,\"0\"],[39209,39235,\"0\"],[39222,39235,\"0\"],[39238,39262,\"0\"],[39250,39262,\"0\"],[39265,39291,\"0\"],[39278,39291,\"0\"],[39301,39310,\"0\"],[39311,39320,\"0\"],[39321,39330,\"0\"],[39331,39340,\"0\"],[39341,39361,\"0\"],[39395,39411,\"0\"],[39468,39484,\"0\"],[39545,39605,\"0\"],[39545,39559,\"0\"],[39545,39551,\"0\"],[39553,39559,\"0\"],[39606,39611,\"0\"],[39716,39721,\"0\"],[40256,40287,\"0\"],[40256,40259,\"0\"],[41033,41124,\"0\"],[41033,41057,\"0\"],[41059,41124,\"0\"],[41059,41083,\"0\"],[41125,41174,\"0\"],[41365,41426,\"0\"],[41389,41426,\"0\"],[41427,41488,\"0\"],[41451,41488,\"0\"],[41542,41576,\"0\"],[41634,41659,\"0\"],[41638,41658,\"0\"],[41684,41693,\"0\"],[41883,41948,\"0\"],[41983,42015,\"0\"],[41993,42015,\"0\"]],null]]]\n[\"4/366\",\"b\",[[0,\"4/366\",null,[[2302,2635,\"0\"],[2316,2635,\"0\"],[2316,2351,\"0\"],[2443,2556,\"0\"],[2881,2954,\"0\"],[2881,2913,\"0\"],[2955,2986,\"0\"],[2958,2986,\"0\"],[3024,3036,\"0\"],[3038,3078,\"0\"],[3237,3309,\"0\"],[3513,3552,\"0\"],[3553,3597,\"0\"],[3598,3653,\"0\"],[3654,3714,\"0\"],[3715,3742,\"0\"],[3854,3906,\"0\"],[3854,3874,\"0\"],[3907,3931,\"0\"],[4227,4322,\"0\"],[4255,4322,\"0\"],[4280,4322,\"0\"],[4291,4322,\"0\"],[4373,4426,\"0\"],[4373,4401,\"0\"],[4507,4563,\"0\"],[4507,4551,\"0\"],[4507,4522,\"0\"],[4524,4551,\"0\"],[4564,4687,\"0\"],[4564,4608,\"0\"],[4564,4579,\"0\"],[4581,4608,\"0\"],[4610,4687,\"0\"],[4777,4818,\"0\"],[4822,4945,\"0\"],[5164,5244,\"0\"],[5834,5852,\"0\"],[6329,6372,\"0\"],[6349,6372,\"0\"],[6469,6484,\"0\"],[6721,6760,\"0\"],[6721,6745,\"0\"],[7522,7526,\"0\"],[7548,7552,\"0\"],[7892,7910,\"0\"],[8500,8504,\"0\"],[8526,8530,\"0\"],[8973,8994,\"0\"],[8973,8979,\"0\"],[9081,9134,\"0\"],[9185,9202,\"0\"],[9303,9326,\"0\"],[9373,9396,\"0\"],[9484,9517,\"0\"],[9518,9558,\"0\"],[9561,9595,\"0\"],[9596,9672,\"0\"],[9723,10342,\"0\"],[9723,9737,\"0\"],[9757,9813,\"0\"],[9757,9771,\"0\"],[9814,9850,\"0\"],[9814,9827,\"0\"],[9851,9958,\"0\"],[9851,9866,\"0\"],[9959,10066,\"0\"],[9959,9974,\"0\"],[10007,10036,\"0\"],[10010,10036,\"0\"],[10037,10065,\"0\"],[10041,10065,\"0\"],[10067,10099,\"0\"],[10067,10081,\"0\"],[10100,10268,\"0\"],[10100,10115,\"0\"],[10148,10177,\"0\"],[10151,10177,\"0\"],[10178,10234,\"0\"],[10181,10234,\"0\"],[10181,10199,\"0\"],[10235,10267,\"0\"],[10238,10267,\"0\"],[10238,10244,\"0\"],[10269,10341,\"0\"],[10269,10280,\"0\"],[10464,10504,\"0\"],[10464,10482,\"0\"],[10600,10628,\"0\"],[10662,10679,\"0\"],[10782,10886,\"0\"],[11149,11221,\"0\"],[11260,11300,\"0\"],[11381,11399,\"0\"],[11440,11625,\"0\"],[11526,11622,\"0\"],[11529,11622,\"0\"],[11829,11872,\"0\"],[11909,11941,\"0\"],[11992,11993,\"0\"],[52032,52340,\"1/2\"],[52033,52052,\"1/2\"],[194,276,\"0\"],[194,211,\"0\"],[52654,52669,\"0\"],[52735,52751,\"0\"],[52776,52777,\"0\"],[52883,52927,\"0\"],[52984,53016,\"0\"],[53090,53100,\"0\"],[53105,53131,\"0\"],[53105,53116,\"0\"],[53250,53262,\"0\"],[53275,53290,\"0\"],[53329,53340,\"0\"],[53524,53565,\"0\"],[53646,53689,\"0\"],[53738,53908,\"0\"],[53742,53908,\"0\"],[53742,53788,\"0\"],[53742,53766,\"0\"],[53790,53908,\"0\"],[53823,53847,\"0\"],[53956,54128,\"0\"],[53960,54128,\"0\"],[53960,54006,\"0\"],[53960,53984,\"0\"],[54008,54128,\"0\"],[54041,54065,\"0\"],[54156,54181,\"0\"],[54194,54244,\"0\"],[54198,54244,\"0\"],[54198,54222,\"0\"],[54376,54570,\"0\"],[54394,54570,\"0\"],[54415,54570,\"0\"],[54415,54448,\"0\"],[54450,54570,\"0\"],[54477,54501,\"0\"],[54590,54591,\"0\"],[54595,54653,\"0\"],[54628,54652,\"0\"],[54795,54819,\"0\"],[173763,173774,\"0\"],[173763,173767,\"0\"],[173769,173774,\"0\"],[173803,173812,\"0\"],[173829,173863,\"0\"],[173888,173922,\"0\"],[174114,174136,\"0\"],[174114,174118,\"0\"],[174120,174136,\"0\"],[174120,174124,\"0\"],[174126,174136,\"0\"],[174126,174130,\"0\"],[174132,174136,\"0\"],[174160,174292,\"0\"],[174160,174191,\"0\"],[174193,174292,\"0\"],[174193,174224,\"0\"],[174226,174292,\"0\"],[174226,174258,\"0\"],[174260,174292,\"0\"],[174314,174385,\"0\"],[174405,174474,\"0\"],[174405,174437,\"0\"],[174405,174415,\"0\"],[174405,174409,\"0\"],[174411,174415,\"0\"],[174417,174437,\"0\"],[174417,174427,\"0\"],[174417,174421,\"0\"],[174423,174427,\"0\"],[174429,174437,\"0\"],[174429,174432,\"0\"],[174434,174437,\"0\"],[174441,174473,\"0\"],[174441,174451,\"0\"],[174441,174445,\"0\"],[174447,174451,\"0\"],[174453,174473,\"0\"],[174453,174463,\"0\"],[174453,174457,\"0\"],[174459,174463,\"0\"],[174465,174473,\"0\"],[174465,174468,\"0\"],[174470,174473,\"0\"],[174591,174637,\"0\"],[174640,174641,\"0\"],[174657,174681,\"0\"],[174731,174741,\"0\"],[174749,174837,\"0\"],[174751,174836,\"0\"],[174774,174836,\"0\"],[174811,174835,\"0\"],[174847,174857,\"0\"],[174865,174887,\"0\"],[174941,174977,\"0\"],[174978,175162,\"0\"],[174993,175037,\"0\"],[174993,175008,\"0\"],[175208,175254,\"0\"],[175275,175554,\"0\"],[175290,175394,\"0\"],[175314,175394,\"0\"],[175328,175394,\"0\"],[175367,175393,\"0\"],[175395,175553,\"0\"],[175419,175553,\"0\"],[175433,175553,\"0\"],[175466,175490,\"0\"],[175648,175710,\"0\"],[175733,175795,\"0\"],[175803,175849,\"0\"],[175864,175919,\"0\"],[175888,175919,\"0\"],[175970,175985,\"0\"],[175994,176009,\"0\"],[176024,176478,\"0\"],[176028,176263,\"0\"],[176142,176189,\"0\"],[176192,176262,\"0\"],[176249,176261,\"0\"],[176264,176322,\"0\"],[176267,176322,\"0\"],[176267,176279,\"0\"],[176339,176350,\"0\"],[176377,176389,\"0\"],[176419,176477,\"0\"],[176422,176477,\"0\"],[176422,176433,\"0\"],[176561,176623,\"0\"],[176626,177197,\"1/2\"],[176626,176645,\"1/2\"],[176767,176840,\"0\"],[176886,176962,\"0\"],[177004,177075,\"0\"],[177120,177194,\"0\"],[177932,177942,\"0\"],[178075,178109,\"0\"],[178075,178085,\"0\"],[178129,178135,\"0\"],[178136,178161,\"0\"],[178136,178154,\"0\"],[178162,178180,\"0\"],[178393,178428,\"0\"],[178393,178402,\"0\"],[178609,178627,\"0\"],[178787,178817,\"0\"],[41,51,\"0\"],[178906,178926,\"0\"],[178997,179015,\"0\"],[179058,179184,\"0\"],[179259,179323,\"0\"],[179325,179385,\"0\"],[179325,179342,\"0\"],[179386,179433,\"0\"],[179386,179406,\"0\"],[180581,181533,\"0\"],[180768,180797,\"0\"],[180908,181096,\"0\"],[180919,181096,\"0\"],[181193,181194,\"0\"],[181198,181257,\"0\"],[181198,181220,\"0\"],[181284,181285,\"0\"],[181339,181340,\"0\"],[181418,181419,\"0\"],[181561,181599,\"0\"],[181686,181704,\"0\"],[181686,181689,\"0\"],[181705,181723,\"0\"],[181705,181708,\"0\"],[181855,182993,\"0\"],[181858,182993,\"0\"],[181858,181882,\"0\"],[182015,182036,\"0\"],[182041,182081,\"0\"],[182041,182049,\"0\"],[182051,182081,\"0\"],[182051,182060,\"0\"],[182062,182081,\"0\"],[182062,182069,\"0\"],[182071,182081,\"0\"],[182083,182148,\"0\"],[182083,182102,\"0\"],[182107,182147,\"0\"],[182107,182115,\"0\"],[182117,182147,\"0\"],[182117,182126,\"0\"],[182128,182147,\"0\"],[182128,182135,\"0\"],[182137,182147,\"0\"],[182149,182150,\"0\"],[182152,182220,\"0\"],[182221,182298,\"0\"],[182299,182415,\"0\"],[182418,182771,\"0\"],[182433,182471,\"0\"],[182433,182453,\"0\"],[182641,182717,\"0\"],[182772,182991,\"0\"],[182785,182990,\"0\"],[182796,182990,\"0\"],[182807,182990,\"0\"],[184522,184543,\"0\"],[185022,185067,\"0\"],[185022,185045,\"0\"],[185146,185175,\"0\"],[185705,185806,\"0\"],[185705,185723,\"0\"],[185725,185806,\"0\"],[185725,185747,\"0\"],[185781,185805,\"0\"],[186187,186241,\"0\"],[186244,186291,\"0\"],[186244,186269,\"0\"],[186575,186746,\"0\"],[186765,187120,\"0\"],[186789,186803,\"0\"],[186957,187119,\"0\"],[187235,187349,\"0\"],[187622,187826,\"0\"],[187622,187636,\"0\"],[187727,187772,\"0\"],[188036,188090,\"0\"],[188164,188165,\"0\"],[188240,188251,\"0\"],[188400,188423,\"0\"],[188400,188411,\"0\"],[188413,188423,\"0\"],[188426,188437,\"0\"],[188489,188527,\"0\"],[188652,188663,\"0\"],[188715,188857,\"0\"],[188728,188747,\"0\"],[188897,189016,\"0\"],[188911,189015,\"0\"],[189061,189080,\"0\"],[189106,189148,\"0\"],[189394,189433,\"0\"],[189461,189469,\"0\"],[189536,189544,\"0\"],[189612,189623,\"0\"],[189962,189983,\"0\"],[190071,190148,\"0\"],[190369,190416,\"0\"],[190369,190380,\"0\"],[190634,190645,\"0\"],[190782,190838,\"0\"],[190782,190796,\"0\"],[190839,190872,\"0\"],[190839,190850,\"0\"],[191085,191086,\"0\"],[191222,191282,\"0\"],[191228,191248,\"0\"],[191283,191337,\"0\"],[191283,191290,\"0\"],[191338,191373,\"0\"],[191501,191512,\"0\"],[191578,191589,\"0\"],[191775,191781,\"0\"],[191820,191886,\"0\"],[191820,191828,\"0\"],[192365,192384,\"0\"],[192394,192432,\"0\"]],null]]]\n[\"0/516\",\"b\",[[0,\"0/516\",null,[[861,889,\"0\"],[861,886,\"0\"],[890,938,\"0\"],[890,900,\"0\"],[950,953,\"0\"],[1259,1266,\"0\"],[1268,1459,\"0\"],[1268,1280,\"0\"],[1283,1291,\"0\"],[1292,1328,\"0\"],[1309,1328,\"0\"],[1364,1413,\"0\"],[1460,1489,\"0\"],[1693,1781,\"0\"],[1693,1725,\"0\"],[1693,1708,\"0\"],[1710,1725,\"0\"],[1784,1815,\"0\"],[2027,2058,\"0\"],[2156,2166,\"0\"],[2450,2530,\"0\"],[2450,2508,\"0\"],[2450,2453,\"0\"],[2455,2508,\"0\"],[2455,2460,\"0\"],[2463,2507,\"0\"],[2463,2486,\"0\"],[2488,2507,\"0\"],[2552,2558,\"0\"],[2755,2756,\"0\"],[2837,2876,\"0\"],[2879,2921,\"0\"],[2924,2958,\"0\"],[2961,3020,\"0\"],[3497,3534,\"0\"],[3612,3643,\"0\"],[3701,3718,\"0\"],[3803,3820,\"0\"],[3853,3863,\"0\"],[3853,3856,\"0\"],[3864,3874,\"0\"],[3864,3867,\"0\"],[3875,3913,\"0\"],[3875,3904,\"0\"],[4072,4082,\"0\"],[4106,4196,\"0\"],[4142,4152,\"0\"],[4179,4195,\"0\"],[4237,4284,\"0\"],[4418,4586,\"0\"],[4460,4461,\"0\"],[4484,4578,\"0\"],[4484,4514,\"0\"],[4516,4578,\"0\"],[4516,4539,\"0\"],[4544,4549,\"0\"],[4587,4709,\"0\"],[4587,4592,\"0\"],[4642,4708,\"0\"],[4642,4648,\"0\"],[4651,4676,\"0\"],[4794,4900,\"0\"],[4812,4900,\"0\"],[4927,4943,\"0\"],[5087,5105,\"0\"],[5212,5229,\"0\"],[5255,5279,\"0\"],[5287,5312,\"0\"],[5387,5397,\"0\"],[5520,5731,\"0\"],[5520,5531,\"0\"],[5533,5731,\"0\"],[5533,5556,\"0\"],[5559,5568,\"0\"],[5631,5730,\"0\"],[5631,5640,\"0\"],[5732,5794,\"0\"],[5982,6023,\"0\"],[6041,6057,\"0\"],[6248,6252,\"0\"],[6701,6717,\"0\"],[6779,6804,\"0\"],[6844,6854,\"0\"],[6942,6961,\"0\"],[6989,7008,\"0\"],[7039,7058,\"0\"],[7117,7148,\"0\"],[7117,7133,\"0\"],[7139,7142,\"0\"],[7295,7321,\"0\"],[7295,7313,\"0\"],[7445,7521,\"0\"],[7445,7468,\"0\"],[7584,7590,\"0\"],[7717,7767,\"0\"],[7717,7731,\"0\"],[7768,7823,\"0\"],[7768,7784,\"0\"],[7909,7925,\"0\"],[7944,7969,\"0\"],[8068,8096,\"0\"],[8138,8139,\"0\"],[8170,8527,\"0\"],[8170,8190,\"0\"],[8193,8221,\"0\"],[8223,8290,\"0\"],[8223,8228,\"0\"],[8248,8249,\"0\"],[8291,8371,\"0\"],[8291,8296,\"0\"],[8306,8307,\"0\"],[8374,8443,\"0\"],[8374,8379,\"0\"],[8399,8400,\"0\"],[8444,8525,\"0\"],[8444,8449,\"0\"],[8459,8460,\"0\"],[8590,8595,\"0\"],[8618,8649,\"0\"],[8695,8696,\"0\"],[8727,8835,\"0\"],[8727,8736,\"0\"],[8738,8835,\"0\"],[8738,8769,\"0\"],[8792,8793,\"0\"],[8836,8948,\"0\"],[8836,8845,\"0\"],[8847,8948,\"0\"],[8847,8878,\"0\"],[8901,8902,\"0\"],[8949,9056,\"0\"],[8949,8958,\"0\"],[8960,9056,\"0\"],[8960,8989,\"0\"],[9012,9013,\"0\"],[9057,9168,\"0\"],[9057,9066,\"0\"],[9068,9168,\"0\"],[9068,9097,\"0\"],[9120,9121,\"0\"],[9474,9610,\"0\"],[9493,9610,\"0\"],[9603,9609,\"0\"],[9649,9674,\"0\"],[10075,10095,\"0\"],[10186,10206,\"0\"],[10329,10456,\"0\"],[29216,29227,\"0\"],[29216,29220,\"0\"],[29222,29227,\"0\"],[29259,29336,\"0\"],[29561,29601,\"0\"],[29561,29573,\"0\"],[29767,29795,\"0\"],[29947,29951,\"0\"],[30082,30096,\"0\"],[30100,30151,\"0\"],[30123,30151,\"0\"],[30223,30260,\"0\"],[30285,30343,\"0\"],[30285,30326,\"0\"],[30344,30345,\"0\"],[30346,30461,\"0\"],[30368,30461,\"0\"],[30433,30456,\"0\"],[30433,30448,\"0\"],[30580,32193,\"0\"],[31233,31285,\"0\"],[31373,31434,\"0\"],[31373,31409,\"0\"],[31461,31498,\"0\"],[31499,31709,\"0\"],[31509,31709,\"0\"],[31509,31526,\"0\"],[31710,31836,\"0\"],[31722,31798,\"0\"],[31837,31957,\"0\"],[31848,31921,\"0\"],[31958,32086,\"0\"],[31958,31997,\"0\"],[31999,32086,\"0\"],[31999,32036,\"0\"],[32127,32190,\"0\"],[32225,32229,\"0\"],[32302,32347,\"0\"],[32348,32419,\"0\"],[32364,32419,\"0\"],[32661,32722,\"0\"],[32723,34048,\"0\"],[32745,32823,\"0\"],[32745,32784,\"0\"],[32786,32823,\"0\"],[32825,32910,\"0\"],[32989,33121,\"0\"],[32989,33040,\"0\"],[33122,33207,\"0\"],[33288,33423,\"0\"],[33288,33340,\"0\"],[33426,33479,\"0\"],[33547,33693,\"0\"],[33547,33623,\"0\"],[33694,33748,\"0\"],[33818,33966,\"0\"],[33818,33894,\"0\"],[33968,34047,\"0\"],[33968,33974,\"0\"],[33976,34047,\"0\"],[33992,34047,\"0\"],[34102,34197,\"0\"],[34102,34144,\"0\"],[34121,34144,\"0\"],[34198,34291,\"0\"],[34198,34240,\"0\"],[34217,34240,\"0\"],[34314,34318,\"0\"],[34326,34605,\"0\"],[34387,34605,\"0\"],[34390,34605,\"0\"],[34390,34424,\"0\"],[34426,34605,\"0\"],[34426,34449,\"0\"],[34451,34605,\"0\"],[34451,34497,\"0\"],[34468,34473,\"0\"],[34499,34605,\"0\"],[34536,34570,\"0\"],[34610,34708,\"0\"],[34625,34630,\"0\"],[34643,34708,\"0\"],[34643,34677,\"0\"],[34817,34860,\"0\"],[34970,34971,\"0\"],[34976,35068,\"0\"],[34976,35048,\"0\"],[34992,35048,\"0\"],[35134,35284,\"0\"],[35134,35144,\"0\"],[35137,35144,\"0\"],[35204,35248,\"0\"],[35285,35430,\"0\"],[35285,35295,\"0\"],[35288,35295,\"0\"],[35351,35395,\"0\"],[35474,35511,\"0\"],[35591,35604,\"0\"],[35658,35690,\"0\"],[35847,35851,\"0\"],[35922,36063,\"0\"],[36071,36477,\"0\"],[36090,36185,\"0\"],[36186,36280,\"0\"],[36186,36218,\"0\"],[36220,36280,\"0\"],[36233,36280,\"0\"],[36353,36374,\"0\"],[36535,36549,\"0\"],[36565,36570,\"0\"],[36599,36642,\"0\"],[36601,36613,\"0\"],[36620,36637,\"0\"],[36650,36680,\"0\"],[36682,36747,\"0\"],[36693,36709,\"0\"],[36716,36735,\"0\"],[36750,36785,\"0\"],[36761,36785,\"0\"],[36848,36862,\"0\"],[36878,36883,\"0\"],[36909,36952,\"0\"],[9,21,\"0\"],[28,45,\"0\"],[37198,37235,\"0\"],[37198,37221,\"0\"],[37223,37235,\"0\"],[37223,37228,\"0\"],[37230,37235,\"0\"],[37238,37275,\"0\"],[37238,37261,\"0\"],[37263,37275,\"0\"],[37263,37268,\"0\"],[37270,37275,\"0\"],[37278,37282,\"0\"],[37289,37489,\"0\"],[37289,37323,\"0\"],[37325,37489,\"0\"],[37365,37489,\"0\"],[37365,37399,\"0\"],[37401,37489,\"0\"],[37424,37437,\"0\"],[37458,37471,\"0\"],[37492,37636,\"0\"],[37492,37526,\"0\"],[37528,37636,\"0\"],[37528,37562,\"0\"],[37564,37636,\"0\"],[37564,37599,\"0\"],[37601,37636,\"0\"],[37683,37783,\"0\"],[37683,37706,\"0\"],[37786,37888,\"0\"],[37786,37809,\"0\"],[37891,37895,\"0\"],[37903,37904,\"0\"],[37978,37991,\"0\"],[37992,38015,\"0\"],[37992,38003,\"0\"],[38005,38015,\"0\"],[38020,38039,\"0\"],[38024,38034,\"0\"],[38340,38356,\"0\"],[38357,38387,\"0\"],[38357,38371,\"0\"],[38357,38368,\"0\"],[38373,38387,\"0\"],[38373,38383,\"0\"],[38388,38420,\"0\"],[38392,38419,\"0\"],[38392,38405,\"0\"],[38392,38402,\"0\"],[38407,38419,\"0\"],[38407,38415,\"0\"],[38518,38542,\"0\"],[38518,38523,\"0\"],[38526,38529,\"0\"],[38644,38671,\"0\"],[38644,38649,\"0\"],[38652,38655,\"0\"],[38829,38863,\"0\"],[38998,39002,\"0\"],[39020,39024,\"0\"],[39073,39077,\"0\"],[39117,39312,\"0\"],[39120,39312,\"0\"],[39120,39128,\"0\"],[39130,39312,\"0\"],[39159,39188,\"0\"],[39325,39357,\"0\"],[39560,39564,\"0\"],[39750,39760,\"0\"],[39768,39784,\"0\"],[39906,39938,\"0\"],[40073,40086,\"0\"],[40104,40108,\"0\"],[40157,40161,\"0\"],[40201,40392,\"0\"],[40204,40392,\"0\"],[40204,40212,\"0\"],[40214,40392,\"0\"],[40244,40273,\"0\"],[40410,40414,\"0\"],[40458,40461,\"0\"],[40612,40629,\"0\"],[40630,40691,\"0\"],[40630,40653,\"0\"],[40695,40771,\"0\"],[40714,40771,\"0\"],[40810,40814,\"0\"],[40835,41099,\"0\"],[40835,40925,\"0\"],[40835,40869,\"0\"],[40871,40925,\"0\"],[40894,40925,\"0\"],[40930,40959,\"0\"],[41007,41059,\"0\"],[41103,41161,\"0\"],[41247,41251,\"0\"],[41592,41599,\"0\"],[41618,42498,\"0\"],[41618,41667,\"0\"],[41633,41667,\"0\"],[41842,41871,\"0\"],[41910,41921,\"0\"],[42013,42021,\"0\"],[42062,42112,\"0\"],[42062,42071,\"0\"],[42113,42144,\"0\"],[42170,42495,\"0\"],[42170,42196,\"0\"],[42200,42348,\"0\"],[42258,42292,\"0\"],[42306,42343,\"0\"],[42349,42494,\"0\"],[42404,42439,\"0\"],[42453,42489,\"0\"],[42767,42793,\"0\"],[42910,42914,\"0\"],[42922,42984,\"0\"],[42988,43043,\"0\"],[43048,43105,\"0\"],[43145,43283,\"0\"],[43287,43288,\"0\"],[43292,43318,\"0\"],[43319,43458,\"0\"],[43483,43529,\"0\"],[43532,43533,\"0\"],[43549,43550,\"0\"],[43570,43571,\"0\"],[43610,43614,\"0\"],[43619,43888,\"0\"],[43684,43888,\"0\"],[43684,43727,\"0\"],[43768,43801,\"0\"],[43768,43793,\"0\"],[43802,43887,\"0\"],[43802,43820,\"0\"],[43873,43874,\"0\"],[43892,43921,\"0\"],[43932,43974,\"0\"],[43982,44123,\"0\"],[44131,44132,\"0\"],[44576,44598,\"0\"],[44655,44673,\"0\"],[44723,44836,\"0\"],[44751,44772,\"0\"],[44837,45070,\"0\"],[44837,44863,\"0\"],[45072,45144,\"0\"],[45072,45108,\"0\"],[45146,45221,\"0\"],[45146,45183,\"0\"],[45261,45297,\"0\"],[45261,45279,\"0\"],[45298,45341,\"0\"],[45331,45339,\"0\"],[45342,45403,\"0\"],[45404,45496,\"0\"],[45497,45554,\"0\"],[45555,45648,\"0\"],[45762,45966,\"0\"],[45762,45791,\"0\"],[45793,45966,\"0\"],[45793,45832,\"0\"],[45834,45966,\"0\"],[45968,46125,\"0\"],[45968,46104,\"0\"],[45968,46012,\"0\"],[46014,46104,\"0\"],[46044,46104,\"0\"],[46044,46095,\"0\"],[46138,46193,\"0\"],[46208,46264,\"0\"],[46301,46330,\"0\"],[46384,46422,\"0\"],[46467,46506,\"0\"],[46606,46656,\"0\"],[46661,46710,\"0\"],[46890,46957,\"0\"],[46890,46914,\"0\"],[46959,47458,\"0\"],[46959,47011,\"0\"],[46959,46985,\"0\"],[46987,47011,\"0\"],[47135,47161,\"0\"],[47252,47278,\"0\"],[47279,47340,\"0\"],[47341,47408,\"0\"],[47459,48235,\"0\"],[47562,47593,\"0\"],[47620,47663,\"0\"],[47666,47705,\"0\"],[47732,47774,\"0\"],[47777,47815,\"0\"],[47842,47843,\"0\"],[47898,47941,\"0\"],[47944,47984,\"0\"],[48040,48041,\"0\"],[48099,48141,\"0\"],[48144,48185,\"0\"],[48270,48290,\"0\"],[48297,48311,\"0\"],[48319,48444,\"0\"],[48319,48348,\"0\"],[48350,48444,\"0\"],[48350,48389,\"0\"],[48587,48613,\"0\"],[48645,48646,\"0\"],[48733,48759,\"0\"],[48792,48793,\"0\"],[48891,49016,\"0\"],[48891,48920,\"0\"],[48922,49016,\"0\"],[48922,48961,\"0\"],[49095,49260,\"0\"],[49095,49124,\"0\"],[49126,49260,\"0\"],[49126,49210,\"0\"],[49126,49165,\"0\"],[49167,49210,\"0\"],[49261,50228,\"0\"],[49285,49696,\"0\"],[49304,49402,\"0\"],[49304,49354,\"0\"],[49403,49499,\"0\"],[49403,49452,\"0\"],[49500,49598,\"0\"],[49500,49550,\"0\"],[49599,49695,\"0\"],[49599,49648,\"0\"],[49697,50227,\"0\"],[49784,49800,\"0\"],[49801,49891,\"0\"],[49801,49845,\"0\"],[49847,49891,\"0\"],[49894,49938,\"0\"],[50043,50059,\"0\"],[50060,50152,\"0\"],[50060,50105,\"0\"],[50107,50152,\"0\"],[50155,50200,\"0\"],[50307,50333,\"0\"],[50365,50366,\"0\"],[50465,50491,\"0\"],[50524,50525,\"0\"],[50575,50576,\"0\"],[50669,50692,\"0\"],[50739,50751,\"0\"]],null]]]\n[\"6/337\",\"b\",[[0,\"6/337\",null,[[237,280,\"0\"],[237,253,\"0\"],[22323,22478,\"0\"],[22323,22418,\"0\"],[22343,22418,\"0\"],[22442,22478,\"0\"],[22506,22590,\"0\"],[22506,22564,\"0\"],[22506,22533,\"0\"],[22535,22564,\"0\"],[22719,22819,\"0\"],[22737,22819,\"0\"],[22820,23038,\"0\"],[22820,22972,\"0\"],[22839,22972,\"0\"],[22839,22916,\"0\"],[22918,22972,\"0\"],[22974,23038,\"0\"],[23039,23370,\"0\"],[23039,23067,\"0\"],[23070,23369,\"0\"],[23398,23402,\"0\"],[23407,23457,\"0\"],[23458,23581,\"0\"],[23585,24095,\"0\"],[23585,23695,\"0\"],[23696,23763,\"0\"],[23764,23859,\"0\"],[23809,23836,\"0\"],[23877,23924,\"0\"],[23989,24092,\"0\"],[24016,24074,\"0\"],[24016,24052,\"0\"],[24105,24115,\"0\"],[24251,24320,\"0\"],[24251,24305,\"0\"],[24348,24355,\"0\"],[24391,24411,\"0\"],[24515,24516,\"0\"],[53720,53773,\"0\"],[53720,53737,\"0\"],[54045,54072,\"0\"],[54045,54052,\"0\"],[54054,54072,\"0\"],[54107,54159,\"0\"],[54123,54159,\"0\"],[54250,54265,\"0\"],[54328,54389,\"0\"],[54328,54362,\"0\"],[54427,54428,\"0\"],[54433,54464,\"0\"],[54486,54489,\"0\"],[54596,54624,\"0\"],[54714,54772,\"0\"],[54773,54864,\"0\"],[54873,54931,\"0\"],[55077,55168,\"0\"],[55077,55096,\"0\"],[55116,55160,\"0\"],[55258,55304,\"0\"],[55305,55324,\"0\"],[55347,55391,\"0\"],[56217,56351,\"0\"],[56217,56276,\"0\"],[56278,56351,\"0\"],[56278,56303,\"0\"],[56660,56704,\"0\"],[56675,56704,\"0\"],[56706,56736,\"0\"],[56788,56794,\"0\"],[56940,56955,\"0\"],[56956,56986,\"0\"],[57042,57128,\"0\"],[57042,57095,\"0\"],[57057,57095,\"0\"],[57191,57192,\"0\"],[57193,57208,\"0\"],[57330,57406,\"0\"],[57330,57353,\"0\"],[57469,57475,\"0\"],[57549,57561,\"0\"],[57655,57676,\"0\"],[57655,57667,\"0\"],[57669,57676,\"0\"],[57683,57704,\"0\"],[57683,57695,\"0\"],[57697,57704,\"0\"],[57732,57864,\"0\"],[57907,57940,\"0\"],[57907,57920,\"0\"],[57922,57940,\"0\"],[58038,58111,\"0\"],[58038,58070,\"0\"],[58038,58047,\"0\"],[58049,58070,\"0\"],[58049,58058,\"0\"],[58060,58070,\"0\"],[58073,58111,\"0\"],[58073,58091,\"0\"],[58112,58361,\"0\"],[58112,58123,\"0\"],[58551,58552,\"0\"],[58642,58768,\"0\"],[58642,58660,\"0\"],[58662,58768,\"0\"],[58662,58668,\"0\"],[58673,58716,\"0\"],[58769,58785,\"0\"],[58769,58775,\"0\"],[58820,58826,\"0\"],[58830,58873,\"0\"],[59145,59153,\"0\"],[59157,59181,\"0\"],[59210,59294,\"0\"],[59210,59216,\"0\"],[59236,59293,\"0\"],[59236,59244,\"0\"],[59247,59274,\"0\"],[59560,59626,\"0\"],[59588,59592,\"0\"],[59693,59760,\"0\"],[59721,59725,\"0\"],[59797,59801,\"0\"],[59863,59867,\"0\"],[59915,59931,\"0\"],[60045,60332,\"1/2\"],[60046,60065,\"1/2\"],[60873,60895,\"0\"],[60873,60888,\"0\"],[60890,60895,\"0\"],[61128,61281,\"0\"],[61294,61339,\"0\"],[61294,61318,\"0\"],[61387,61426,\"0\"],[61543,61755,\"0\"],[61543,61551,\"0\"],[61554,61641,\"0\"],[61593,61623,\"0\"],[61642,61708,\"0\"],[61642,61650,\"0\"],[61710,61754,\"0\"],[61710,61726,\"0\"],[61710,61718,\"0\"],[61720,61726,\"0\"],[61732,61748,\"0\"],[61756,61819,\"0\"],[61756,61762,\"0\"],[61800,61818,\"0\"],[61800,61806,\"0\"],[61811,61812,\"0\"],[61820,61858,\"0\"],[61824,61858,\"0\"],[61824,61830,\"0\"],[61832,61858,\"0\"],[61922,61940,\"0\"],[62098,62121,\"0\"],[62542,62565,\"0\"],[62669,62845,\"0\"],[62680,62845,\"0\"],[62874,62987,\"0\"],[62898,62987,\"0\"],[62909,62987,\"0\"],[62909,62936,\"0\"],[63023,63065,\"0\"],[63033,63065,\"0\"],[63132,63176,\"0\"],[63142,63176,\"0\"],[63298,63308,\"0\"],[63298,63301,\"0\"],[63309,63319,\"0\"],[63309,63312,\"0\"],[63351,63392,\"0\"],[63396,63397,\"0\"],[63543,63555,\"0\"],[63601,63716,\"0\"],[63601,63618,\"0\"],[63669,63715,\"0\"],[63672,63715,\"0\"],[63672,63696,\"0\"],[63717,63750,\"0\"],[63717,63728,\"0\"],[63751,63795,\"0\"],[63751,63768,\"0\"],[63842,63843,\"0\"],[64083,64117,\"0\"],[64083,64096,\"0\"],[64118,64181,\"0\"],[64182,64218,\"0\"],[64731,64749,\"0\"],[65291,65343,\"0\"],[65406,65465,\"0\"],[65889,65902,\"0\"],[65975,66022,\"0\"],[66051,66122,\"0\"],[66096,66120,\"0\"],[66153,66189,\"0\"],[66190,66226,\"0\"],[66405,66498,\"0\"],[66527,66574,\"0\"],[66705,66773,\"0\"],[66720,66728,\"0\"],[66729,66767,\"0\"],[66729,66759,\"0\"],[66778,66792,\"0\"],[66804,66831,\"0\"],[66804,66810,\"0\"],[66812,66831,\"0\"],[67079,67085,\"0\"],[67122,67181,\"0\"],[67536,67546,\"0\"],[67697,67748,\"0\"],[67697,67711,\"0\"],[67713,67748,\"0\"],[67713,67724,\"0\"],[67960,68059,\"0\"],[67960,67970,\"0\"],[68161,68172,\"0\"],[68175,68191,\"0\"],[68194,68195,\"0\"],[68226,68234,\"0\"],[68293,68294,\"0\"],[68332,68692,\"0\"],[68365,68692,\"0\"],[68396,68692,\"0\"],[68410,68692,\"0\"],[68410,68427,\"0\"],[68429,68692,\"0\"],[68429,68469,\"0\"],[68481,68482,\"0\"],[68517,68518,\"0\"],[68525,68551,\"0\"],[68552,68631,\"0\"],[68562,68631,\"0\"],[68632,68673,\"0\"],[68853,68877,\"0\"],[68974,69001,\"0\"],[69310,69328,\"0\"],[69358,69453,\"0\"],[69403,69431,\"0\"],[69609,69759,\"0\"],[69609,69630,\"0\"],[69633,69658,\"0\"],[69792,69798,\"0\"],[69848,69951,\"0\"],[69848,69866,\"0\"],[69975,70001,\"0\"],[70160,70190,\"0\"],[70396,70397,\"0\"],[70511,70577,\"0\"],[70511,70547,\"0\"],[70626,70809,\"0\"],[70626,70632,\"0\"],[70635,70645,\"0\"],[70674,70686,\"0\"],[70714,70719,\"0\"],[70766,70771,\"0\"],[70862,70868,\"0\"],[70873,70883,\"0\"],[70897,70936,\"0\"],[70917,70936,\"0\"],[70946,70958,\"0\"],[71170,71203,\"0\"],[71170,71181,\"0\"],[71266,71289,\"0\"],[71266,71275,\"0\"],[71291,71617,\"0\"],[71356,71616,\"0\"],[71366,71616,\"0\"],[71366,71398,\"0\"],[71924,72064,\"1/2\"],[71925,71944,\"1/2\"],[72205,72230,\"0\"],[72449,72479,\"0\"],[72640,72670,\"0\"],[72702,72723,\"0\"],[72702,72708,\"0\"],[72776,72777,\"0\"],[73214,73293,\"0\"],[73214,73227,\"0\"],[73331,73332,\"0\"],[73631,73646,\"0\"],[73790,73860,\"0\"],[73922,73923,\"0\"],[73975,74428,\"0\"],[73985,74428,\"0\"],[74012,74071,\"0\"],[74101,74369,\"0\"],[74104,74369,\"0\"],[74104,74124,\"0\"],[74170,74260,\"0\"],[74261,74367,\"0\"],[74497,74498,\"0\"],[74517,74557,\"0\"],[74517,74535,\"0\"],[74537,74557,\"0\"],[74623,74683,\"0\"],[74651,74664,\"0\"],[74688,74708,\"0\"],[74761,74791,\"0\"],[74844,74845,\"0\"],[74850,74865,\"0\"],[74926,75009,\"0\"],[74944,74967,\"0\"],[74947,74967,\"0\"],[75338,75382,\"0\"],[75358,75382,\"0\"],[75520,75688,\"0\"],[75540,75688,\"0\"],[75559,75688,\"0\"],[75627,75671,\"0\"],[75792,75823,\"0\"],[75883,75972,\"0\"],[75883,75905,\"0\"],[75973,76020,\"0\"],[75973,75996,\"0\"],[75976,75996,\"0\"],[76021,76064,\"0\"],[76021,76042,\"0\"],[76024,76042,\"0\"],[76118,76119,\"0\"],[76173,76174,\"0\"],[76188,76789,\"0\"],[76232,76320,\"0\"],[76260,76320,\"0\"],[76514,76559,\"0\"],[76514,76536,\"0\"],[76597,76713,\"0\"],[76600,76713,\"0\"],[76600,76621,\"0\"],[76764,76788,\"0\"],[77184,77185,\"0\"],[77349,77384,\"0\"],[77550,77672,\"0\"],[77579,77638,\"0\"],[77703,77926,\"1/2\"],[77704,77723,\"1/2\"],[145,217,\"0\"]],null]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[138,148,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[133,144,\"0\"]],null]]]\n[0,null,[[0,0]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[7,49,\"1/2\"],[7,35,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[129,158,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"7/11\",\"b\",[[0,\"7/11\",null,[[77,188,\"1/2\"],[77,109,\"1/2\"],[77,91,\"1/2\"],[95,109,\"0\"],[115,188,\"1/2\"],[115,167,\"1/2\"],[115,130,\"1/2\"],[134,167,\"0\"],[134,149,\"0\"],[153,167,\"0\"],[173,187,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,39,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[146,153,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[53,74,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[13,33,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[54,104,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[243,259,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[286,287,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[317,332,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[443,465,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[666,714,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[79,84,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[145,170,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[71,86,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[160,182,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,35,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[84,106,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[207,221,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[100,114,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[58,88,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,35,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[257,290,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[509,516,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[93,128,\"0\"],[93,118,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[140,145,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,236,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[268,274,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[90,154,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[197,220,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[277,370,\"0\"]],null]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[376,597,\"0\"],[376,405,\"0\"],[409,597,\"0\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,49,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[5,31,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[37,82,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[88,219,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[80,100,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[69,194,\"0\"],[69,109,\"0\"],[69,88,\"0\"],[92,109,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[203,233,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[265,284,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[128,169,\"0\"],[128,158,\"0\"],[162,169,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[187,199,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[305,323,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[376,393,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[5,30,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[40,105,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[76,123,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[178,192,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[246,270,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[504,535,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[574,599,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[609,632,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[152,203,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[973,1028,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1722,1747,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[141,176,\"0\"],[56,81,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[198,223,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[260,265,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[341,366,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[400,406,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[439,455,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[60,154,\"0\"],[42,106,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[181,210,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[337,347,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[418,428,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[317,336,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[472,491,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[63,71,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,59,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[107,183,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[193,218,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[59,99,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[431,462,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[480,505,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[42,90,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1125,1146,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,60,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[165,196,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[750,771,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,38,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[31,137,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[141,176,\"0\"],[56,81,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[189,240,\"0\"],[198,240,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[275,280,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[356,381,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[36,62,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[194,198,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[55,64,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[54,134,\"0\"],[66,134,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[244,267,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[280,305,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,27,\"0\"],[14,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[139,164,\"0\"]],null]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[184,286,\"0\"],[189,286,\"0\"],[189,206,\"0\"],[210,286,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[377,399,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[34,66,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[183,192,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,95,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[463,485,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,83,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[174,206,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[305,360,\"0\"],[305,331,\"0\"],[318,331,\"0\"],[335,360,\"0\"],[347,360,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,24,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[580,594,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[652,678,\"0\"],[652,665,\"0\"],[669,678,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[726,768,\"0\"],[726,739,\"0\"],[743,768,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[802,809,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,87,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[154,179,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[384,403,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,24,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[135,173,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,42,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,78,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[90,120,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[306,316,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[527,537,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,10,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[104,143,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[433,487,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,130,\"0\"],[13,34,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,46,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[9,37,\"0\"],[9,22,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[193,206,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[5,46,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,69,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[93,130,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[23,54,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[123,151,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[-1,32,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[551,560,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[656,665,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[681,690,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[999,1030,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,61,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[157,211,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1432,1440,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[30,99,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[53,69,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[327,370,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,65,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[99,137,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[146,168,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[135,170,\"0\"],[53,78,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[183,188,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[258,283,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[330,342,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[84,157,\"0\"],[42,84,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[194,218,\"0\"],[251,274,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[317,330,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,55,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[152,325,\"1/2\"],[179,214,\"1/2\"],[268,323,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[401,475,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[700,703,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[44,62,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[182,201,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[24,42,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[93,111,\"1\"]],null]]]\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1276,1297,\"1/2\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[88,137,\"1/2\"],[105,137,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[66,190,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,33,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,47,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[116,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[277,299,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,41,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[387,439,\"0\"],[416,439,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[560,607,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,34,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,28,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,47,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[116,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[277,299,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,42,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[389,409,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[488,535,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,35,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[54,87,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[138,170,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[389,411,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[458,501,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[702,711,\"0\"],[725,766,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[924,946,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1224,1233,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[118,189,\"0\"],[118,139,\"0\"],[143,189,\"0\"]],null]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[145,168,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2265,2315,\"0\"]],null]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[349,365,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[391,408,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[1006,1050,\"0\"],[1006,1024,\"0\"],[1028,1050,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1216,1226,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1377,1387,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1497,1507,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[93,110,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,34,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[60,73,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[278,300,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[468,490,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[532,577,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[36,105,\"1/2\"],[56,105,\"0\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[38,60,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[18,43,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[91,111,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[168,188,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[448,500,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[663,669,\"0\"],[695,700,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[759,765,\"0\"],[768,829,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[875,881,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,33,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[45,60,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[102,161,\"0\"],[102,156,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[233,261,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[192,230,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[327,396,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[135,175,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[276,318,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[81,184,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,19,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[54,75,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[27,75,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,30,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[67,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[269,270,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[58,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[7,32,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[82,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[94,129,\"1/2\"],[94,119,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[141,177,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[196,201,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[275,300,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[193,206,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[133,150,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[585,620,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,48,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[66,191,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[27,67,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[94,129,\"0\"],[94,119,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[141,177,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[196,201,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[275,300,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[70,98,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[219,244,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,126,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,235,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[49,87,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[108,135,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[153,289,\"0\"],[153,280,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[345,378,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[418,440,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[14,83,\"0\"],[14,63,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[542,580,\"0\"],[558,580,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[677,680,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[7,177,\"0\"],[7,33,\"0\"]],null]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[262,300,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[96,131,\"0\"],[96,121,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[143,148,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[226,251,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[141,150,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[54,104,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[290,327,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[624,688,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[71,201,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[345,355,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[516,555,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[753,775,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1011,1039,\"0\"]],null]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[77,82,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[139,164,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[149,191,\"0\"],[149,166,\"0\"],[170,191,\"0\"],[200,221,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[247,271,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[11,28,\"0\"],[38,77,\"0\"],[38,76,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[111,167,\"0\"],[111,166,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[493,513,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[539,551,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[604,616,\"0\"]],null]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[650,693,\"0\"],[650,667,\"0\"],[671,693,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[716,801,\"0\"],[716,736,\"0\"],[741,800,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,38,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[298,323,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[375,405,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[459,492,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[625,646,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,29,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[94,99,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,240,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[296,313,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1164,1181,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[92,127,\"0\"],[92,117,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[139,144,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[214,239,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[76,93,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[105,130,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[181,206,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n",
+ "\n{\"present_sessions\":[0]}\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[19,38,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[171,212,\"0\"],[171,193,\"0\"],[197,212,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[278,294,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[485,500,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[629,644,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[101,118,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[44,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n[15,null,[[0,15]]]\n[\"1\",\"b\",[[0,\"1\",null,[[63,94,\"1\"]],null]]]\n[3,null,[[0,3]]]\n\n\n[5,null,[[0,5]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[46,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[272,285,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,73,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[43,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[239,312,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[400,467,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[31,52,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[256,281,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[301,327,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[352,383,\"1/2\"]],null]]]\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[402,469,\"1/2\"],[402,430,\"1/2\"],[434,469,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/6\",\"b\",[[0,\"0/6\",null,[[12,105,\"0\"],[19,105,\"0\"],[19,47,\"0\"],[51,105,\"0\"],[51,77,\"0\"],[81,105,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,43,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[23,39,\"1\"]],null]]]\n[15,null,[[0,15]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[68,98,\"1/2\"]],null]]]\n[15,null,[[0,15]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[152,173,\"1/2\"]],null]]]\n[15,null,[[0,15]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,21,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[86,97,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,27,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[49,63,\"1/2\"]],null]]]\n\n[4,null,[[0,4]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1896,1957,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[64,81,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,29,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[8,null,[[0,8]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[105,115,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,30,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,58,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[22,null,[[0,22]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,29,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[10,31,\"1\"]],null]]]\n[4,null,[[0,4]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[74,120,\"1/2\"],[76,119,\"1/2\"],[89,119,\"1/2\"]],null]]]\n[28,null,[[0,28]]]\n\n\n\n\n[22,null,[[0,22]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[8,34,\"1\"]],null]]]\n[25,null,[[0,25]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[114,126,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[52,null,[[0,52]]]\n\n\n[52,null,[[0,52]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[23,33,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n[52,null,[[0,52]]]\n\n[52,null,[[0,52]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[573,642,\"0\"],[573,599,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,35,\"0\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[193,265,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[140,205,\"0\"],[140,164,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[246,311,\"0\"],[246,270,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[355,362,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[139,170,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[322,359,\"0\"],[332,359,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[397,434,\"0\"],[407,434,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/7\",\"b\",[[0,\"0/7\",null,[[581,677,\"0\"],[581,627,\"0\"],[581,596,\"0\"],[600,627,\"0\"],[631,677,\"0\"],[631,646,\"0\"],[650,677,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,29,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,123,\"0\"],[13,34,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[61,144,\"0\"],[61,78,\"0\"],[82,144,\"0\"],[82,111,\"0\"],[115,144,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[80,96,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[269,276,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,57,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,39,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[60,80,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[93,115,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,52,\"0\"],[13,26,\"0\"],[30,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[387,419,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[54,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[150,158,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[54,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[12,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[172,180,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[153,188,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[39,57,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,39,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[60,80,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[93,115,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,52,\"0\"],[13,26,\"0\"],[30,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[379,411,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1185,1241,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[42,89,\"0\"],[42,64,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,85,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[164,328,\"0\"],[164,190,\"0\"],[164,171,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[11675,11720,\"1/2\"],[11685,11720,\"1/2\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[11794,11841,\"1/2\"],[11804,11841,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[101,109,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[165,169,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[62,105,\"0\"],[79,105,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[172,175,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[14,25,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[129,143,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[230,257,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[314,343,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[387,412,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[538,561,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[26,38,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[80,106,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[153,170,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[214,229,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[271,290,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[336,425,\"0\"],[336,365,\"0\"],[369,425,\"0\"],[369,395,\"0\"],[399,425,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[469,487,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[533,545,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[587,599,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[642,689,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[14,40,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,24,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,23,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[14,23,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,25,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[94,103,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[213,223,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[20,26,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[20,26,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[149,153,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[126,169,\"0\"],[143,169,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[53,89,\"0\"],[67,89,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[641,656,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[252,257,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[40,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[222,246,\"0\"],[229,246,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[751,795,\"0\"],[751,771,\"0\"],[775,795,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[53,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[126,129,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[7,null,[[0,7]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,45,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[8,null,[[0,8]]]\n\n\n[7,null,[[0,7]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[528,533,\"1\"]],null]]]\n[7,null,[[0,7]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[91,140,\"1/2\"]],null]]]\n[7,null,[[0,7]]]\n\n\n[7,null,[[0,7]]]\n\n\n\n[1,null,[[0,1]]]\n[21,null,[[0,21]]]\n\n[21,null,[[0,21]]]\n[\"1\",\"b\",[[0,\"1\",null,[[101,106,\"1\"]],null]]]\n[25,null,[[0,25]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[20686,20715,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13,33,\"1/2\"]],null]]]\n\n[5,null,[[0,5]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[83,95,\"1/2\"]],null]]]\n[5,null,[[0,5]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[42,67,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[105,113,\"1\"]],null]]]\n[4,null,[[0,4]]]\n[\"1\",\"b\",[[0,\"1\",null,[[42,58,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11,44,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[8,null,[[0,8]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[182,196,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[173,185,\"1/2\"]],null]]]\n[5,null,[[0,5]]]\n[5,null,[[0,5]]]\n\n[5,null,[[0,5]]]\n\n\n\n[5,null,[[0,5]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[309,321,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,52,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[581,594,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[4,null,[[0,4]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[62,93,\"1\"],[72,93,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[4,null,[[0,4]]]\n\n\n[1,null,[[0,1]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[165,171,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[4,null,[[0,4]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[437,459,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[914,931,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[90,107,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[100,114,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[272,287,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,31,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,35,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[153,228,\"0\"],[153,173,\"0\"],[177,228,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[291,308,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[75,97,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[16,null,[[0,16]]]\n\n[16,null,[[0,16]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,29,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[96,180,\"1/2\"],[122,180,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[395,405,\"1/2\"]],null]]]\n[16,null,[[0,16]]]\n\n[16,null,[[0,16]]]\n\n\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[36,83,\"1/2\"],[61,83,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[220,286,\"1/2\"],[220,242,\"1/2\"],[246,286,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[431,480,\"1/2\"],[431,453,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[974,1000,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,36,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[4,null,[[0,4]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1198,1205,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,13,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[4,null,[[0,4]]]\n\n\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[1795,1836,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,33,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[76,89,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[563,660,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[743,778,\"1/2\"]],null]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n\n[9,null,[[0,9]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[434,452,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[49,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[9,null,[[0,9]]]\n\n[9,null,[[0,9]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[9,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[382,388,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[77,92,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[36,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[41,47,\"0\"],[125,156,\"0\"],[125,147,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n\n[4,null,[[0,4]]]\n[\"0\",\"b\",[[0,\"0\",null,[[27,42,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[92,117,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[157,180,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[4,null,[[0,4]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,137,\"0\"],[13,63,\"0\"],[67,137,\"0\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[807,847,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[884,965,\"1/2\"],[903,965,\"1/2\"],[903,929,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"3/4\",\"b\",[[0,\"3/4\",null,[[1004,1088,\"1/2\"],[1021,1088,\"1/2\"],[1021,1045,\"1/2\"],[1049,1088,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1127,1171,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1209,1216,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1256,1267,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[95,112,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[94,118,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[128,136,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[242,273,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,23,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[6,null,[[0,6]]]\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[121,128,\"1\"]],null]]]\n[272,null,[[0,272]]]\n[272,null,[[0,272]]]\n\n\n\n\n[6,null,[[0,6]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[447,461,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[6,null,[[0,6]]]\n\n\n[1,null,[[0,1]]]\n[44,null,[[0,44]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[48,80,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,27,\"1\"]],null]]]\n[36,null,[[0,36]]]\n\n[20,null,[[0,20]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[181,189,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[263,267,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,21,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[24,null,[[0,24]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[612,653,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[125,163,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[140,150,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[35,51,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,36,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,112,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[145,166,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[308,333,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,15,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[8,15,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[25,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[134,174,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,17,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[56,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[259,277,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[367,385,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[616,634,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,23,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[63,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[172,186,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[33,61,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[100,138,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,17,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[185,204,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[189,211,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[9,null,[[0,9]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[557,569,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[678,739,\"1/2\"],[678,708,\"1/2\"],[712,739,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[934,961,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[9,null,[[0,9]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,18,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,23,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[10,18,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,24,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[60,78,\"1/2\"]],null]]]\n\n[9,null,[[0,9]]]\n\n\n\n\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[60,78,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[202,235,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[727,733,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[107,116,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[216,241,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[380,437,\"0\"],[380,407,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[520,545,\"0\"]],null]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[17,127,\"0\"],[59,127,\"0\"],[59,88,\"0\"],[92,127,\"0\"]],null]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[780,845,\"0\"],[780,807,\"0\"],[811,845,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[54,101,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,26,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[80,93,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[136,151,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[206,210,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[248,255,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,25,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[116,152,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[304,361,\"1/2\"],[304,333,\"1/2\"],[337,361,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[572,613,\"1/2\"],[572,602,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[657,699,\"1/2\"],[657,685,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,25,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[30,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,230,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,24,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[11,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[50229,50285,\"1/2\"],[50249,50285,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[83,105,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[2,null,[[0,2]]]\n\n[2,null,[[0,2]]]\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[484,521,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[172,208,\"0\"]],null]]]\n\n\n[2,null,[[0,2]]]\n\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[25,50,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[115,136,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n\n\n\n\n\n\n\n\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[401,413,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[64,79,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[42,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[149,186,\"0\"],[177,186,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[289,305,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[435,474,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[582,600,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[230,241,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[41,57,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[15,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[104,122,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[103,118,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[140,209,\"1/2\"],[140,169,\"1/2\"],[173,209,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[371,376,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[87,104,\"1/2\"]],null]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[122,161,\"1\"]],null]]]\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[9,91,\"1/2\"],[32,91,\"1/2\"],[32,54,\"1/2\"],[58,91,\"1/2\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[10,24,\"1\"]],null]]]\n\n\n[16,null,[[0,16]]]\n\n[24,null,[[0,24]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[519,589,\"1/2\"],[527,589,\"1/2\"],[547,589,\"1/2\"],[571,589,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[407,440,\"1/2\"],[418,440,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[34,40,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[37,69,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,44,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[8,null,[[0,8]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[9,73,\"1/2\"],[9,38,\"1/2\"],[42,73,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[55,85,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[177,184,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9,31,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[123,130,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[70,118,\"1/2\"],[70,99,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[127,136,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[313,337,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[375,397,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[672,695,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[849,863,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[89,109,\"1\"]],null]]]\n\n\n[3,null,[[0,3]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[131,157,\"1/2\"]],null]]]\n[3,null,[[0,3]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[204,238,\"1/2\"]],null]]]\n[3,null,[[0,3]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[176,186,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,19,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[164,228,\"0\"],[164,182,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[240,257,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8,10,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[38,null,[[0,38]]]\n\n\n[38,null,[[0,38]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[72,121,\"1/2\"],[72,101,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[22,null,[[0,22]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n[\"1\",\"b\",[[0,\"1\",null,[[38,41,\"1\"]],null]]]\n[3,null,[[0,3]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[13,71,\"1\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[9,30,\"1\"]],null]]]\n[\"1\",\"b\",[[0,\"1\",null,[[25,39,\"1\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[9,71,\"1\"],[18,71,\"1/2\"],[18,46,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[2,null,[[0,2]]]\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[99,133,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[239,269,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[12,74,\"1\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[63,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[32,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[187,207,\"1\"]],null]]]\n\n\n[3,null,[[0,3]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[122,145,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[3,null,[[0,3]]]\n[3,null,[[0,3]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[306,349,\"1/2\"],[320,349,\"0\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[188,197,\"1/2\"],[250,264,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[258,286,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[107,138,\"0\"],[142,185,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,38,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[70,90,\"0\"],[94,137,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[883,911,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[157,181,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[14,67,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[14,27,\"0\"],[30,56,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[479,542,\"0\"],[479,506,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[69,83,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[126,131,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[38,52,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[5,null,[[0,5]]]\n\n\n\n\n\n[5,null,[[0,5]]]\n\n[5,null,[[0,5]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[58,111,\"1/2\"],[86,111,\"1/2\"]],null]]]\n\n\n[4,null,[[0,4]]]\n[\"1\",\"b\",[[0,\"1\",null,[[139,158,\"1\"]],null]]]\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[59,79,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n[5,null,[[0,5]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,222,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[329,348,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[65,90,\"1\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[10,39,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,73,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[122,129,\"1/2\"],[229,236,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[995,1017,\"1/2\"],[1069,1091,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[69,101,\"0\"],[69,85,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[12,43,\"0\"],[12,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[146,166,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[38,108,\"0\"],[53,108,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[277,321,\"1/2\"],[297,321,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[438,460,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[40,44,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[128,172,\"0\"],[128,152,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[274,319,\"1/2\"],[283,319,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[331,343,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[937,942,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[990,1052,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1127,1189,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[60,67,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[47,53,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[47,53,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[104,110,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[173,178,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[618,626,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[47,76,\"1/2\"],[58,76,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[53,62,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[39,44,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[75,80,\"1\"]],null]]]\n[2,null,[[0,2]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[76,81,\"1\"]],null]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[121,127,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"1\",\"b\",[[0,\"1\",null,[[90,116,\"1\"]],null]]]\n[2,null,[[0,2]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[47,61,\"1/2\"]],null]]]\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[455,481,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[495,530,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[96,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[43,96,\"0\"],[43,64,\"0\"],[68,96,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[1056,1114,\"1/2\"],[1067,1113,\"1/2\"],[1083,1113,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[25,39,\"1/2\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1203,1214,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1287,1292,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1365,1403,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[162,176,\"1/2\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1726,1761,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[33,39,\"1/2\"]],null]]]\n[8,null,[[0,8]]]\n\n\n[8,null,[[0,8]]]\n\n[8,null,[[0,8]]]\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[102,111,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[329,336,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[12,59,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[88,91,\"1/2\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[9,null,[[0,9]]]\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[201,210,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[248,315,\"1/2\"],[280,294,\"0\"]],null]]]\n[9,null,[[0,9]]]\n[9,null,[[0,9]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[693,744,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[350,369,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[186,252,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[63,143,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[489,497,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[994,1081,\"0\"],[994,1035,\"0\"],[1039,1081,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1649,1663,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2669,2702,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[9,null,[[0,9]]]\n\n[9,null,[[0,9]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2988,3002,\"1/2\"]],null]]]\n[9,null,[[0,9]]]\n[9,null,[[0,9]]]\n\n\n[1,null,[[0,1]]]\n[4,null,[[0,4]]]\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[200,206,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[409,418,\"1/2\"]],null]]]\n\n\n[4,null,[[0,4]]]\n[\"0\",\"b\",[[0,\"0\",null,[[520,535,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,23,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[4,null,[[0,4]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[853,856,\"1/2\"]],null]]]\n\n[4,null,[[0,4]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1056,1071,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[4,null,[[0,4]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[135,138,\"1/2\"]],null]]]\n\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1710,1724,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[4,null,[[0,4]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[177,186,\"1/2\"]],null]]]\n[4,null,[[0,4]]]\n\n[4,null,[[0,4]]]\n[0,null,[[0,0]]]\n\n[4,null,[[0,4]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"1/3\",\"b\",[[0,\"1/3\",null,[[14,51,\"1/2\"],[19,51,\"0\"],[32,51,\"0\"]],null]]]\n\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[25725,25776,\"1/2\"],[25739,25776,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[216,248,\"0\"],[216,230,\"0\"],[234,248,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[352,367,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,15,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[463,499,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1250,1262,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1328,1340,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[785,807,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[35,67,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[11,63,\"0\"],[11,39,\"0\"],[44,62,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[12,45,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[\"0/8\",\"b\",[[0,\"0/8\",null,[[429,557,\"0\"],[446,556,\"0\"],[446,484,\"0\"],[456,484,\"0\"],[466,484,\"0\"],[488,556,\"0\"],[488,511,\"0\"],[515,556,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[337,353,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[84,104,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2661,2668,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[73,89,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[110,165,\"0\"],[120,165,\"0\"],[130,165,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[421,444,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,97,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[108,193,\"0\"],[118,193,\"0\"],[128,193,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[447,470,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,153,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[112,118,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[212,218,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[322,349,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[383,410,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[473,481,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[267,294,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[870,892,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1174,1176,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[1640,1694,\"0\"],[1654,1694,\"0\"],[1654,1672,\"0\"],[1676,1694,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[108,135,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[169,196,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[222,283,\"0\"],[222,241,\"0\"],[245,283,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[309,351,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[197,244,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[985,1025,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[2527,2539,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[2577,2581,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[2616,2620,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[55,84,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2819,2846,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[759,781,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[95,131,\"0\"],[95,111,\"0\"],[115,131,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[307,313,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[566,577,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[840,855,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1032,1040,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[52,83,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[175,190,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[42,102,\"0\"],[42,51,\"0\"],[55,102,\"0\"],[55,63,\"0\"],[67,102,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[226,303,\"0\"],[226,242,\"0\"],[246,303,\"0\"],[246,262,\"0\"],[266,303,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[376,392,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[476,492,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[579,584,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[52,110,\"0\"],[52,65,\"0\"],[69,110,\"0\"],[69,87,\"0\"],[91,110,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[109,117,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1226,1241,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[42,102,\"0\"],[42,51,\"0\"],[55,102,\"0\"],[55,63,\"0\"],[67,102,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[226,339,\"0\"],[226,242,\"0\"],[246,339,\"0\"],[246,262,\"0\"],[266,339,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[412,428,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[511,527,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[611,617,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[52,110,\"0\"],[52,65,\"0\"],[69,110,\"0\"],[69,87,\"0\"],[91,110,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[164,172,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[771,793,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[10,42,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[46,79,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[323,479,\"0\"],[339,479,\"0\"],[339,407,\"0\"],[411,479,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[424,444,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2354,2361,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[2755,2777,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[10,84,\"0\"],[10,47,\"0\"],[51,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[224,256,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[12,91,\"0\"],[12,49,\"0\"],[53,91,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[12,91,\"0\"],[12,49,\"0\"],[53,91,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[240,278,\"0\"],[240,257,\"0\"],[261,278,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[347,372,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[449,474,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[630,645,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[897,901,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[100,112,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[173,255,\"0\"],[173,184,\"0\"],[188,255,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[456,481,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[58,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[213,299,\"0\"],[213,236,\"0\"],[271,299,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[44,60,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[60,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[294,316,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[95,124,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,51,\"0\"],[13,30,\"0\"],[34,51,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[130,148,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[15,122,\"0\"],[15,54,\"0\"],[58,122,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[776,794,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1221,1238,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1362,1379,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1707,1718,\"0\"],[1731,1742,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[1903,1956,\"0\"],[1903,1916,\"0\"],[1920,1956,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2872,2905,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[3291,3317,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[10,86,\"0\"],[10,46,\"0\"],[50,86,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[200,234,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[588,668,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[4480,4487,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[23,65,\"1/2\"],[23,51,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[168,195,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[23,38,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[109,155,\"1\"],[109,135,\"1\"],[139,155,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[225,249,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[-1,212,\"1/2\"],[-1,82,\"1/2\"],[-1,27,\"1/2\"],[31,82,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[45,109,\"1/2\"],[45,76,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[226,268,\"0\"],[226,251,\"0\"],[255,268,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,45,\"0\"]],null]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[76,187,\"0\"],[76,102,\"0\"],[106,187,\"0\"],[106,139,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[239,273,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,53,\"0\"],[24,53,\"0\"],[24,45,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,52,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[16,100,\"0\"],[16,50,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[16,54,\"0\"],[16,27,\"0\"],[31,54,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,58,\"0\"],[13,26,\"0\"],[31,57,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[20,69,\"0\"],[20,30,\"0\"],[34,69,\"0\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[18,315,\"0\"],[18,43,\"0\"],[47,315,\"0\"],[47,61,\"0\"]],null]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[45,63,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,39,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[138,317,\"0\"],[138,151,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[469,503,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[604,622,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[21,78,\"0\"],[33,78,\"0\"],[45,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[790,838,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,56,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1103,1141,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,48,\"0\"]],null]]]\n\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[21,114,\"0\"],[21,36,\"0\"],[40,114,\"0\"],[40,57,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1689,1694,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[53,86,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[572,581,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[642,651,\"0\"]],null]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[-1,12,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[-1,12,\"0\"]],null]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1313,1342,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1483,1509,\"0\"],[1483,1496,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1637,1663,\"0\"],[1637,1650,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1866,1888,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[2129,2175,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[113,118,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,43,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[48,53,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,56,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,58,\"0\"],[24,58,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[126,155,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,44,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[227,254,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[72,110,\"0\"],[72,98,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[131,210,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[492,519,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,38,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[585,613,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,38,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[679,710,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,50,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[788,804,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,35,\"0\"]],null]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[867,928,\"0\"],[867,895,\"0\"],[899,928,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1006,1044,\"0\"],[1017,1044,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,35,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[63,160,\"0\"],[63,91,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[257,319,\"0\"],[257,279,\"0\"],[283,319,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,48,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[462,523,\"0\"],[462,491,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[\"4/4\",\"b\",[[0,\"4/4\",null,[[-1,248,\"1/2\"],[-1,100,\"1/2\"],[-1,27,\"1/2\"],[31,100,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[229,258,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[351,382,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[515,544,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,18,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[47,63,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[106,115,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[224,252,\"0\"],[224,245,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,61,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[87,162,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[224,229,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[17,72,\"0\"],[17,48,\"0\"],[52,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[127,169,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,39,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[117,122,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,38,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,39,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[62,91,\"0\"],[62,85,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[112,130,\"0\"]],null]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[227,286,\"0\"],[227,255,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[360,375,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[390,419,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[459,518,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[604,611,\"0\"],[630,653,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[686,710,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[759,861,\"0\"],[759,796,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[979,984,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[125,153,\"0\"],[125,152,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[254,259,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[349,365,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[87,98,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[240,268,\"0\"],[240,267,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[379,384,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[84,110,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[268,283,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[863,867,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[947,952,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[986,1007,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1102,1122,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[172,177,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[158,209,\"0\"],[158,188,\"0\"],[192,209,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,26,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[538,554,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[622,641,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[\"9/19\",\"b\",[[0,\"9/19\",null,[[20049,20101,\"1/2\"],[20049,20073,\"1/2\"],[20075,20101,\"0\"],[20129,20166,\"1/2\"],[20129,20154,\"1/2\"],[20191,20217,\"0\"],[20227,20253,\"0\"],[20263,20297,\"0\"],[20263,20287,\"0\"],[36,41,\"1\"],[46,51,\"1/2\"],[59,94,\"0\"],[59,85,\"0\"],[98,103,\"0\"],[122,123,\"0\"],[304,305,\"0\"],[359,394,\"1/2\"],[359,385,\"1/2\"],[407,417,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[498,522,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[932,965,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,17,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[172,213,\"0\"],[172,177,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[317,320,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[65,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,19,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[47,82,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[124,149,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[16,67,\"0\"],[25,67,\"0\"],[25,45,\"0\"],[49,67,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[221,231,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[451,466,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[137,150,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[305,346,\"0\"],[321,337,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[403,424,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[54,73,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,141,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[258,285,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[334,361,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[488,513,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[562,587,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[696,707,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[756,767,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,42,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[81,144,\"0\"],[95,143,\"0\"],[105,143,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[37,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,49,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[466,489,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,26,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[612,621,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[84,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,21,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[216,229,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[403,430,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[491,527,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[133,159,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[98,103,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[57,86,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[172,189,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[9971,10191,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[18,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[330,357,\"0\"],[339,357,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[211,231,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[42,61,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1272,1286,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[44,70,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,183,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[380,392,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[407,433,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[536,541,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[97,177,\"1/2\"],[97,126,\"1/2\"],[130,177,\"0\"],[148,177,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[195,271,\"1/2\"],[195,223,\"1/2\"],[227,271,\"0\"],[227,257,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1018,1023,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1077,1083,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[20,84,\"1/2\"],[20,51,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[57,79,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[16,66,\"0\"],[23,66,\"0\"],[30,66,\"0\"],[30,48,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"2/5\",\"b\",[[0,\"2/5\",null,[[16,96,\"1/2\"],[16,41,\"1/2\"],[48,95,\"0\"],[55,95,\"0\"],[74,95,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[16,53,\"0\"],[16,39,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n[\"1\",\"b\",[[0,\"1\",null,[[17,43,\"1\"]],null]]]\n[3,null,[[0,3]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[16,99,\"0\"],[16,41,\"0\"],[45,99,\"0\"],[45,78,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,24,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[130,188,\"1/2\"],[130,158,\"1/2\"],[162,188,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[49,75,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[280,332,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[470,496,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[32,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[722,727,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,66,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1829,1850,\"1/2\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1935,1948,\"1/2\"]],null]]]\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[35,64,\"1/2\"],[35,63,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[201,219,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[424,484,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[631,636,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[969,985,\"1\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[1226,1242,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[1566,1625,\"1/2\"],[1566,1594,\"1/2\"],[1598,1625,\"1/2\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[225,231,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[615,628,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[879,906,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[5300,5314,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[19,null,[[0,19]]]\n[19,null,[[0,19]]]\n[19,null,[[0,19]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[125,171,\"0\"],[125,146,\"0\"],[150,171,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[24,67,\"0\"],[24,56,\"0\"],[60,67,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[274,302,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,31,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[369,376,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/7\",\"b\",[[0,\"0/7\",null,[[441,496,\"0\"],[441,465,\"0\"],[441,451,\"0\"],[455,465,\"0\"],[471,495,\"0\"],[471,481,\"0\"],[485,495,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[561,603,\"0\"]],null]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[25,162,\"0\"],[25,46,\"0\"],[52,162,\"0\"],[52,73,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[862,907,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[972,999,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,51,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[1167,1220,\"0\"],[1167,1195,\"0\"],[1199,1220,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[60,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[161,189,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1788,1807,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24,53,\"1/2\"]],null]]]\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[359,364,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[100,121,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,125,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[482,514,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,36,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,42,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[81,154,\"1\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[51,63,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,50,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[36,63,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[20,169,\"0\"],[20,41,\"0\"],[20,31,\"0\"]],null]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[47,52,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[156,183,\"0\"],[156,177,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[211,238,\"0\"],[211,232,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[264,273,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[17,62,\"0\"],[17,32,\"0\"],[36,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[275,295,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[13204,13284,\"1/2\"],[13204,13233,\"1/2\"],[13237,13284,\"0\"],[13255,13284,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[13302,13378,\"1/2\"],[13302,13330,\"1/2\"],[13334,13378,\"0\"],[13334,13364,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13472,13477,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13546,13552,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[13646,13657,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[11,null,[[0,11]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,45,\"1/2\"]],null]]]\n[10,null,[[0,10]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1188,1220,\"1/2\"]],null]]]\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[10,null,[[0,10]]]\n[10,null,[[0,10]]]\n\n[\"1\",\"b\",[[0,\"1\",null,[[127,145,\"1\"]],null]]]\n[12,null,[[0,12]]]\n\n[12,null,[[0,12]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[25,52,\"1/2\"]],null]]]\n[155,null,[[0,155]]]\n\n\n\n\n\n[\"1/3\",\"b\",[[0,\"1/3\",null,[[457,547,\"1/2\"],[475,547,\"0\"],[512,547,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[10,null,[[0,10]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2884,2964,\"1/2\"],[2884,2913,\"1/2\"],[2917,2964,\"0\"],[2935,2964,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2982,3058,\"1/2\"],[2982,3010,\"1/2\"],[3014,3058,\"0\"],[3014,3044,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3069,3074,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3143,3149,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3243,3254,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[36,46,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[637,717,\"1/2\"],[637,666,\"1/2\"],[670,717,\"0\"],[688,717,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[735,811,\"1/2\"],[735,763,\"1/2\"],[767,811,\"0\"],[767,797,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[822,827,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[896,902,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[996,1007,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,31,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[90,109,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[20,null,[[0,20]]]\n[20,null,[[0,20]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[610,690,\"1/2\"],[610,639,\"1/2\"],[643,690,\"0\"],[661,690,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[708,784,\"1/2\"],[708,736,\"1/2\"],[740,784,\"0\"],[740,770,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[795,800,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[869,875,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[969,980,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[63,78,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[17,56,\"0\"],[17,32,\"0\"],[36,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[116,130,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[280,310,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[468,494,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[12,null,[[0,12]]]\n[12,null,[[0,12]]]\n[12,null,[[0,12]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[34,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[32,65,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[78,99,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[142,176,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[12,null,[[0,12]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[74,81,\"1/2\"]],null]]]\n[12,null,[[0,12]]]\n\n[0,null,[[0,0]]]\n\n[12,null,[[0,12]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2279,2289,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[12,null,[[0,12]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[119,133,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,58,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[119,133,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,58,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[20,59,\"0\"],[20,35,\"0\"],[39,59,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,46,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[8,null,[[0,8]]]\n[8,null,[[0,8]]]\n[\"0\",\"b\",[[0,\"0\",null,[[24,53,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[96,118,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[211,224,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[25,127,\"0\"],[25,45,\"0\"],[49,127,\"0\"],[49,64,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[218,274,\"0\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[123,153,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[7694,7774,\"1/2\"],[7694,7723,\"1/2\"],[7727,7774,\"0\"],[7745,7774,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[7792,7868,\"1/2\"],[7792,7820,\"1/2\"],[7824,7868,\"0\"],[7824,7854,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8058,8063,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8132,8138,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8232,8243,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[50,115,\"0\"],[50,71,\"0\"],[75,115,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[140,166,\"0\"]],null]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[455,459,\"0\"]],null]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[990,1070,\"1/2\"],[990,1019,\"1/2\"],[1023,1070,\"0\"],[1041,1070,\"0\"]],null]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1108,1114,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1278,1286,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1358,1364,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1751,1831,\"1/2\"],[1751,1780,\"1/2\"],[1784,1831,\"0\"],[1802,1831,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1849,1925,\"1/2\"],[1849,1877,\"1/2\"],[1881,1925,\"0\"],[1881,1911,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1936,1941,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2010,2016,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2110,2121,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[73,84,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,68,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[172,200,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[63,83,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[171,176,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,69,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[63,83,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[171,176,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[124,178,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[24,123,\"0\"],[24,61,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[21,68,\"0\"],[21,49,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[159,216,\"0\"],[159,183,\"0\"],[187,216,\"0\"]],null]]]\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24,84,\"1/2\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[24,50,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[24,50,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[88,93,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[88,93,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[25,71,\"0\"],[36,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[112,157,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[31,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[130,140,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[250,255,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[422,461,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[576,590,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[85,107,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[835,845,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,39,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n[6,null,[[0,6]]]\n\n[6,null,[[0,6]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[6780,6860,\"1/2\"],[6780,6809,\"1/2\"],[6813,6860,\"0\"],[6831,6860,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[6878,6954,\"1/2\"],[6878,6906,\"1/2\"],[6910,6954,\"0\"],[6910,6940,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7172,7177,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7246,7252,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7346,7357,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[17,58,\"0\"],[30,58,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[136,156,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[243,248,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[113,129,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[17,23,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,127,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[132,152,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[185,205,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[239,259,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[79,90,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,34,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[960,970,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[37,58,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[48,74,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[239,249,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[616,629,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[68,76,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[40,57,\"1/2\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[385,444,\"1/2\"],[413,444,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1452,1475,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"3/3\",\"b\",[[0,\"3/3\",null,[[21,49,\"1\"],[21,26,\"1/2\"],[30,49,\"1\"]],null]]]\n[4,null,[[0,4]]]\n\n\n[6,null,[[0,6]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[85,103,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,33,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[110,123,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[202,259,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[130,199,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[72,82,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[96,101,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[718,738,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[73,175,\"0\"],[73,100,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[28,67,\"0\"],[39,67,\"0\"]],null]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[150,162,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[88,119,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[227,258,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[22,null,[[0,22]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,33,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[333,338,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[77,136,\"0\"],[89,105,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[68,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[699,725,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[93,98,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[111,134,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[429,445,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[95,100,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[87,92,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[14788,14868,\"1/2\"],[14788,14817,\"1/2\"],[14821,14868,\"0\"],[14839,14868,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[14886,14962,\"1/2\"],[14886,14914,\"1/2\"],[14918,14962,\"0\"],[14918,14948,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15243,15248,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15317,15323,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15417,15428,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[13,82,\"1/2\"],[13,40,\"1/2\"],[44,82,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[146,180,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,38,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[40,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[158,164,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[58,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[162,195,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[255,289,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[457,462,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[17,70,\"0\"],[33,70,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[145,245,\"0\"],[160,245,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,39,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[47,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[486,501,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[78,104,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[228,250,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[25,259,\"0\"],[25,59,\"0\"]],null]]]\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[64,78,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[152,188,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[271,286,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,186,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,186,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,48,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,48,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[9,null,[[0,9]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"2/2\",\"b\",[[0,\"2/2\",null,[[110,202,\"1\"],[142,202,\"1\"]],null]]]\n[9,null,[[0,9]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[10836,10916,\"1/2\"],[10836,10865,\"1/2\"],[10869,10916,\"0\"],[10887,10916,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[10934,11010,\"1/2\"],[10934,10962,\"1/2\"],[10966,11010,\"0\"],[10966,10996,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11200,11205,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11274,11280,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[11374,11385,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[47,95,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[70,135,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[987,992,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2279,2359,\"1/2\"],[2279,2308,\"1/2\"],[2312,2359,\"0\"],[2330,2359,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2377,2453,\"1/2\"],[2377,2405,\"1/2\"],[2409,2453,\"0\"],[2409,2439,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2464,2469,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2538,2544,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2638,2649,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"3/4\",\"b\",[[0,\"3/4\",null,[[17,81,\"1/2\"],[27,81,\"1/2\"],[27,53,\"1/2\"],[57,81,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[240,244,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[21,47,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[31,60,\"1/2\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[61,90,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[104,120,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[57,125,\"0\"],[57,83,\"0\"],[87,125,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1095,1137,\"1/2\"],[1106,1137,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"2/3\",\"b\",[[0,\"2/3\",null,[[1216,1277,\"1/2\"],[1216,1247,\"1/2\"],[1251,1277,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[28,40,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,83,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,163,\"0\"]],null]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[99,131,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[252,262,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,54,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[24,null,[[0,24]]]\n[\"0\",\"b\",[[0,\"0\",null,[[40,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[17,222,\"1\"]],null]]]\n\n\n\n\n[24,null,[[0,24]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[5454,5534,\"1/2\"],[5454,5483,\"1/2\"],[5487,5534,\"0\"],[5505,5534,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[5552,5628,\"1/2\"],[5552,5580,\"1/2\"],[5584,5628,\"0\"],[5584,5614,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[5873,5878,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[5947,5953,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[6047,6058,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[178,185,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,127,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[17,63,\"0\"],[35,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[157,162,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[131,149,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[275,301,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,68,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[36,59,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,47,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[600,619,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[728,742,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[36,82,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[206,216,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[281,304,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[40,79,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[105,181,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[645,684,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,150,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[1076,1115,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,84,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[37,81,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1426,1454,\"0\"],[1439,1454,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1566,1589,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[106,156,\"0\"],[106,129,\"0\"],[133,156,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[60,71,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[388,411,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[31,71,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[92,153,\"0\"],[108,153,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[125,171,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,70,\"0\"]],null]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[92,138,\"0\"],[92,130,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,174,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,174,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,44,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[24,62,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[227,284,\"0\"],[253,284,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[487,517,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[588,593,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[794,837,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1072,1166,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1449,1454,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[26,76,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[324,376,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[134,191,\"0\"],[160,191,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[268,298,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[377,387,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[410,453,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[530,624,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[748,753,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,75,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[164,216,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[29,57,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[89,115,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[515,525,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[14567,14647,\"1/2\"],[14567,14596,\"1/2\"],[14600,14647,\"0\"],[14618,14647,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[14665,14741,\"1/2\"],[14665,14693,\"1/2\"],[14697,14741,\"0\"],[14697,14727,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15106,15111,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15180,15186,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[15280,15291,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,34,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[90,95,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[81,97,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[227,236,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,29,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[79,109,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[667,718,\"0\"],[680,718,\"0\"],[692,718,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[3638,3718,\"1/2\"],[3638,3667,\"1/2\"],[3671,3718,\"0\"],[3689,3718,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[3736,3812,\"1/2\"],[3736,3764,\"1/2\"],[3768,3812,\"0\"],[3768,3798,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4053,4058,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4127,4133,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4227,4238,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[45,73,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[104,134,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[274,282,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1095,1175,\"1/2\"],[1095,1124,\"1/2\"],[1128,1175,\"0\"],[1146,1175,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1193,1269,\"1/2\"],[1193,1221,\"1/2\"],[1225,1269,\"0\"],[1225,1255,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1451,1456,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1510,1516,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[135915,135943,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[2,null,[[0,2]]]\n\n\n[1,null,[[0,1]]]\n\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n[2,null,[[0,2]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[83,126,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[186,228,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[83,108,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[39,64,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[111,143,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[33,88,\"0\"],[33,82,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[145,150,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,50,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[72,126,\"0\"],[72,120,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[161,181,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,59,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2854,2934,\"1/2\"],[2854,2883,\"1/2\"],[2887,2934,\"0\"],[2905,2934,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2952,3028,\"1/2\"],[2952,2980,\"1/2\"],[2984,3028,\"0\"],[2984,3014,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3150,3155,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[3209,3215,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[270,279,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[352,383,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1575,1655,\"1/2\"],[1575,1604,\"1/2\"],[1608,1655,\"0\"],[1626,1655,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1673,1749,\"1/2\"],[1673,1701,\"1/2\"],[1705,1749,\"0\"],[1705,1735,\"0\"]],null]]]\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1760,1765,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1834,1840,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1934,1945,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[17,46,\"0\"]],null]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[141992,142020,\"1/2\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[9,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[139,186,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[209,224,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[212,261,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,59,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[148,158,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,59,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[146,186,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[56,69,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[53,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/4\",\"b\",[[0,\"0/4\",null,[[152,210,\"0\"],[171,209,\"0\"],[171,188,\"0\"],[192,209,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[997,1006,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[57,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[406,439,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[140,206,\"0\"],[140,189,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[121,141,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[259,285,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[339,349,\"0\"]],null]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[207,228,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[423,438,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[6628,6708,\"1/2\"],[6628,6657,\"1/2\"],[6661,6708,\"0\"],[6679,6708,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[6726,6802,\"1/2\"],[6726,6754,\"1/2\"],[6758,6802,\"0\"],[6758,6788,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7064,7069,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7123,7129,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[27,76,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[90,101,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[193,241,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[255,270,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[512,548,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[580,614,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[643,674,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[706,740,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1104,1155,\"1/2\"]],null]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1184,1236,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[1315,1397,\"1/2\"]],null]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,57,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[106,111,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,42,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,62,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[100,132,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[292,313,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[76,118,\"0\"],[88,118,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[832,838,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[911,960,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[134,146,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,43,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,24,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[106,111,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,54,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[25,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[66,119,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[206,250,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[341,383,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[461,487,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1512,1532,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,65,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[13,80,\"0\"],[26,80,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[13,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[119,134,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[64,79,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[16,90,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,59,\"0\"],[13,32,\"0\"],[36,59,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[108,161,\"0\"],[131,161,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[232,255,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[457,496,\"0\"],[473,496,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[51,62,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[719,746,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,46,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[13,67,\"0\"],[13,36,\"0\"],[40,67,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[62,73,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[100,126,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[366,404,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[240,246,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[202,247,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[526,569,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[239,256,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,45,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[462,478,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[57,111,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[242,269,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,55,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[325,335,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[57,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[111,143,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[348,395,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[877,910,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[220,280,\"0\"],[220,263,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[150,180,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[326,362,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,74,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,127,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[245,301,\"0\"],[245,269,\"0\"],[273,301,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[360,370,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[37,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[105,119,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[973,987,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1085,1117,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[1203,1260,\"0\"],[1203,1227,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[31,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[193,206,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[246,256,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[159,183,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[351,372,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[555,575,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[670,694,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[25,89,\"0\"],[25,54,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[24353,24433,\"1/2\"],[24353,24382,\"1/2\"],[24386,24433,\"0\"],[24404,24433,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[24451,24527,\"1/2\"],[24451,24479,\"1/2\"],[24483,24527,\"0\"],[24483,24513,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24789,24794,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24863,24869,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[24963,24974,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[46,106,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,207,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[479,508,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[64,121,\"0\"],[74,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/5\",\"b\",[[0,\"0/5\",null,[[178,256,\"0\"],[186,256,\"0\"],[186,200,\"0\"],[205,255,\"0\"],[205,235,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[274,297,\"0\"]],null]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[52,113,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[199,258,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,56,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[84,119,\"0\"],[90,119,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[112,135,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[288,300,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,92,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[74,99,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[177,217,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,73,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[36,61,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[118,125,\"0\"]],null]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,30,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,36,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[21,75,\"0\"],[21,43,\"0\"],[47,75,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[205,220,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[304,326,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[445,467,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,41,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[153,169,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[266,285,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[25,40,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[134,164,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[191,205,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[72,78,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[588,612,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[24,87,\"0\"]],null]]]\n\n\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[7015,7095,\"1/2\"],[7015,7044,\"1/2\"],[7048,7095,\"0\"],[7066,7095,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[7113,7189,\"1/2\"],[7113,7141,\"1/2\"],[7145,7189,\"0\"],[7145,7175,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7477,7482,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[7536,7542,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,26,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[276,296,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[67,93,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[67,93,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[95,105,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,57,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[122,137,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,27,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2092,2172,\"1/2\"],[2092,2121,\"1/2\"],[2125,2172,\"0\"],[2143,2172,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2190,2266,\"1/2\"],[2190,2218,\"1/2\"],[2222,2266,\"0\"],[2222,2252,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2456,2461,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2515,2521,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,23,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[82,130,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[73,93,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,61,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[320,340,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,61,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[29,69,\"0\"]],null]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[92,115,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[79,89,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[176,187,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,37,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,38,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[67,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[202,220,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[445,462,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[67,72,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[86,132,\"0\"],[103,132,\"0\"],[103,121,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[4014,4094,\"1/2\"],[4014,4043,\"1/2\"],[4047,4094,\"0\"],[4065,4094,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[4112,4188,\"1/2\"],[4112,4140,\"1/2\"],[4144,4188,\"0\"],[4144,4174,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4500,4505,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4574,4580,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[4674,4685,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[58,77,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[97,147,\"0\"],[97,126,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[289,325,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[387,416,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[29,32,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[971,1003,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[1111,1140,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[1505,1520,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2210,2290,\"1/2\"],[2210,2239,\"1/2\"],[2243,2290,\"0\"],[2261,2290,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[2308,2384,\"1/2\"],[2308,2336,\"1/2\"],[2340,2384,\"0\"],[2340,2370,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2573,2578,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2632,2638,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2717,2728,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[17,22,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[287,295,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[379,388,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[17,52,\"0\"],[27,52,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[183,199,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[21,91,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[74,130,\"0\"],[74,104,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[73,90,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1877,1957,\"1/2\"],[1877,1906,\"1/2\"],[1910,1957,\"0\"],[1928,1957,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[1975,2051,\"1/2\"],[1975,2003,\"1/2\"],[2007,2051,\"0\"],[2007,2037,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2237,2242,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2311,2317,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[2411,2422,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[76,81,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[61,68,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[165,206,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[25,53,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[172,208,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[44,70,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[22,38,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[69,95,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[1,null,[[0,1]]]\n[\"1\",\"b\",[[0,\"1\",null,[[17,39,\"1\"]],null]]]\n[18,null,[[0,18]]]\n[18,null,[[0,18]]]\n\n\n[19,null,[[0,19]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[201,229,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[30,64,\"0\"]],null]]]\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[480,486,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[42,74,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[\"0\",\"b\",[[0,\"0\",null,[[20,49,\"0\"]],null]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[78,120,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[143,174,\"0\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[203,204,\"0\"]],null]]]\n[\"0\",\"b\",[[0,\"0\",null,[[33,51,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[0,null,[[0,0]]]\n\n[\"0\",\"b\",[[0,\"0\",null,[[60,86,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[21,28,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n[\"0\",\"b\",[[0,\"0\",null,[[147,160,\"0\"]],null]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[191,246,\"0\"],[191,234,\"0\"],[191,222,\"0\"]],null]]]\n[\"0/2\",\"b\",[[0,\"0/2\",null,[[282,337,\"0\"],[282,318,\"0\"]],null]]]\n\n[0,null,[[0,0]]]\n[\"0/3\",\"b\",[[0,\"0/3\",null,[[25,88,\"0\"],[25,44,\"0\"],[49,87,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n\n\n\n[0,null,[[0,0]]]\n[\"0\",\"b\",[[0,\"0\",null,[[77,97,\"0\"]],null]]]\n[0,null,[[0,0]]]\n\n[0,null,[[0,0]]]\n\n\n\n\n\n[0,null,[[0,0]]]\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[8079,8159,\"1/2\"],[8079,8108,\"1/2\"],[8112,8159,\"0\"],[8130,8159,\"0\"]],null]]]\n[\"2/4\",\"b\",[[0,\"2/4\",null,[[8177,8253,\"1/2\"],[8177,8205,\"1/2\"],[8209,8253,\"0\"],[8209,8239,\"0\"]],null]]]\n\n[1,null,[[0,1]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8471,8476,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8545,8551,\"1/2\"]],null]]]\n[0,null,[[0,0]]]\n[0,null,[[0,0]]]\n\n\n[\"1/2\",\"b\",[[0,\"1/2\",null,[[8645,8656,\"1/2\"]],null]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n[1,null,[[0,1]]]\n",
+ "\n{\"present_sessions\":[0]}\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n\n\n\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]\n[1,null,[[0,1]]]"
+ ],
+ "totals": {
+ "f": 18,
+ "n": 9701,
+ "h": 2848,
+ "m": 6171,
+ "p": 682,
+ "c": "29.35780",
+ "b": 2944,
+ "d": 0,
+ "M": 0,
+ "s": 0,
+ "C": 0,
+ "N": 0,
+ "diff": null
+ },
+ "report": {
+ "files": {
+ "/theme/Responsive/js/external/jquery.js": [
+ 0,
+ [0, 3413, 1203, 1886, 324, "35.24758", 1262, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/jquery-ui.1.12.1.min.js": [
+ 1,
+ [0, 8, 0, 2, 6, "0", 8, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/eventbooking-theme/jquery-ui.js": [
+ 2,
+ [0, 15, 5, 10, 0, "33.33333", 2, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-da.js": [
+ 3,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-de.js": [
+ 4,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-et.js": [
+ 5,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-en-GB.js": [
+ 6,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-fi.js": [
+ 7,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-fr.js": [
+ 8,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-gl.js": [
+ 9,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-nl.js": [
+ 10,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-nb.js": [
+ 11,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-sv.js": [
+ 12,
+ [0, 7, 6, 1, 0, "85.71429", 1, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/vendor/js/bootstrap.js": [
+ 13,
+ [0, 1095, 259, 821, 15, "23.65297", 301, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/js/onlinebooking.js": [
+ 14,
+ [0, 127, 24, 102, 1, "18.89764", 15, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/vendor/js/qunit/qunit.js": [
+ 15,
+ [0, 1893, 596, 1158, 139, "31.48442", 499, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/vendor/js/sinon/sinon.js": [
+ 16,
+ [0, 3032, 653, 2182, 197, "21.53694", 847, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ],
+ "/tests/onlinebooking.js": [
+ 17,
+ [0, 48, 48, 0, 0, "100", 0, 0, 0, 0, 0, 0, 0],
+ null,
+ null
+ ]
+ },
+ "sessions": {}
+ }
+}
diff --git a/apps/worker/services/report/languages/tests/unit/node/node3.json b/apps/worker/services/report/languages/tests/unit/node/node3.json
new file mode 100644
index 0000000000..11581e26e9
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/node/node3.json
@@ -0,0 +1,93664 @@
+{
+ "/theme/Responsive/js/external/jquery.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 346,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 12,
+ 12,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 152,
+ null,
+ null,
+ 152,
+ null,
+ null,
+ 152,
+ null,
+ null,
+ null,
+ null,
+ 174,
+ null,
+ null,
+ null,
+ 12,
+ 12,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 22,
+ null,
+ 22,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 272,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 272,
+ 17,
+ null,
+ null,
+ 17,
+ 17,
+ null,
+ null,
+ null,
+ 272,
+ 0,
+ null,
+ null,
+ null,
+ 272,
+ 34,
+ 34,
+ null,
+ null,
+ 272,
+ null,
+ null,
+ 310,
+ null,
+ null,
+ 166,
+ 1569,
+ 1569,
+ null,
+ null,
+ 1569,
+ 0,
+ null,
+ null,
+ null,
+ 1569,
+ null,
+ null,
+ 13,
+ 10,
+ 10,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ null,
+ 13,
+ null,
+ null,
+ 1556,
+ 1548,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 272,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 963,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 461,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1627,
+ null,
+ null,
+ null,
+ 1627,
+ 1468,
+ null,
+ null,
+ 159,
+ null,
+ null,
+ 159,
+ 0,
+ null,
+ null,
+ null,
+ 159,
+ 159,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 138,
+ null,
+ 138,
+ 86,
+ null,
+ 52,
+ null,
+ null,
+ null,
+ 1620,
+ 45,
+ null,
+ null,
+ null,
+ 1575,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 292,
+ null,
+ null,
+ null,
+ 102,
+ null,
+ null,
+ null,
+ 316,
+ null,
+ 316,
+ 272,
+ 272,
+ 473,
+ 0,
+ null,
+ null,
+ null,
+ 44,
+ 610,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 316,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 37,
+ null,
+ 37,
+ 37,
+ 19,
+ null,
+ null,
+ null,
+ null,
+ 18,
+ null,
+ null,
+ null,
+ 37,
+ null,
+ null,
+ null,
+ 38,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 230,
+ null,
+ null,
+ null,
+ 230,
+ 249,
+ null,
+ null,
+ 230,
+ null,
+ 230,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ 63,
+ 63,
+ 61,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ null,
+ null,
+ 57,
+ 57,
+ 57,
+ 46,
+ null,
+ 46,
+ 46,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 37,
+ null,
+ 37,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 37,
+ 1,
+ null,
+ null,
+ null,
+ 36,
+ 36,
+ 9,
+ null,
+ null,
+ null,
+ 36,
+ null,
+ 36,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 10,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 410,
+ null,
+ null,
+ 410,
+ 18,
+ null,
+ null,
+ 392,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 64,
+ 4,
+ null,
+ 64,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 90,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 111,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 111,
+ null,
+ null,
+ 111,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 111,
+ null,
+ 95,
+ 0,
+ null,
+ 95,
+ null,
+ 95,
+ null,
+ null,
+ null,
+ 95,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 35,
+ 7,
+ 7,
+ null,
+ null,
+ 28,
+ null,
+ null,
+ 28,
+ 28,
+ null,
+ null,
+ null,
+ null,
+ 60,
+ null,
+ null,
+ null,
+ 56,
+ 8,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 48,
+ null,
+ null,
+ 48,
+ 0,
+ null,
+ 48,
+ null,
+ null,
+ null,
+ 48,
+ 48,
+ 48,
+ 120,
+ null,
+ 48,
+ null,
+ null,
+ 48,
+ null,
+ null,
+ null,
+ 56,
+ 56,
+ 56,
+ null,
+ null,
+ 55,
+ null,
+ null,
+ 56,
+ 48,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 21,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 3,
+ null,
+ 3,
+ null,
+ 20,
+ null,
+ 0,
+ null,
+ 20,
+ null,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 12,
+ 12,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 10,
+ null,
+ 10,
+ 10,
+ null,
+ 2,
+ null,
+ null,
+ 10,
+ 3,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 5,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 7,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 172,
+ 172,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 5,
+ 5,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 68,
+ null,
+ 68,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 76,
+ 19,
+ 19,
+ null,
+ null,
+ null,
+ 57,
+ 57,
+ 0,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 16,
+ null,
+ null,
+ 1,
+ null,
+ 19,
+ 0,
+ null,
+ null,
+ null,
+ 19,
+ null,
+ 19,
+ null,
+ null,
+ null,
+ null,
+ 19,
+ 19,
+ null,
+ null,
+ 19,
+ null,
+ null,
+ null,
+ 19,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 68,
+ 0,
+ null,
+ 68,
+ null,
+ null,
+ 1,
+ null,
+ 81,
+ 0,
+ null,
+ null,
+ 81,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 81,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ 20,
+ 20,
+ null,
+ 20,
+ 3,
+ 13,
+ 10,
+ null,
+ null,
+ 3,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ 3,
+ null,
+ 3,
+ 0,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ 2,
+ null,
+ null,
+ 45,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 63,
+ null,
+ 63,
+ 63,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 13,
+ 13,
+ null,
+ 13,
+ 13,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 5,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 60,
+ null,
+ null,
+ null,
+ 60,
+ 50,
+ null,
+ null,
+ 10,
+ 10,
+ 10,
+ null,
+ 10,
+ null,
+ null,
+ 13,
+ 13,
+ null,
+ 3,
+ null,
+ 13,
+ null,
+ null,
+ 13,
+ null,
+ null,
+ 13,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 13,
+ 78,
+ null,
+ 14,
+ 14,
+ null,
+ null,
+ null,
+ null,
+ 14,
+ null,
+ null,
+ null,
+ 13,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 120,
+ null,
+ null,
+ 120,
+ 143,
+ null,
+ 120,
+ null,
+ null,
+ 1,
+ 17,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 17,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 63,
+ null,
+ null,
+ null,
+ 63,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 63,
+ 84,
+ 84,
+ null,
+ null,
+ null,
+ 84,
+ null,
+ 84,
+ 0,
+ 84,
+ null,
+ null,
+ null,
+ 58,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ 26,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ 121,
+ 121,
+ 229,
+ 13,
+ null,
+ null,
+ 108,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 108,
+ null,
+ null,
+ null,
+ null,
+ 108,
+ 108,
+ null,
+ null,
+ 8,
+ 8,
+ 0,
+ null,
+ 8,
+ null,
+ null,
+ 8,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ null,
+ 34,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 34,
+ 21,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 34,
+ 121,
+ 121,
+ 121,
+ 0,
+ 0,
+ null,
+ 121,
+ 121,
+ 108,
+ 108,
+ null,
+ null,
+ 121,
+ 108,
+ null,
+ null,
+ null,
+ null,
+ 121,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 34,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 34,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 34,
+ 21,
+ 21,
+ null,
+ null,
+ 34,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 23,
+ null,
+ null,
+ null,
+ null,
+ 23,
+ null,
+ 8,
+ 7,
+ null,
+ 8,
+ 8,
+ 8,
+ 8,
+ 0,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 8,
+ null,
+ 23,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 21,
+ null,
+ null,
+ null,
+ 21,
+ null,
+ null,
+ null,
+ 21,
+ null,
+ null,
+ 5,
+ 5,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ 5,
+ null,
+ null,
+ 5,
+ 0,
+ null,
+ 5,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 21,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 21,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 18,
+ null,
+ 18,
+ 74,
+ 74,
+ null,
+ null,
+ null,
+ 18,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 9,
+ 1,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ 3,
+ 13,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 16,
+ null,
+ 16,
+ 3,
+ null,
+ null,
+ 16,
+ 0,
+ null,
+ null,
+ 16,
+ 45,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 65,
+ null,
+ null,
+ null,
+ 65,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 65,
+ null,
+ 65,
+ 95,
+ null,
+ null,
+ 65,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 346,
+ null,
+ null,
+ 346,
+ 153,
+ null,
+ null,
+ null,
+ null,
+ 193,
+ null,
+ null,
+ 193,
+ 57,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 49,
+ null,
+ null,
+ null,
+ 57,
+ null,
+ null,
+ 25,
+ 8,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 17,
+ null,
+ 17,
+ null,
+ null,
+ 7,
+ 7,
+ null,
+ 17,
+ null,
+ null,
+ null,
+ 32,
+ 32,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 136,
+ 105,
+ 105,
+ 105,
+ null,
+ null,
+ null,
+ 31,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 16,
+ 16,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 18,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 12,
+ 25,
+ null,
+ 25,
+ 25,
+ null,
+ null,
+ 25,
+ 11,
+ null,
+ null,
+ 25,
+ null,
+ null,
+ 3,
+ 3,
+ null,
+ null,
+ null,
+ 3,
+ 0,
+ null,
+ null,
+ null,
+ 25,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 30,
+ 30,
+ 50,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ null,
+ 10,
+ 10,
+ 10,
+ 10,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 10,
+ 0,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ 10,
+ 10,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 37,
+ null,
+ null,
+ 37,
+ 0,
+ 0,
+ null,
+ null,
+ 37,
+ 37,
+ 57,
+ 57,
+ 57,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 37,
+ 0,
+ null,
+ null,
+ 37,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ 5,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ 5,
+ null,
+ 5,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 10,
+ 10,
+ 10,
+ 10,
+ 10,
+ 10,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 12,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 13,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 15,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 15,
+ null,
+ null,
+ 15,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 15,
+ null,
+ null,
+ null,
+ null,
+ 15,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 15,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 5,
+ 4,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 218,
+ null,
+ null,
+ null,
+ null,
+ 218,
+ 35,
+ 35,
+ 55,
+ null,
+ null,
+ null,
+ 183,
+ 127,
+ null,
+ 127,
+ 127,
+ null,
+ null,
+ 127,
+ null,
+ null,
+ 14,
+ 14,
+ 14,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 127,
+ 113,
+ 187,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 218,
+ 162,
+ null,
+ null,
+ null,
+ 56,
+ 11,
+ null,
+ null,
+ 45,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 60,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 188,
+ null,
+ null,
+ 188,
+ 53,
+ null,
+ null,
+ null,
+ null,
+ 53,
+ null,
+ null,
+ null,
+ 53,
+ 52,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 188,
+ null,
+ null,
+ 23,
+ null,
+ null,
+ null,
+ null,
+ 23,
+ 23,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 23,
+ null,
+ null,
+ 225,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ 26,
+ 0,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 26,
+ null,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ null,
+ null,
+ 26,
+ null,
+ 26,
+ 52,
+ null,
+ null,
+ null,
+ null,
+ 26,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 26,
+ 26,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 70,
+ 70,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ 2,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 25,
+ null,
+ null,
+ null,
+ null,
+ 25,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 24,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 24,
+ 24,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ 10,
+ 10,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ 14,
+ null,
+ null,
+ 14,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 1,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 100,
+ null,
+ 100,
+ 94,
+ null,
+ 6,
+ 6,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 100,
+ 12,
+ null,
+ null,
+ 88,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 33,
+ null,
+ null,
+ 33,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 27,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 27,
+ 27,
+ null,
+ 27,
+ null,
+ null,
+ 27,
+ null,
+ null,
+ null,
+ 15,
+ null,
+ null,
+ 12,
+ 0,
+ null,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ 12,
+ 12,
+ 12,
+ null,
+ null,
+ 12,
+ 12,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ 12,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 27,
+ null,
+ 27,
+ 27,
+ null,
+ null,
+ 45,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 45,
+ null,
+ null,
+ 45,
+ null,
+ null,
+ 45,
+ 16,
+ null,
+ null,
+ null,
+ 45,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 27,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 53,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 77,
+ null,
+ null,
+ 77,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 77,
+ null,
+ null,
+ 37,
+ 37,
+ 40,
+ 40,
+ null,
+ null,
+ 19,
+ 19,
+ null,
+ null,
+ null,
+ 21,
+ 21,
+ 21,
+ null,
+ null,
+ 77,
+ 0,
+ 77,
+ 0,
+ null,
+ null,
+ 77,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 77,
+ 108,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 108,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 108,
+ 0,
+ null,
+ null,
+ null,
+ 108,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 108,
+ 19,
+ null,
+ null,
+ null,
+ 108,
+ 22,
+ null,
+ null,
+ null,
+ 108,
+ 47,
+ null,
+ 108,
+ 47,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 108,
+ 108,
+ 108,
+ 125,
+ 125,
+ 125,
+ null,
+ null,
+ 125,
+ 0,
+ null,
+ null,
+ null,
+ 125,
+ null,
+ null,
+ 125,
+ null,
+ null,
+ 125,
+ null,
+ null,
+ 125,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 125,
+ 99,
+ 99,
+ null,
+ null,
+ 99,
+ null,
+ null,
+ 93,
+ 93,
+ null,
+ null,
+ null,
+ null,
+ 125,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 125,
+ 24,
+ null,
+ 101,
+ null,
+ null,
+ null,
+ 125,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 58,
+ null,
+ null,
+ null,
+ null,
+ 58,
+ 2,
+ null,
+ null,
+ null,
+ 56,
+ 56,
+ 56,
+ 68,
+ 68,
+ 68,
+ null,
+ null,
+ 68,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 68,
+ 68,
+ 68,
+ 68,
+ null,
+ null,
+ null,
+ 68,
+ 68,
+ 69,
+ null,
+ 69,
+ null,
+ null,
+ null,
+ null,
+ 60,
+ null,
+ 60,
+ 0,
+ null,
+ 60,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 68,
+ 55,
+ null,
+ null,
+ 55,
+ null,
+ null,
+ 55,
+ null,
+ null,
+ null,
+ null,
+ 56,
+ 26,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ 3,
+ 3,
+ null,
+ 3,
+ null,
+ null,
+ 3,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 55,
+ 55,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 8,
+ 0,
+ null,
+ null,
+ null,
+ 8,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 77,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 42,
+ 42,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 42,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 42,
+ null,
+ null,
+ 32,
+ 32,
+ null,
+ 42,
+ 0,
+ null,
+ 42,
+ 58,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 14,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 14,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 6,
+ null,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ 6,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 20,
+ null,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 20,
+ 20,
+ 20,
+ null,
+ 20,
+ 14,
+ null,
+ null,
+ null,
+ 20,
+ 20,
+ 20,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ 20,
+ null,
+ 20,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ null,
+ 20,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 20,
+ null,
+ null,
+ 1,
+ 10,
+ null,
+ null,
+ null,
+ 10,
+ 17,
+ 0,
+ null,
+ null,
+ 17,
+ 17,
+ 17,
+ null,
+ 17,
+ null,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ 1,
+ null,
+ 12,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 6,
+ 6,
+ 6,
+ null,
+ 6,
+ 6,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 14,
+ 14,
+ 14,
+ 14,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 6,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 6,
+ 6,
+ null,
+ 6,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ 7,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 72,
+ null,
+ 72,
+ 72,
+ null,
+ null,
+ 72,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 149,
+ 148,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 148,
+ 148,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 148,
+ null,
+ null,
+ 148,
+ null,
+ null,
+ null,
+ 148,
+ 148,
+ null,
+ 148,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 148,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 148,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 3,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 18,
+ 18,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 25,
+ 25,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 44,
+ null,
+ null,
+ null,
+ 44,
+ 20,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ 44,
+ null,
+ null,
+ 48,
+ 16,
+ null,
+ null,
+ 48,
+ null,
+ null,
+ 48,
+ 32,
+ null,
+ null,
+ null,
+ 48,
+ 32,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 44,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 24,
+ 24,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 35,
+ 0,
+ null,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ 35,
+ 35,
+ null,
+ null,
+ 35,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 35,
+ 0,
+ null,
+ null,
+ null,
+ 35,
+ 26,
+ null,
+ null,
+ null,
+ 35,
+ 0,
+ null,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ 35,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 172,
+ null,
+ null,
+ null,
+ 172,
+ null,
+ null,
+ null,
+ 172,
+ null,
+ null,
+ 172,
+ 28,
+ null,
+ null,
+ null,
+ 172,
+ 144,
+ null,
+ null,
+ null,
+ 172,
+ 0,
+ null,
+ null,
+ null,
+ 172,
+ 104,
+ 104,
+ null,
+ 68,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 25,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 25,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 25,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 3,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 11,
+ 15,
+ null,
+ null,
+ null,
+ 15,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 15,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ 3,
+ 6,
+ 6,
+ null,
+ null,
+ 3,
+ 0,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 3,
+ 3,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 94,
+ null,
+ null,
+ null,
+ 8,
+ 16,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 170,
+ null,
+ null,
+ null,
+ 170,
+ 0,
+ null,
+ null,
+ null,
+ 170,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 170,
+ 170,
+ null,
+ null,
+ null,
+ 170,
+ 152,
+ 0,
+ 0,
+ null,
+ null,
+ 152,
+ null,
+ 0,
+ null,
+ null,
+ 152,
+ 152,
+ null,
+ null,
+ 18,
+ 0,
+ null,
+ null,
+ 18,
+ null,
+ null,
+ 18,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 16,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 16,
+ 16,
+ 16,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 16,
+ null,
+ 16,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 179,
+ 179,
+ null,
+ null,
+ null,
+ 1,
+ 91,
+ null,
+ null,
+ 1,
+ null,
+ 41,
+ null,
+ null,
+ 41,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 41,
+ 41,
+ null,
+ 41,
+ 46,
+ 46,
+ null,
+ 46,
+ 46,
+ 46,
+ 46,
+ 46,
+ null,
+ null,
+ null,
+ null,
+ 46,
+ 46,
+ 46,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 41,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ 30,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 30,
+ 0,
+ null,
+ null,
+ 30,
+ 30,
+ null,
+ 30,
+ 42,
+ null,
+ null,
+ 42,
+ null,
+ 42,
+ 42,
+ 42,
+ null,
+ null,
+ 121,
+ 34,
+ null,
+ null,
+ null,
+ null,
+ 42,
+ 42,
+ 23,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 30,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ 3,
+ 3,
+ 3,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 5,
+ null,
+ null,
+ 5,
+ 5,
+ 5,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 7,
+ 0,
+ null,
+ null,
+ null,
+ 7,
+ 0,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 7,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ 7,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ 7,
+ 7,
+ 7,
+ null,
+ 7,
+ 28,
+ 28,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ 7,
+ null,
+ 42,
+ null,
+ null,
+ null,
+ null,
+ 42,
+ null,
+ 42,
+ 0,
+ null,
+ null,
+ null,
+ 42,
+ 42,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 7,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 8,
+ 7,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 22,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ 6,
+ null,
+ null,
+ 6,
+ 2,
+ null,
+ 6,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 2,
+ null,
+ 5,
+ 2,
+ 2,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 6,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ 2,
+ 5,
+ 5,
+ null,
+ null,
+ 2,
+ 2,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 6,
+ null,
+ 6,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 6,
+ 6,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 6,
+ 0,
+ null,
+ null,
+ 6,
+ 6,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ null,
+ 3,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ 0,
+ null,
+ 8,
+ 0,
+ 0,
+ null,
+ null,
+ 8,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 8,
+ 8,
+ null,
+ null,
+ 8,
+ 1,
+ null,
+ null,
+ 7,
+ null,
+ 7,
+ 0,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ 2,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ null,
+ 6,
+ 32,
+ null,
+ null,
+ 32,
+ 44,
+ null,
+ 44,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 44,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 44,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 0,
+ 1,
+ 0,
+ 346,
+ 0,
+ 0,
+ 12,
+ 152,
+ 174,
+ 12,
+ 12,
+ 8,
+ 12,
+ 0,
+ 22,
+ 0,
+ 272,
+ 0,
+ 0,
+ 963,
+ 461,
+ 0,
+ 1627,
+ 138,
+ 1620,
+ 0,
+ 292,
+ 102,
+ 316,
+ 0,
+ 37,
+ 38,
+ 230,
+ 20,
+ 57,
+ 37,
+ 9,
+ 10,
+ 410,
+ 1,
+ 64,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 90,
+ 0,
+ 111,
+ 3,
+ 20,
+ 12,
+ 10,
+ 0,
+ 0,
+ 5,
+ 0,
+ 2,
+ 0,
+ 2,
+ 0,
+ 7,
+ 0,
+ 0,
+ 0,
+ 172,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 5,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 68,
+ 0,
+ 76,
+ 0,
+ 16,
+ 19,
+ 68,
+ 81,
+ 0,
+ 0,
+ 20,
+ 0,
+ 3,
+ 0,
+ 2,
+ 2,
+ 0,
+ 0,
+ 2,
+ 45,
+ 2,
+ 63,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 2,
+ 0,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 60,
+ 120,
+ 17,
+ 0,
+ 63,
+ 8,
+ 121,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 26,
+ 0,
+ 108,
+ 8,
+ 34,
+ 23,
+ 21,
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 18,
+ 9,
+ 5,
+ 0,
+ 13,
+ 0,
+ 16,
+ 45,
+ 65,
+ 0,
+ 1,
+ 6,
+ 2,
+ 346,
+ 0,
+ 0,
+ 1,
+ 0,
+ 5,
+ 0,
+ 0,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 18,
+ 0,
+ 12,
+ 25,
+ 30,
+ 50,
+ 30,
+ 10,
+ 37,
+ 37,
+ 57,
+ 0,
+ 0,
+ 0,
+ 0,
+ 5,
+ 0,
+ 5,
+ 0,
+ 10,
+ 5,
+ 0,
+ 2,
+ 0,
+ 0,
+ 5,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4,
+ 12,
+ 4,
+ 4,
+ 4,
+ 4,
+ 13,
+ 15,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 1,
+ 218,
+ 0,
+ 60,
+ 2,
+ 188,
+ 23,
+ 225,
+ 12,
+ 26,
+ 70,
+ 2,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 0,
+ 24,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 4,
+ 4,
+ 0,
+ 0,
+ 100,
+ 33,
+ 27,
+ 1,
+ 0,
+ 53,
+ 0,
+ 77,
+ 0,
+ 108,
+ 108,
+ 1,
+ 58,
+ 1,
+ 1,
+ 30,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 55,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4,
+ 0,
+ 77,
+ 0,
+ 42,
+ 58,
+ 14,
+ 0,
+ 0,
+ 6,
+ 0,
+ 20,
+ 0,
+ 10,
+ 12,
+ 6,
+ 0,
+ 9,
+ 1,
+ 0,
+ 0,
+ 0,
+ 14,
+ 14,
+ 0,
+ 0,
+ 6,
+ 6,
+ 0,
+ 0,
+ 0,
+ 6,
+ 6,
+ 1,
+ 1,
+ 0,
+ 0,
+ 5,
+ 7,
+ 72,
+ 1,
+ 149,
+ 0,
+ 0,
+ 148,
+ 1,
+ 148,
+ 3,
+ 1,
+ 18,
+ 25,
+ 44,
+ 24,
+ 0,
+ 35,
+ 172,
+ 2,
+ 24,
+ 0,
+ 25,
+ 4,
+ 4,
+ 3,
+ 0,
+ 11,
+ 15,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 8,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 94,
+ 8,
+ 16,
+ 170,
+ 0,
+ 16,
+ 0,
+ 16,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 10,
+ 179,
+ 91,
+ 41,
+ 0,
+ 30,
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 5,
+ 7,
+ 0,
+ 8,
+ 7,
+ 0,
+ 22,
+ 1,
+ 0,
+ 2,
+ 0,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 5,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 6,
+ 6,
+ 0,
+ 0,
+ 0,
+ 3,
+ 3,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 2,
+ 0,
+ 2,
+ 6,
+ 32,
+ 44,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "branchData": {
+ "18": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 64,
+ "src": "typeof module === \"object\" && typeof module.exports === \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 26,
+ "src": "typeof module === \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 54,
+ "nodeLength": 34,
+ "src": "typeof module.exports === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "27": [
+ null,
+ {
+ "position": 431,
+ "nodeLength": 15,
+ "src": "global.document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "30": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 11,
+ "src": "!w.document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "40": [
+ null,
+ {
+ "position": 829,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "77": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 15,
+ "src": "doc || document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "133": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 11,
+ "src": "num == null",
+ "evalFalse": 0,
+ "evalTrue": 12
+ }
+ ],
+ "138": [
+ null,
+ {
+ "position": 161,
+ "nodeLength": 7,
+ "src": "num < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "180": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 5,
+ "src": "i < 0",
+ "evalFalse": 22,
+ "evalTrue": 0
+ }
+ ],
+ "181": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 17,
+ "src": "j >= 0 && j < len",
+ "evalFalse": 0,
+ "evalTrue": 22
+ },
+ {
+ "position": 84,
+ "nodeLength": 6,
+ "src": "j >= 0",
+ "evalFalse": 0,
+ "evalTrue": 22
+ },
+ {
+ "position": 94,
+ "nodeLength": 7,
+ "src": "j < len",
+ "evalFalse": 0,
+ "evalTrue": 22
+ }
+ ],
+ "185": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 37,
+ "src": "this.prevObject || this.constructor()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "197": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 20,
+ "src": "arguments[0] || {}",
+ "evalFalse": 0,
+ "evalTrue": 272
+ }
+ ],
+ "203": [
+ null,
+ {
+ "position": 179,
+ "nodeLength": 27,
+ "src": "typeof target === \"boolean\"",
+ "evalFalse": 255,
+ "evalTrue": 17
+ }
+ ],
+ "207": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 20,
+ "src": "arguments[i] || {}",
+ "evalFalse": 0,
+ "evalTrue": 17
+ }
+ ],
+ "212": [
+ null,
+ {
+ "position": 393,
+ "nodeLength": 58,
+ "src": "typeof target !== \"object\" && !jQuery.isFunction(target)",
+ "evalFalse": 272,
+ "evalTrue": 0
+ },
+ {
+ "position": 393,
+ "nodeLength": 26,
+ "src": "typeof target !== \"object\"",
+ "evalFalse": 243,
+ "evalTrue": 29
+ }
+ ],
+ "217": [
+ null,
+ {
+ "position": 537,
+ "nodeLength": 12,
+ "src": "i === length",
+ "evalFalse": 238,
+ "evalTrue": 34
+ }
+ ],
+ "222": [
+ null,
+ {
+ "position": 591,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 272,
+ "evalTrue": 310
+ }
+ ],
+ "225": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 34,
+ "src": "(options = arguments[i]) != null",
+ "evalFalse": 144,
+ "evalTrue": 166
+ }
+ ],
+ "233": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 15,
+ "src": "target === copy",
+ "evalFalse": 1569,
+ "evalTrue": 0
+ }
+ ],
+ "238": [
+ null,
+ {
+ "position": 205,
+ "nodeLength": 97,
+ "src": "deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))",
+ "evalFalse": 1556,
+ "evalTrue": 13
+ },
+ {
+ "position": 213,
+ "nodeLength": 89,
+ "src": "copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))",
+ "evalFalse": 115,
+ "evalTrue": 13
+ },
+ {
+ "position": 223,
+ "nodeLength": 77,
+ "src": "jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy))",
+ "evalFalse": 107,
+ "evalTrue": 13
+ }
+ ],
+ "241": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 11,
+ "src": "copyIsArray",
+ "evalFalse": 3,
+ "evalTrue": 10
+ }
+ ],
+ "243": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 28,
+ "src": "src && jQuery.isArray(src)",
+ "evalFalse": 10,
+ "evalTrue": 0
+ }
+ ],
+ "246": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 34,
+ "src": "src && jQuery.isPlainObject(src)",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "253": [
+ null,
+ {
+ "position": 663,
+ "nodeLength": 18,
+ "src": "copy !== undefined",
+ "evalFalse": 8,
+ "evalTrue": 1548
+ }
+ ],
+ "279": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 33,
+ "src": "jQuery.type(obj) === \"function\"",
+ "evalFalse": 388,
+ "evalTrue": 575
+ }
+ ],
+ "285": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 33,
+ "src": "obj != null && obj === obj.window",
+ "evalFalse": 443,
+ "evalTrue": 18
+ },
+ {
+ "position": 10,
+ "nodeLength": 11,
+ "src": "obj != null",
+ "evalFalse": 0,
+ "evalTrue": 461
+ },
+ {
+ "position": 25,
+ "nodeLength": 18,
+ "src": "obj === obj.window",
+ "evalFalse": 443,
+ "evalTrue": 18
+ }
+ ],
+ "294": [
+ null,
+ {
+ "position": 195,
+ "nodeLength": 267,
+ "src": "(type === \"number\" || type === \"string\") && !isNaN(obj - parseFloat(obj))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 195,
+ "nodeLength": 38,
+ "src": "type === \"number\" || type === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 195,
+ "nodeLength": 17,
+ "src": "type === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 216,
+ "nodeLength": 17,
+ "src": "type === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "307": [
+ null,
+ {
+ "position": 121,
+ "nodeLength": 50,
+ "src": "!obj || toString.call(obj) !== \"[object Object]\"",
+ "evalFalse": 159,
+ "evalTrue": 1468
+ },
+ {
+ "position": 129,
+ "nodeLength": 42,
+ "src": "toString.call(obj) !== \"[object Object]\"",
+ "evalFalse": 159,
+ "evalTrue": 1019
+ }
+ ],
+ "314": [
+ null,
+ {
+ "position": 306,
+ "nodeLength": 6,
+ "src": "!proto",
+ "evalFalse": 159,
+ "evalTrue": 0
+ }
+ ],
+ "319": [
+ null,
+ {
+ "position": 439,
+ "nodeLength": 56,
+ "src": "hasOwn.call(proto, \"constructor\") && proto.constructor",
+ "evalFalse": 0,
+ "evalTrue": 159
+ }
+ ],
+ "320": [
+ null,
+ {
+ "position": 506,
+ "nodeLength": 78,
+ "src": "typeof Ctor === \"function\" && fnToString.call(Ctor) === ObjectFunctionString",
+ "evalFalse": 0,
+ "evalTrue": 159
+ },
+ {
+ "position": 506,
+ "nodeLength": 26,
+ "src": "typeof Ctor === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 159
+ },
+ {
+ "position": 536,
+ "nodeLength": 48,
+ "src": "fnToString.call(Ctor) === ObjectFunctionString",
+ "evalFalse": 0,
+ "evalTrue": 159
+ }
+ ],
+ "336": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 11,
+ "src": "obj == null",
+ "evalFalse": 1575,
+ "evalTrue": 45
+ }
+ ],
+ "341": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 52,
+ "src": "typeof obj === \"object\" || typeof obj === \"function\"",
+ "evalFalse": 453,
+ "evalTrue": 1122
+ },
+ {
+ "position": 112,
+ "nodeLength": 23,
+ "src": "typeof obj === \"object\"",
+ "evalFalse": 1048,
+ "evalTrue": 527
+ },
+ {
+ "position": 139,
+ "nodeLength": 25,
+ "src": "typeof obj === \"function\"",
+ "evalFalse": 453,
+ "evalTrue": 595
+ }
+ ],
+ "342": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 46,
+ "src": "class2type[toString.call(obj)] || \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1122
+ }
+ ],
+ "359": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 67,
+ "src": "elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase()",
+ "evalFalse": 102,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 50,
+ "src": "elem.nodeName.toLowerCase() === name.toLowerCase()",
+ "evalFalse": 102,
+ "evalTrue": 0
+ }
+ ],
+ "365": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 18,
+ "src": "isArrayLike(obj)",
+ "evalFalse": 44,
+ "evalTrue": 272
+ }
+ ],
+ "367": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 272,
+ "evalTrue": 473
+ }
+ ],
+ "368": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 48,
+ "src": "callback.call(obj[i], i, obj[i]) === false",
+ "evalFalse": 473,
+ "evalTrue": 0
+ }
+ ],
+ "374": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 48,
+ "src": "callback.call(obj[i], i, obj[i]) === false",
+ "evalFalse": 610,
+ "evalTrue": 0
+ }
+ ],
+ "385": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 12,
+ "src": "text == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "392": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 13,
+ "src": "results || []",
+ "evalFalse": 0,
+ "evalTrue": 37
+ }
+ ],
+ "394": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 11,
+ "src": "arr != null",
+ "evalFalse": 0,
+ "evalTrue": 37
+ }
+ ],
+ "395": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 28,
+ "src": "isArrayLike(Object(arr))",
+ "evalFalse": 18,
+ "evalTrue": 19
+ }
+ ],
+ "397": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 23,
+ "src": "typeof arr === \"string\"",
+ "evalFalse": 19,
+ "evalTrue": 0
+ }
+ ],
+ "409": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 11,
+ "src": "arr == null",
+ "evalFalse": 38,
+ "evalTrue": 0
+ }
+ ],
+ "419": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 7,
+ "src": "j < len",
+ "evalFalse": 230,
+ "evalTrue": 249
+ }
+ ],
+ "437": [
+ null,
+ {
+ "position": 204,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 20,
+ "evalTrue": 63
+ }
+ ],
+ "439": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 34,
+ "src": "callbackInverse !== callbackExpect",
+ "evalFalse": 2,
+ "evalTrue": 61
+ }
+ ],
+ "454": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 20,
+ "src": "isArrayLike(elems)",
+ "evalFalse": 0,
+ "evalTrue": 57
+ }
+ ],
+ "456": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 57,
+ "evalTrue": 46
+ }
+ ],
+ "459": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 13,
+ "src": "value != null",
+ "evalFalse": 0,
+ "evalTrue": 46
+ }
+ ],
+ "469": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 13,
+ "src": "value != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "487": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 27,
+ "src": "typeof context === \"string\"",
+ "evalFalse": 37,
+ "evalTrue": 0
+ }
+ ],
+ "495": [
+ null,
+ {
+ "position": 261,
+ "nodeLength": 24,
+ "src": "!jQuery.isFunction(fn)",
+ "evalFalse": 36,
+ "evalTrue": 1
+ }
+ ],
+ "502": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 15,
+ "src": "context || this",
+ "evalFalse": 0,
+ "evalTrue": 9
+ }
+ ],
+ "506": [
+ null,
+ {
+ "position": 596,
+ "nodeLength": 24,
+ "src": "fn.guid || jQuery.guid++",
+ "evalFalse": 0,
+ "evalTrue": 36
+ }
+ ],
+ "518": [
+ null,
+ {
+ "position": 11002,
+ "nodeLength": 28,
+ "src": "typeof Symbol === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "534": [
+ null,
+ {
+ "position": 216,
+ "nodeLength": 38,
+ "src": "!!obj && \"length\" in obj && obj.length",
+ "evalFalse": 124,
+ "evalTrue": 286
+ },
+ {
+ "position": 225,
+ "nodeLength": 29,
+ "src": "\"length\" in obj && obj.length",
+ "evalFalse": 124,
+ "evalTrue": 286
+ }
+ ],
+ "537": [
+ null,
+ {
+ "position": 292,
+ "nodeLength": 45,
+ "src": "type === \"function\" || jQuery.isWindow(obj)",
+ "evalFalse": 392,
+ "evalTrue": 18
+ },
+ {
+ "position": 292,
+ "nodeLength": 19,
+ "src": "type === \"function\"",
+ "evalFalse": 410,
+ "evalTrue": 0
+ }
+ ],
+ "541": [
+ null,
+ {
+ "position": 370,
+ "nodeLength": 103,
+ "src": "type === \"array\" || length === 0 || typeof length === \"number\" && length > 0 && (length - 1) in obj",
+ "evalFalse": 44,
+ "evalTrue": 348
+ },
+ {
+ "position": 370,
+ "nodeLength": 16,
+ "src": "type === \"array\"",
+ "evalFalse": 325,
+ "evalTrue": 67
+ },
+ {
+ "position": 390,
+ "nodeLength": 83,
+ "src": "length === 0 || typeof length === \"number\" && length > 0 && (length - 1) in obj",
+ "evalFalse": 44,
+ "evalTrue": 281
+ },
+ {
+ "position": 390,
+ "nodeLength": 12,
+ "src": "length === 0",
+ "evalFalse": 271,
+ "evalTrue": 54
+ }
+ ],
+ "542": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 65,
+ "src": "typeof length === \"number\" && length > 0 && (length - 1) in obj",
+ "evalFalse": 44,
+ "evalTrue": 227
+ },
+ {
+ "position": 411,
+ "nodeLength": 26,
+ "src": "typeof length === \"number\"",
+ "evalFalse": 44,
+ "evalTrue": 227
+ },
+ {
+ "position": 29,
+ "nodeLength": 35,
+ "src": "length > 0 && (length - 1) in obj",
+ "evalFalse": 0,
+ "evalTrue": 227
+ },
+ {
+ "position": 442,
+ "nodeLength": 10,
+ "src": "length > 0",
+ "evalFalse": 0,
+ "evalTrue": 227
+ }
+ ],
+ "588": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 60,
+ "evalTrue": 4
+ }
+ ],
+ "606": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "607": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 16,
+ "src": "list[i] === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "688": [
+ null,
+ {
+ "position": 161,
+ "nodeLength": 34,
+ "src": "high !== high || escapedWhitespace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 161,
+ "nodeLength": 13,
+ "src": "high !== high",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "690": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 8,
+ "src": "high < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "701": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 11,
+ "src": "asCodePoint",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "704": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 11,
+ "src": "ch === \"\\x00\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "726": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 61,
+ "src": "elem.disabled === true && (\"form\" in elem || \"label\" in elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 22,
+ "src": "elem.disabled === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38,
+ "nodeLength": 33,
+ "src": "\"form\" in elem || \"label\" in elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "741": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 10,
+ "src": "arr.length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "754": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 23,
+ "src": "(target[j++] = els[i++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "762": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 32,
+ "src": "context && context.ownerDocument",
+ "evalFalse": 29,
+ "evalTrue": 82
+ }
+ ],
+ "765": [
+ null,
+ {
+ "position": 175,
+ "nodeLength": 7,
+ "src": "context",
+ "evalFalse": 16,
+ "evalTrue": 95
+ }
+ ],
+ "767": [
+ null,
+ {
+ "position": 222,
+ "nodeLength": 13,
+ "src": "results || []",
+ "evalFalse": 0,
+ "evalTrue": 111
+ }
+ ],
+ "770": [
+ null,
+ {
+ "position": 305,
+ "nodeLength": 98,
+ "src": "typeof selector !== \"string\" || !selector || nodeType !== 1 && nodeType !== 9 && nodeType !== 11",
+ "evalFalse": 111,
+ "evalTrue": 0
+ },
+ {
+ "position": 305,
+ "nodeLength": 28,
+ "src": "typeof selector !== \"string\"",
+ "evalFalse": 111,
+ "evalTrue": 0
+ },
+ {
+ "position": 337,
+ "nodeLength": 66,
+ "src": "!selector || nodeType !== 1 && nodeType !== 9 && nodeType !== 11",
+ "evalFalse": 111,
+ "evalTrue": 0
+ }
+ ],
+ "771": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 51,
+ "src": "nodeType !== 1 && nodeType !== 9 && nodeType !== 11",
+ "evalFalse": 111,
+ "evalTrue": 0
+ },
+ {
+ "position": 355,
+ "nodeLength": 14,
+ "src": "nodeType !== 1",
+ "evalFalse": 82,
+ "evalTrue": 29
+ },
+ {
+ "position": 17,
+ "nodeLength": 33,
+ "src": "nodeType !== 9 && nodeType !== 11",
+ "evalFalse": 29,
+ "evalTrue": 0
+ },
+ {
+ "position": 374,
+ "nodeLength": 14,
+ "src": "nodeType !== 9",
+ "evalFalse": 29,
+ "evalTrue": 0
+ },
+ {
+ "position": 17,
+ "nodeLength": 15,
+ "src": "nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "777": [
+ null,
+ {
+ "position": 515,
+ "nodeLength": 5,
+ "src": "!seed",
+ "evalFalse": 16,
+ "evalTrue": 95
+ }
+ ],
+ "779": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 72,
+ "src": "(context ? context.ownerDocument || context : preferredDoc) !== document",
+ "evalFalse": 95,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 7,
+ "src": "context",
+ "evalFalse": 0,
+ "evalTrue": 95
+ },
+ {
+ "position": 21,
+ "nodeLength": 32,
+ "src": "context.ownerDocument || context",
+ "evalFalse": 0,
+ "evalTrue": 95
+ }
+ ],
+ "782": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 19,
+ "src": "context || document",
+ "evalFalse": 0,
+ "evalTrue": 95
+ }
+ ],
+ "784": [
+ null,
+ {
+ "position": 160,
+ "nodeLength": 14,
+ "src": "documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 95
+ }
+ ],
+ "788": [
+ null,
+ {
+ "position": 163,
+ "nodeLength": 56,
+ "src": "nodeType !== 11 && (match = rquickExpr.exec(selector))",
+ "evalFalse": 60,
+ "evalTrue": 35
+ },
+ {
+ "position": 163,
+ "nodeLength": 15,
+ "src": "nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 95
+ }
+ ],
+ "791": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 13,
+ "src": "(m = match[1])",
+ "evalFalse": 35,
+ "evalTrue": 0
+ }
+ ],
+ "794": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 14,
+ "src": "nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "795": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 35,
+ "src": "(elem = context.getElementById(m))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "800": [
+ null,
+ {
+ "position": 151,
+ "nodeLength": 13,
+ "src": "elem.id === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "814": [
+ null,
+ {
+ "position": 147,
+ "nodeLength": 113,
+ "src": "newContext && (elem = newContext.getElementById(m)) && contains(context, elem) && elem.id === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 162,
+ "nodeLength": 98,
+ "src": "(elem = newContext.getElementById(m)) && contains(context, elem) && elem.id === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "815": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 49,
+ "src": "contains(context, elem) && elem.id === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "816": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 13,
+ "src": "elem.id === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "824": [
+ null,
+ {
+ "position": 843,
+ "nodeLength": 8,
+ "src": "match[2]",
+ "evalFalse": 28,
+ "evalTrue": 7
+ }
+ ],
+ "829": [
+ null,
+ {
+ "position": 988,
+ "nodeLength": 86,
+ "src": "(m = match[3]) && support.getElementsByClassName && context.getElementsByClassName",
+ "evalFalse": 0,
+ "evalTrue": 28
+ },
+ {
+ "position": 1005,
+ "nodeLength": 69,
+ "src": "support.getElementsByClassName && context.getElementsByClassName",
+ "evalFalse": 0,
+ "evalTrue": 28
+ }
+ ],
+ "838": [
+ null,
+ {
+ "position": 1448,
+ "nodeLength": 102,
+ "src": "support.qsa && !compilerCache[selector + \" \"] && (!rbuggyQSA || !rbuggyQSA.test(selector))",
+ "evalFalse": 4,
+ "evalTrue": 56
+ }
+ ],
+ "839": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 83,
+ "src": "!compilerCache[selector + \" \"] && (!rbuggyQSA || !rbuggyQSA.test(selector))",
+ "evalFalse": 4,
+ "evalTrue": 56
+ }
+ ],
+ "840": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 41,
+ "src": "!rbuggyQSA || !rbuggyQSA.test(selector)",
+ "evalFalse": 0,
+ "evalTrue": 56
+ }
+ ],
+ "842": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 14,
+ "src": "nodeType !== 1",
+ "evalFalse": 48,
+ "evalTrue": 8
+ }
+ ],
+ "850": [
+ null,
+ {
+ "position": 287,
+ "nodeLength": 43,
+ "src": "context.nodeName.toLowerCase() !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 48
+ }
+ ],
+ "853": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 35,
+ "src": "(nid = context.getAttribute(\"id\"))",
+ "evalFalse": 48,
+ "evalTrue": 0
+ }
+ ],
+ "862": [
+ null,
+ {
+ "position": 356,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 48,
+ "evalTrue": 120
+ }
+ ],
+ "868": [
+ null,
+ {
+ "position": 535,
+ "nodeLength": 79,
+ "src": "rsibling.test(selector) && testContext(context.parentNode) || context",
+ "evalFalse": 0,
+ "evalTrue": 48
+ },
+ {
+ "position": 535,
+ "nodeLength": 62,
+ "src": "rsibling.test(selector) && testContext(context.parentNode)",
+ "evalFalse": 48,
+ "evalTrue": 0
+ }
+ ],
+ "872": [
+ null,
+ {
+ "position": 965,
+ "nodeLength": 11,
+ "src": "newSelector",
+ "evalFalse": 0,
+ "evalTrue": 56
+ }
+ ],
+ "880": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 15,
+ "src": "nid === expando",
+ "evalFalse": 8,
+ "evalTrue": 48
+ }
+ ],
+ "904": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 41,
+ "src": "keys.push(key + \" \") > Expr.cacheLength",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "935": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 13,
+ "src": "el.parentNode",
+ "evalFalse": 7,
+ "evalTrue": 3
+ }
+ ],
+ "952": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "964": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 6,
+ "src": "b && a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "965": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 79,
+ "src": "cur && a.nodeType === 1 && b.nodeType === 1 && a.sourceIndex - b.sourceIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 72,
+ "src": "a.nodeType === 1 && b.nodeType === 1 && a.sourceIndex - b.sourceIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 16,
+ "src": "a.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 52,
+ "src": "b.nodeType === 1 && a.sourceIndex - b.sourceIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 16,
+ "src": "b.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "969": [
+ null,
+ {
+ "position": 167,
+ "nodeLength": 4,
+ "src": "diff",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "974": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 3,
+ "src": "cur",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "975": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 22,
+ "src": "(cur = cur.nextSibling)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "976": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 9,
+ "src": "cur === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "982": [
+ null,
+ {
+ "position": 330,
+ "nodeLength": 1,
+ "src": "a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "992": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 38,
+ "src": "name === \"input\" && elem.type === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 16,
+ "src": "name === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 18,
+ "src": "elem.type === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1003": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 60,
+ "src": "(name === \"input\" || name === \"button\") && elem.type === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 37,
+ "src": "name === \"input\" || name === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 16,
+ "src": "name === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73,
+ "nodeLength": 17,
+ "src": "name === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95,
+ "nodeLength": 18,
+ "src": "elem.type === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1019": [
+ null,
+ {
+ "position": 221,
+ "nodeLength": 14,
+ "src": "\"form\" in elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1028": [
+ null,
+ {
+ "position": 476,
+ "nodeLength": 42,
+ "src": "elem.parentNode && elem.disabled === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 495,
+ "nodeLength": 23,
+ "src": "elem.disabled === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1031": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 15,
+ "src": "\"label\" in elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1032": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 26,
+ "src": "\"label\" in elem.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1033": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 37,
+ "src": "elem.parentNode.disabled === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1035": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 26,
+ "src": "elem.disabled === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1041": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 191,
+ "src": "elem.isDisabled === disabled || elem.isDisabled !== !disabled && disabledAncestor(elem) === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 376,
+ "nodeLength": 28,
+ "src": "elem.isDisabled === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1045": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 76,
+ "src": "elem.isDisabled !== !disabled && disabledAncestor(elem) === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 493,
+ "nodeLength": 29,
+ "src": "elem.isDisabled !== !disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1046": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 37,
+ "src": "disabledAncestor(elem) === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1049": [
+ null,
+ {
+ "position": 1107,
+ "nodeLength": 26,
+ "src": "elem.disabled === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1054": [
+ null,
+ {
+ "position": 1628,
+ "nodeLength": 15,
+ "src": "\"label\" in elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1055": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 26,
+ "src": "elem.disabled === disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1076": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1077": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 29,
+ "src": "seed[(j = matchIndexes[i])]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1091": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 73,
+ "src": "context && typeof context.getElementsByTagName !== \"undefined\" && context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 62,
+ "src": "typeof context.getElementsByTagName !== \"undefined\" && context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 51,
+ "src": "typeof context.getElementsByTagName !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1105": [
+ null,
+ {
+ "position": 137,
+ "nodeLength": 52,
+ "src": "elem && (elem.ownerDocument || elem).documentElement",
+ "evalFalse": 0,
+ "evalTrue": 172
+ },
+ {
+ "position": 146,
+ "nodeLength": 26,
+ "src": "elem.ownerDocument || elem",
+ "evalFalse": 0,
+ "evalTrue": 172
+ }
+ ],
+ "1106": [
+ null,
+ {
+ "position": 199,
+ "nodeLength": 15,
+ "src": "documentElement",
+ "evalFalse": 0,
+ "evalTrue": 172
+ },
+ {
+ "position": 217,
+ "nodeLength": 35,
+ "src": "documentElement.nodeName !== \"HTML\"",
+ "evalFalse": 172,
+ "evalTrue": 0
+ }
+ ],
+ "1116": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 4,
+ "src": "node",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 41,
+ "nodeLength": 26,
+ "src": "node.ownerDocument || node",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1119": [
+ null,
+ {
+ "position": 149,
+ "nodeLength": 62,
+ "src": "doc === document || doc.nodeType !== 9 || !doc.documentElement",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 149,
+ "nodeLength": 16,
+ "src": "doc === document",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 169,
+ "nodeLength": 42,
+ "src": "doc.nodeType !== 9 || !doc.documentElement",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 169,
+ "nodeLength": 18,
+ "src": "doc.nodeType !== 9",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1130": [
+ null,
+ {
+ "position": 487,
+ "nodeLength": 96,
+ "src": "preferredDoc !== document && (subWindow = document.defaultView) && subWindow.top !== subWindow",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 487,
+ "nodeLength": 25,
+ "src": "preferredDoc !== document",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1131": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 64,
+ "src": "(subWindow = document.defaultView) && subWindow.top !== subWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 558,
+ "nodeLength": 27,
+ "src": "subWindow.top !== subWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1134": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 26,
+ "src": "subWindow.addEventListener",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1138": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 21,
+ "src": "subWindow.attachEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1172": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 76,
+ "src": "!document.getElementsByName || !document.getElementsByName(expando).length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1176": [
+ null,
+ {
+ "position": 2000,
+ "nodeLength": 15,
+ "src": "support.getById",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1180": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 34,
+ "src": "elem.getAttribute(\"id\") === attrId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1184": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 63,
+ "src": "typeof context.getElementById !== \"undefined\" && documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 45,
+ "src": "typeof context.getElementById !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1186": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1193": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 80,
+ "src": "typeof elem.getAttributeNode !== \"undefined\" && elem.getAttributeNode(\"id\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16,
+ "nodeLength": 44,
+ "src": "typeof elem.getAttributeNode !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1195": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 29,
+ "src": "node && node.value === attrId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 117,
+ "nodeLength": 21,
+ "src": "node.value === attrId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1202": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 63,
+ "src": "typeof context.getElementById !== \"undefined\" && documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 45,
+ "src": "typeof context.getElementById !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1206": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1210": [
+ null,
+ {
+ "position": 85,
+ "nodeLength": 25,
+ "src": "node && node.value === id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 17,
+ "src": "node.value === id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1217": [
+ null,
+ {
+ "position": 257,
+ "nodeLength": 18,
+ "src": "(elem = elems[i++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1219": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 25,
+ "src": "node && node.value === id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62,
+ "nodeLength": 17,
+ "src": "node.value === id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1231": [
+ null,
+ {
+ "position": 3445,
+ "nodeLength": 28,
+ "src": "support.getElementsByTagName",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1233": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 51,
+ "src": "typeof context.getElementsByTagName !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "1237": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 11,
+ "src": "support.qsa",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1250": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 11,
+ "src": "tag === \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1251": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 20,
+ "src": "(elem = results[i++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1252": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1263": [
+ null,
+ {
+ "position": 4201,
+ "nodeLength": 211,
+ "src": "support.getElementsByClassName && function(className, context) {\n if (typeof context.getElementsByClassName !== \"undefined\" && documentIsHTML) {\n return context.getElementsByClassName(className);\n }\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1264": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 71,
+ "src": "typeof context.getElementsByClassName !== \"undefined\" && documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8,
+ "nodeLength": 53,
+ "src": "typeof context.getElementsByClassName !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1284": [
+ null,
+ {
+ "position": 4973,
+ "nodeLength": 56,
+ "src": "(support.qsa = rnative.test(document.querySelectorAll))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1301": [
+ null,
+ {
+ "position": 683,
+ "nodeLength": 50,
+ "src": "el.querySelectorAll(\"[msallowcapture^='']\").length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1307": [
+ null,
+ {
+ "position": 896,
+ "nodeLength": 41,
+ "src": "!el.querySelectorAll(\"[selected]\").length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1312": [
+ null,
+ {
+ "position": 1109,
+ "nodeLength": 55,
+ "src": "!el.querySelectorAll(\"[id~=\" + expando + \"-]\").length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1319": [
+ null,
+ {
+ "position": 1404,
+ "nodeLength": 39,
+ "src": "!el.querySelectorAll(\":checked\").length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1326": [
+ null,
+ {
+ "position": 1642,
+ "nodeLength": 52,
+ "src": "!el.querySelectorAll(\"a#\" + expando + \"+*\").length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1343": [
+ null,
+ {
+ "position": 454,
+ "nodeLength": 38,
+ "src": "el.querySelectorAll(\"[name=d]\").length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1349": [
+ null,
+ {
+ "position": 717,
+ "nodeLength": 44,
+ "src": "el.querySelectorAll(\":enabled\").length !== 2",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1356": [
+ null,
+ {
+ "position": 978,
+ "nodeLength": 45,
+ "src": "el.querySelectorAll(\":disabled\").length !== 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1366": [
+ null,
+ {
+ "position": 8114,
+ "nodeLength": 198,
+ "src": "(support.matchesSelector = rnative.test((matches = docElem.matches || docElem.webkitMatchesSelector || docElem.mozMatchesSelector || docElem.oMatchesSelector || docElem.msMatchesSelector)))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 8165,
+ "nodeLength": 143,
+ "src": "docElem.matches || docElem.webkitMatchesSelector || docElem.mozMatchesSelector || docElem.oMatchesSelector || docElem.msMatchesSelector",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1367": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 122,
+ "src": "docElem.webkitMatchesSelector || docElem.mozMatchesSelector || docElem.oMatchesSelector || docElem.msMatchesSelector",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1368": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 87,
+ "src": "docElem.mozMatchesSelector || docElem.oMatchesSelector || docElem.msMatchesSelector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1369": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 55,
+ "src": "docElem.oMatchesSelector || docElem.msMatchesSelector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1384": [
+ null,
+ {
+ "position": 8685,
+ "nodeLength": 53,
+ "src": "rbuggyQSA.length && new RegExp(rbuggyQSA.join(\"|\"))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1385": [
+ null,
+ {
+ "position": 8757,
+ "nodeLength": 61,
+ "src": "rbuggyMatches.length && new RegExp(rbuggyMatches.join(\"|\"))",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1394": [
+ null,
+ {
+ "position": 9092,
+ "nodeLength": 46,
+ "src": "hasCompare || rnative.test(docElem.contains)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1396": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 16,
+ "src": "a.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 68
+ }
+ ],
+ "1397": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 17,
+ "src": "b && b.parentNode",
+ "evalFalse": 27,
+ "evalTrue": 41
+ }
+ ],
+ "1398": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 175,
+ "src": "a === bup || !!(bup && bup.nodeType === 1 && (adown.contains ? adown.contains(bup) : a.compareDocumentPosition && a.compareDocumentPosition(bup) & 16))",
+ "evalFalse": 35,
+ "evalTrue": 33
+ },
+ {
+ "position": 97,
+ "nodeLength": 9,
+ "src": "a === bup",
+ "evalFalse": 68,
+ "evalTrue": 0
+ },
+ {
+ "position": 114,
+ "nodeLength": 157,
+ "src": "bup && bup.nodeType === 1 && (adown.contains ? adown.contains(bup) : a.compareDocumentPosition && a.compareDocumentPosition(bup) & 16)",
+ "evalFalse": 35,
+ "evalTrue": 33
+ },
+ {
+ "position": 121,
+ "nodeLength": 150,
+ "src": "bup.nodeType === 1 && (adown.contains ? adown.contains(bup) : a.compareDocumentPosition && a.compareDocumentPosition(bup) & 16)",
+ "evalFalse": 8,
+ "evalTrue": 33
+ },
+ {
+ "position": 121,
+ "nodeLength": 18,
+ "src": "bup.nodeType === 1",
+ "evalFalse": 6,
+ "evalTrue": 35
+ }
+ ],
+ "1399": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 14,
+ "src": "adown.contains",
+ "evalFalse": 0,
+ "evalTrue": 35
+ }
+ ],
+ "1401": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 66,
+ "src": "a.compareDocumentPosition && a.compareDocumentPosition(bup) & 16",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1405": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 1,
+ "src": "b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1406": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 17,
+ "src": "(b = b.parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1407": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 7,
+ "src": "b === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1419": [
+ null,
+ {
+ "position": 9721,
+ "nodeLength": 10,
+ "src": "hasCompare",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1423": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 57,
+ "evalTrue": 19
+ }
+ ],
+ "1430": [
+ null,
+ {
+ "position": 251,
+ "nodeLength": 7,
+ "src": "compare",
+ "evalFalse": 57,
+ "evalTrue": 0
+ }
+ ],
+ "1435": [
+ null,
+ {
+ "position": 368,
+ "nodeLength": 51,
+ "src": "(a.ownerDocument || a) === (b.ownerDocument || b)",
+ "evalFalse": 0,
+ "evalTrue": 57
+ },
+ {
+ "position": 368,
+ "nodeLength": 20,
+ "src": "a.ownerDocument || a",
+ "evalFalse": 0,
+ "evalTrue": 57
+ },
+ {
+ "position": 397,
+ "nodeLength": 20,
+ "src": "b.ownerDocument || b",
+ "evalFalse": 0,
+ "evalTrue": 57
+ }
+ ],
+ "1442": [
+ null,
+ {
+ "position": 543,
+ "nodeLength": 87,
+ "src": "compare & 1 || (!support.sortDetached && b.compareDocumentPosition(a) === compare)",
+ "evalFalse": 57,
+ "evalTrue": 0
+ }
+ ],
+ "1443": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 67,
+ "src": "!support.sortDetached && b.compareDocumentPosition(a) === compare",
+ "evalFalse": 57,
+ "evalTrue": 0
+ },
+ {
+ "position": 43,
+ "nodeLength": 42,
+ "src": "b.compareDocumentPosition(a) === compare",
+ "evalFalse": 57,
+ "evalTrue": 0
+ }
+ ],
+ "1446": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 79,
+ "src": "a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83,
+ "nodeLength": 14,
+ "src": "a === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 101,
+ "nodeLength": 61,
+ "src": "a.ownerDocument === preferredDoc && contains(preferredDoc, a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 101,
+ "nodeLength": 32,
+ "src": "a.ownerDocument === preferredDoc",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1449": [
+ null,
+ {
+ "position": 195,
+ "nodeLength": 79,
+ "src": "b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 195,
+ "nodeLength": 14,
+ "src": "b === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 213,
+ "nodeLength": 61,
+ "src": "b.ownerDocument === preferredDoc && contains(preferredDoc, b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 213,
+ "nodeLength": 32,
+ "src": "b.ownerDocument === preferredDoc",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1454": [
+ null,
+ {
+ "position": 339,
+ "nodeLength": 9,
+ "src": "sortInput",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1459": [
+ null,
+ {
+ "position": 1066,
+ "nodeLength": 11,
+ "src": "compare & 4",
+ "evalFalse": 57,
+ "evalTrue": 0
+ }
+ ],
+ "1463": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1476": [
+ null,
+ {
+ "position": 269,
+ "nodeLength": 12,
+ "src": "!aup || !bup",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1477": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 14,
+ "src": "a === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1478": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 14,
+ "src": "b === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1479": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 3,
+ "src": "aup",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1480": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 3,
+ "src": "bup",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1481": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 9,
+ "src": "sortInput",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1486": [
+ null,
+ {
+ "position": 527,
+ "nodeLength": 11,
+ "src": "aup === bup",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1492": [
+ null,
+ {
+ "position": 669,
+ "nodeLength": 21,
+ "src": "(cur = cur.parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1496": [
+ null,
+ {
+ "position": 743,
+ "nodeLength": 21,
+ "src": "(cur = cur.parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1501": [
+ null,
+ {
+ "position": 856,
+ "nodeLength": 15,
+ "src": "ap[i] === bp[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1505": [
+ null,
+ {
+ "position": 898,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1510": [
+ null,
+ {
+ "position": 151,
+ "nodeLength": 22,
+ "src": "ap[i] === preferredDoc",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1511": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 22,
+ "src": "bp[i] === preferredDoc",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1524": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 41,
+ "src": "(elem.ownerDocument || elem) !== document",
+ "evalFalse": 19,
+ "evalTrue": 0
+ },
+ {
+ "position": 41,
+ "nodeLength": 26,
+ "src": "elem.ownerDocument || elem",
+ "evalFalse": 0,
+ "evalTrue": 19
+ }
+ ],
+ "1531": [
+ null,
+ {
+ "position": 223,
+ "nodeLength": 181,
+ "src": "support.matchesSelector && documentIsHTML && !compilerCache[expr + \" \"] && (!rbuggyMatches || !rbuggyMatches.test(expr)) && (!rbuggyQSA || !rbuggyQSA.test(expr))",
+ "evalFalse": 0,
+ "evalTrue": 19
+ },
+ {
+ "position": 250,
+ "nodeLength": 154,
+ "src": "documentIsHTML && !compilerCache[expr + \" \"] && (!rbuggyMatches || !rbuggyMatches.test(expr)) && (!rbuggyQSA || !rbuggyQSA.test(expr))",
+ "evalFalse": 0,
+ "evalTrue": 19
+ }
+ ],
+ "1532": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 134,
+ "src": "!compilerCache[expr + \" \"] && (!rbuggyMatches || !rbuggyMatches.test(expr)) && (!rbuggyQSA || !rbuggyQSA.test(expr))",
+ "evalFalse": 0,
+ "evalTrue": 19
+ }
+ ],
+ "1533": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 98,
+ "src": "(!rbuggyMatches || !rbuggyMatches.test(expr)) && (!rbuggyQSA || !rbuggyQSA.test(expr))",
+ "evalFalse": 0,
+ "evalTrue": 19
+ },
+ {
+ "position": 310,
+ "nodeLength": 45,
+ "src": "!rbuggyMatches || !rbuggyMatches.test(expr)",
+ "evalFalse": 0,
+ "evalTrue": 19
+ }
+ ],
+ "1534": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 41,
+ "src": "!rbuggyQSA || !rbuggyQSA.test(expr)",
+ "evalFalse": 0,
+ "evalTrue": 19
+ }
+ ],
+ "1540": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 177,
+ "src": "ret || support.disconnectedMatch || elem.document && elem.document.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 19
+ },
+ {
+ "position": 123,
+ "nodeLength": 170,
+ "src": "support.disconnectedMatch || elem.document && elem.document.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 15
+ }
+ ],
+ "1543": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 46,
+ "src": "elem.document && elem.document.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 267,
+ "nodeLength": 29,
+ "src": "elem.document.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1549": [
+ null,
+ {
+ "position": 765,
+ "nodeLength": 51,
+ "src": "Sizzle(expr, document, null, [elem]).length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1554": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 47,
+ "src": "(context.ownerDocument || context) !== document",
+ "evalFalse": 68,
+ "evalTrue": 0
+ },
+ {
+ "position": 41,
+ "nodeLength": 32,
+ "src": "context.ownerDocument || context",
+ "evalFalse": 0,
+ "evalTrue": 68
+ }
+ ],
+ "1562": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 41,
+ "src": "(elem.ownerDocument || elem) !== document",
+ "evalFalse": 81,
+ "evalTrue": 0
+ },
+ {
+ "position": 41,
+ "nodeLength": 26,
+ "src": "elem.ownerDocument || elem",
+ "evalFalse": 0,
+ "evalTrue": 81
+ }
+ ],
+ "1568": [
+ null,
+ {
+ "position": 124,
+ "nodeLength": 56,
+ "src": "fn && hasOwn.call(Expr.attrHandle, name.toLowerCase())",
+ "evalFalse": 81,
+ "evalTrue": 0
+ }
+ ],
+ "1572": [
+ null,
+ {
+ "position": 361,
+ "nodeLength": 17,
+ "src": "val !== undefined",
+ "evalFalse": 81,
+ "evalTrue": 0
+ }
+ ],
+ "1574": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 37,
+ "src": "support.attributes || !documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 81
+ }
+ ],
+ "1576": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 51,
+ "src": "(val = elem.getAttributeNode(name)) && val.specified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1601": [
+ null,
+ {
+ "position": 175,
+ "nodeLength": 41,
+ "src": "!support.sortStable && results.slice(0)",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "1604": [
+ null,
+ {
+ "position": 253,
+ "nodeLength": 12,
+ "src": "hasDuplicate",
+ "evalFalse": 17,
+ "evalTrue": 3
+ }
+ ],
+ "1605": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 20,
+ "src": "(elem = results[i++])",
+ "evalFalse": 3,
+ "evalTrue": 13
+ }
+ ],
+ "1606": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 21,
+ "src": "elem === results[i]",
+ "evalFalse": 3,
+ "evalTrue": 10
+ }
+ ],
+ "1610": [
+ null,
+ {
+ "position": 120,
+ "nodeLength": 3,
+ "src": "j--",
+ "evalFalse": 3,
+ "evalTrue": 10
+ }
+ ],
+ "1632": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 9,
+ "src": "!nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1634": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 17,
+ "src": "(node = elem[i++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1638": [
+ null,
+ {
+ "position": 249,
+ "nodeLength": 51,
+ "src": "nodeType === 1 || nodeType === 9 || nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 249,
+ "nodeLength": 14,
+ "src": "nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 267,
+ "nodeLength": 33,
+ "src": "nodeType === 9 || nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 267,
+ "nodeLength": 14,
+ "src": "nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 285,
+ "nodeLength": 15,
+ "src": "nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1641": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 36,
+ "src": "typeof elem.textContent === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1645": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1649": [
+ null,
+ {
+ "position": 646,
+ "nodeLength": 32,
+ "src": "nodeType === 3 || nodeType === 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 646,
+ "nodeLength": 14,
+ "src": "nodeType === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 664,
+ "nodeLength": 14,
+ "src": "nodeType === 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1682": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 38,
+ "src": "match[3] || match[4] || match[5] || \"\"",
+ "evalFalse": 2,
+ "evalTrue": 1
+ },
+ {
+ "position": 152,
+ "nodeLength": 26,
+ "src": "match[4] || match[5] || \"\"",
+ "evalFalse": 2,
+ "evalTrue": 1
+ },
+ {
+ "position": 164,
+ "nodeLength": 14,
+ "src": "match[5] || \"\"",
+ "evalFalse": 2,
+ "evalTrue": 1
+ }
+ ],
+ "1684": [
+ null,
+ {
+ "position": 223,
+ "nodeLength": 17,
+ "src": "match[2] === \"~=\"",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "1704": [
+ null,
+ {
+ "position": 343,
+ "nodeLength": 32,
+ "src": "match[1].slice(0, 3) === \"nth\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1706": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 9,
+ "src": "!match[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1712": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 8,
+ "src": "match[4]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 247,
+ "nodeLength": 13,
+ "src": "match[6] || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 270,
+ "nodeLength": 41,
+ "src": "match[3] === \"even\" || match[3] === \"odd\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 270,
+ "nodeLength": 19,
+ "src": "match[3] === \"even\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 293,
+ "nodeLength": 18,
+ "src": "match[3] === \"odd\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1713": [
+ null,
+ {
+ "position": 337,
+ "nodeLength": 43,
+ "src": "(match[7] + match[8]) || match[3] === \"odd\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 362,
+ "nodeLength": 18,
+ "src": "match[3] === \"odd\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1716": [
+ null,
+ {
+ "position": 817,
+ "nodeLength": 8,
+ "src": "match[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1725": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 21,
+ "src": "!match[6] && match[2]",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1727": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 35,
+ "src": "matchExpr[\"CHILD\"].test(match[0])",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1732": [
+ null,
+ {
+ "position": 170,
+ "nodeLength": 8,
+ "src": "match[3]",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1733": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 26,
+ "src": "match[4] || match[5] || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28,
+ "nodeLength": 14,
+ "src": "match[5] || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1736": [
+ null,
+ {
+ "position": 297,
+ "nodeLength": 260,
+ "src": "unquoted && rpseudo.test(unquoted) && (excess = tokenize(unquoted, true)) && (excess = unquoted.indexOf(\")\", unquoted.length - excess) - unquoted.length)",
+ "evalFalse": 2,
+ "evalTrue": 0
+ },
+ {
+ "position": 309,
+ "nodeLength": 248,
+ "src": "rpseudo.test(unquoted) && (excess = tokenize(unquoted, true)) && (excess = unquoted.indexOf(\")\", unquoted.length - excess) - unquoted.length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1738": [
+ null,
+ {
+ "position": 78,
+ "nodeLength": 169,
+ "src": "(excess = tokenize(unquoted, true)) && (excess = unquoted.indexOf(\")\", unquoted.length - excess) - unquoted.length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1756": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 24,
+ "src": "nodeNameSelector === \"*\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1759": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 57,
+ "src": "elem.nodeName && elem.nodeName.toLowerCase() === nodeName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 40,
+ "src": "elem.nodeName.toLowerCase() === nodeName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1766": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 322,
+ "src": "pattern || (pattern = new RegExp(\"(^|\" + whitespace + \")\" + className + \"(\" + whitespace + \"|$)\")) && classCache(className, function(elem) {\n return pattern.test(typeof elem.className === \"string\" && elem.className || typeof elem.getAttribute !== \"undefined\" && elem.getAttribute(\"class\") || \"\");\n})",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "1767": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 306,
+ "src": "(pattern = new RegExp(\"(^|\" + whitespace + \")\" + className + \"(\" + whitespace + \"|$)\")) && classCache(className, function(elem) {\n return pattern.test(typeof elem.className === \"string\" && elem.className || typeof elem.getAttribute !== \"undefined\" && elem.getAttribute(\"class\") || \"\");\n})",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "1769": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 132,
+ "src": "typeof elem.className === \"string\" && elem.className || typeof elem.getAttribute !== \"undefined\" && elem.getAttribute(\"class\") || \"\"",
+ "evalFalse": 13,
+ "evalTrue": 32
+ },
+ {
+ "position": 21,
+ "nodeLength": 52,
+ "src": "typeof elem.className === \"string\" && elem.className",
+ "evalFalse": 13,
+ "evalTrue": 32
+ },
+ {
+ "position": 22,
+ "nodeLength": 34,
+ "src": "typeof elem.className === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 45
+ },
+ {
+ "position": 55,
+ "nodeLength": 76,
+ "src": "typeof elem.getAttribute !== \"undefined\" && elem.getAttribute(\"class\") || \"\"",
+ "evalFalse": 13,
+ "evalTrue": 0
+ },
+ {
+ "position": 78,
+ "nodeLength": 70,
+ "src": "typeof elem.getAttribute !== \"undefined\" && elem.getAttribute(\"class\")",
+ "evalFalse": 13,
+ "evalTrue": 0
+ },
+ {
+ "position": 79,
+ "nodeLength": 40,
+ "src": "typeof elem.getAttribute !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 13
+ }
+ ],
+ "1777": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 14,
+ "src": "result == null",
+ "evalFalse": 0,
+ "evalTrue": 63
+ }
+ ],
+ "1778": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 17,
+ "src": "operator === \"!=\"",
+ "evalFalse": 0,
+ "evalTrue": 63
+ }
+ ],
+ "1780": [
+ null,
+ {
+ "position": 120,
+ "nodeLength": 9,
+ "src": "!operator",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1786": [
+ null,
+ {
+ "position": 189,
+ "nodeLength": 16,
+ "src": "operator === \"=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 208,
+ "nodeLength": 16,
+ "src": "result === check",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1787": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 17,
+ "src": "operator === \"!=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62,
+ "nodeLength": 16,
+ "src": "result !== check",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1788": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 17,
+ "src": "operator === \"^=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63,
+ "nodeLength": 38,
+ "src": "check && result.indexOf(check) === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 29,
+ "src": "result.indexOf(check) === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1789": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 17,
+ "src": "operator === \"*=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85,
+ "nodeLength": 37,
+ "src": "check && result.indexOf(check) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94,
+ "nodeLength": 28,
+ "src": "result.indexOf(check) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1790": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 17,
+ "src": "operator === \"$=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84,
+ "nodeLength": 48,
+ "src": "check && result.slice(-check.length) === check",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 39,
+ "src": "result.slice(-check.length) === check",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1791": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 17,
+ "src": "operator === \"~=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 97,
+ "nodeLength": 70,
+ "src": "(\" \" + result.replace(rwhitespace, \" \") + \" \").indexOf(check) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1792": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 17,
+ "src": "operator === \"|=\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119,
+ "nodeLength": 71,
+ "src": "result === check || result.slice(0, check.length + 1) === check + \"-\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119,
+ "nodeLength": 16,
+ "src": "result === check",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 139,
+ "nodeLength": 51,
+ "src": "result.slice(0, check.length + 1) === check + \"-\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1798": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 28,
+ "src": "type.slice(0, 3) !== \"nth\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1799": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 27,
+ "src": "type.slice(-4) !== \"last\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1800": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 18,
+ "src": "what === \"of-type\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1802": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 25,
+ "src": "first === 1 && last === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 134,
+ "nodeLength": 11,
+ "src": "first === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 149,
+ "nodeLength": 10,
+ "src": "last === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1811": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 18,
+ "src": "simple !== forward",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1813": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 37,
+ "src": "ofType && elem.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1814": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 15,
+ "src": "!xml && !ofType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1817": [
+ null,
+ {
+ "position": 283,
+ "nodeLength": 6,
+ "src": "parent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1820": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 6,
+ "src": "simple",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1821": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 3,
+ "src": "dir",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1823": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 19,
+ "src": "(node = node[dir])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1824": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 87,
+ "src": "ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15,
+ "nodeLength": 6,
+ "src": "ofType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1825": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 36,
+ "src": "node.nodeName.toLowerCase() === name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1826": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 19,
+ "src": "node.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1832": [
+ null,
+ {
+ "position": 306,
+ "nodeLength": 42,
+ "src": "type === \"only\" && !start && \"nextSibling\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 306,
+ "nodeLength": 15,
+ "src": "type === \"only\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 325,
+ "nodeLength": 23,
+ "src": "!start && \"nextSibling\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1837": [
+ null,
+ {
+ "position": 494,
+ "nodeLength": 7,
+ "src": "forward",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1840": [
+ null,
+ {
+ "position": 619,
+ "nodeLength": 19,
+ "src": "forward && useCache",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1846": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 41,
+ "src": "node[expando] || (node[expando] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1850": [
+ null,
+ {
+ "position": 291,
+ "nodeLength": 73,
+ "src": "outerCache[node.uniqueID] || (outerCache[node.uniqueID] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1853": [
+ null,
+ {
+ "position": 382,
+ "nodeLength": 25,
+ "src": "uniqueCache[type] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1854": [
+ null,
+ {
+ "position": 428,
+ "nodeLength": 36,
+ "src": "cache[0] === dirruns && cache[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 428,
+ "nodeLength": 22,
+ "src": "cache[0] === dirruns",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1855": [
+ null,
+ {
+ "position": 480,
+ "nodeLength": 23,
+ "src": "nodeIndex && cache[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1856": [
+ null,
+ {
+ "position": 519,
+ "nodeLength": 43,
+ "src": "nodeIndex && parent.childNodes[nodeIndex]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1858": [
+ null,
+ {
+ "position": 581,
+ "nodeLength": 145,
+ "src": "(node = ++nodeIndex && node && node[dir] || (diff = nodeIndex = 0) || start.pop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 588,
+ "nodeLength": 137,
+ "src": "++nodeIndex && node && node[dir] || (diff = nodeIndex = 0) || start.pop()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 588,
+ "nodeLength": 34,
+ "src": "++nodeIndex && node && node[dir]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 603,
+ "nodeLength": 19,
+ "src": "node && node[dir]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1861": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 36,
+ "src": "(diff = nodeIndex = 0) || start.pop()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1864": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 46,
+ "src": "node.nodeType === 1 && ++diff && node === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 19,
+ "src": "node.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 97,
+ "nodeLength": 23,
+ "src": "++diff && node === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 107,
+ "nodeLength": 13,
+ "src": "node === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1872": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 8,
+ "src": "useCache",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1875": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 41,
+ "src": "node[expando] || (node[expando] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1879": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 74,
+ "src": "outerCache[node.uniqueID] || (outerCache[node.uniqueID] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1882": [
+ null,
+ {
+ "position": 333,
+ "nodeLength": 25,
+ "src": "uniqueCache[type] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1883": [
+ null,
+ {
+ "position": 380,
+ "nodeLength": 36,
+ "src": "cache[0] === dirruns && cache[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 380,
+ "nodeLength": 22,
+ "src": "cache[0] === dirruns",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1889": [
+ null,
+ {
+ "position": 643,
+ "nodeLength": 14,
+ "src": "diff === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1891": [
+ null,
+ {
+ "position": 86,
+ "nodeLength": 92,
+ "src": "(node = ++nodeIndex && node && node[dir] || (diff = nodeIndex = 0) || start.pop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 84,
+ "src": "++nodeIndex && node && node[dir] || (diff = nodeIndex = 0) || start.pop()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 34,
+ "src": "++nodeIndex && node && node[dir]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 108,
+ "nodeLength": 19,
+ "src": "node && node[dir]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1892": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 36,
+ "src": "(diff = nodeIndex = 0) || start.pop()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1894": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 109,
+ "src": "(ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1) && ++diff",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 6,
+ "src": "ofType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1895": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 36,
+ "src": "node.nodeName.toLowerCase() === name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1896": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 19,
+ "src": "node.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1900": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 8,
+ "src": "useCache",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1901": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 41,
+ "src": "node[expando] || (node[expando] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1905": [
+ null,
+ {
+ "position": 194,
+ "nodeLength": 77,
+ "src": "outerCache[node.uniqueID] || (outerCache[node.uniqueID] = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1911": [
+ null,
+ {
+ "position": 440,
+ "nodeLength": 13,
+ "src": "node === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1921": [
+ null,
+ {
+ "position": 3205,
+ "nodeLength": 61,
+ "src": "diff === first || (diff % first === 0 && diff / first >= 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3205,
+ "nodeLength": 14,
+ "src": "diff === first",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3225,
+ "nodeLength": 39,
+ "src": "diff % first === 0 && diff / first >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3225,
+ "nodeLength": 18,
+ "src": "diff % first === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3247,
+ "nodeLength": 17,
+ "src": "diff / first >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1932": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 121,
+ "src": "Expr.pseudos[pseudo] || Expr.setFilters[pseudo.toLowerCase()] || Sizzle.error(\"unsupported pseudo: \" + pseudo)",
+ "evalFalse": 0,
+ "evalTrue": 2
+ },
+ {
+ "position": 44,
+ "nodeLength": 95,
+ "src": "Expr.setFilters[pseudo.toLowerCase()] || Sizzle.error(\"unsupported pseudo: \" + pseudo)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1938": [
+ null,
+ {
+ "position": 536,
+ "nodeLength": 13,
+ "src": "fn[expando]",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "1943": [
+ null,
+ {
+ "position": 641,
+ "nodeLength": 13,
+ "src": "fn.length > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1945": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 54,
+ "src": "Expr.setFilters.hasOwnProperty(pseudo.toLowerCase())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1950": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1974": [
+ null,
+ {
+ "position": 222,
+ "nodeLength": 18,
+ "src": "matcher[expando]",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1981": [
+ null,
+ {
+ "position": 149,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1982": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 20,
+ "src": "(elem = unmatched[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1998": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 35,
+ "src": "Sizzle(selector, elem).length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2005": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 76,
+ "src": "(elem.textContent || elem.innerText || getText(elem)).indexOf(text) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14,
+ "nodeLength": 53,
+ "src": "elem.textContent || elem.innerText || getText(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 33,
+ "src": "elem.innerText || getText(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2018": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 29,
+ "src": "!ridentifier.test(lang || \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71,
+ "nodeLength": 10,
+ "src": "lang || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2025": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 111,
+ "src": "(elemLang = documentIsHTML ? elem.lang : elem.getAttribute(\"xml:lang\") || elem.getAttribute(\"lang\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23,
+ "nodeLength": 14,
+ "src": "documentIsHTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2027": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 58,
+ "src": "elem.getAttribute(\"xml:lang\") || elem.getAttribute(\"lang\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2030": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 57,
+ "src": "elemLang === lang || elemLang.indexOf(lang + \"-\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56,
+ "nodeLength": 17,
+ "src": "elemLang === lang",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77,
+ "nodeLength": 36,
+ "src": "elemLang.indexOf(lang + \"-\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2032": [
+ null,
+ {
+ "position": 267,
+ "nodeLength": 46,
+ "src": "(elem = elem.parentNode) && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 319,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2039": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 39,
+ "src": "window.location && window.location.hash",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2040": [
+ null,
+ {
+ "position": 66,
+ "nodeLength": 35,
+ "src": "hash && hash.slice(1) === elem.id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 27,
+ "src": "hash.slice(1) === elem.id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2044": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 16,
+ "src": "elem === docElem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2048": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 126,
+ "src": "elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 31,
+ "src": "elem === document.activeElement",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 90,
+ "src": "(!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 41,
+ "src": "!document.hasFocus || document.hasFocus()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96,
+ "nodeLength": 40,
+ "src": "elem.type || elem.href || ~elem.tabIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 109,
+ "nodeLength": 27,
+ "src": "elem.href || ~elem.tabIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2059": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 85,
+ "src": "(nodeName === \"input\" && !!elem.checked) || (nodeName === \"option\" && !!elem.selected)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 201,
+ "nodeLength": 38,
+ "src": "nodeName === \"input\" && !!elem.checked",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 201,
+ "nodeLength": 20,
+ "src": "nodeName === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 245,
+ "nodeLength": 40,
+ "src": "nodeName === \"option\" && !!elem.selected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 245,
+ "nodeLength": 21,
+ "src": "nodeName === \"option\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2065": [
+ null,
+ {
+ "position": 103,
+ "nodeLength": 15,
+ "src": "elem.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2069": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 22,
+ "src": "elem.selected === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2078": [
+ null,
+ {
+ "position": 323,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2079": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 17,
+ "src": "elem.nodeType < 6",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2101": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 63,
+ "src": "name === \"input\" && elem.type === \"button\" || name === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54,
+ "nodeLength": 42,
+ "src": "name === \"input\" && elem.type === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54,
+ "nodeLength": 16,
+ "src": "name === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 22,
+ "src": "elem.type === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 100,
+ "nodeLength": 17,
+ "src": "name === \"button\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2106": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 260,
+ "src": "elem.nodeName.toLowerCase() === \"input\" && elem.type === \"text\" && ((attr = elem.getAttribute(\"type\")) == null || attr.toLowerCase() === \"text\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 39,
+ "src": "elem.nodeName.toLowerCase() === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2107": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 213,
+ "src": "elem.type === \"text\" && ((attr = elem.getAttribute(\"type\")) == null || attr.toLowerCase() === \"text\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73,
+ "nodeLength": 20,
+ "src": "elem.type === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2111": [
+ null,
+ {
+ "position": 136,
+ "nodeLength": 74,
+ "src": "(attr = elem.getAttribute(\"type\")) == null || attr.toLowerCase() === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 136,
+ "nodeLength": 41,
+ "src": "(attr = elem.getAttribute(\"type\")) == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181,
+ "nodeLength": 29,
+ "src": "attr.toLowerCase() === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2124": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 12,
+ "src": "argument < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2129": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2137": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2144": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 12,
+ "src": "argument < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2145": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 8,
+ "src": "--i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2152": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 12,
+ "src": "argument < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2153": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 12,
+ "src": "++i < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2181": [
+ null,
+ {
+ "position": 113,
+ "nodeLength": 6,
+ "src": "cached",
+ "evalFalse": 10,
+ "evalTrue": 50
+ }
+ ],
+ "2182": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 9,
+ "src": "parseOnly",
+ "evalFalse": 50,
+ "evalTrue": 0
+ }
+ ],
+ "2189": [
+ null,
+ {
+ "position": 245,
+ "nodeLength": 5,
+ "src": "soFar",
+ "evalFalse": 10,
+ "evalTrue": 13
+ }
+ ],
+ "2192": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 42,
+ "src": "!matched || (match = rcomma.exec(soFar))",
+ "evalFalse": 0,
+ "evalTrue": 13
+ }
+ ],
+ "2193": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 5,
+ "src": "match",
+ "evalFalse": 10,
+ "evalTrue": 3
+ }
+ ],
+ "2195": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 39,
+ "src": "soFar.slice(match[0].length) || soFar",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "2203": [
+ null,
+ {
+ "position": 286,
+ "nodeLength": 35,
+ "src": "(match = rcombinators.exec(soFar))",
+ "evalFalse": 13,
+ "evalTrue": 0
+ }
+ ],
+ "2215": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 110,
+ "src": "(match = matchExpr[type].exec(soFar)) && (!preFilters[type] || (match = preFilters[type](match)))",
+ "evalFalse": 64,
+ "evalTrue": 14
+ },
+ {
+ "position": 55,
+ "nodeLength": 64,
+ "src": "!preFilters[type] || (match = preFilters[type](match))",
+ "evalFalse": 0,
+ "evalTrue": 14
+ }
+ ],
+ "2227": [
+ null,
+ {
+ "position": 872,
+ "nodeLength": 8,
+ "src": "!matched",
+ "evalFalse": 13,
+ "evalTrue": 0
+ }
+ ],
+ "2235": [
+ null,
+ {
+ "position": 1282,
+ "nodeLength": 9,
+ "src": "parseOnly",
+ "evalFalse": 10,
+ "evalTrue": 0
+ }
+ ],
+ "2237": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 5,
+ "src": "soFar",
+ "evalFalse": 10,
+ "evalTrue": 0
+ }
+ ],
+ "2247": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 120,
+ "evalTrue": 143
+ }
+ ],
+ "2256": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 11,
+ "src": "skip || dir",
+ "evalFalse": 0,
+ "evalTrue": 17
+ }
+ ],
+ "2257": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 28,
+ "src": "base && key === \"parentNode\"",
+ "evalFalse": 1,
+ "evalTrue": 16
+ },
+ {
+ "position": 101,
+ "nodeLength": 20,
+ "src": "key === \"parentNode\"",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "2260": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 16,
+ "src": "combinator.first",
+ "evalFalse": 17,
+ "evalTrue": 0
+ }
+ ],
+ "2263": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 19,
+ "src": "(elem = elem[dir])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2264": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 39,
+ "src": "elem.nodeType === 1 || checkNonElements",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2277": [
+ null,
+ {
+ "position": 184,
+ "nodeLength": 3,
+ "src": "xml",
+ "evalFalse": 63,
+ "evalTrue": 0
+ }
+ ],
+ "2278": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 19,
+ "src": "(elem = elem[dir])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2279": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 39,
+ "src": "elem.nodeType === 1 || checkNonElements",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2280": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 29,
+ "src": "matcher(elem, context, xml)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2286": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 19,
+ "src": "(elem = elem[dir])",
+ "evalFalse": 0,
+ "evalTrue": 84
+ }
+ ],
+ "2287": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 39,
+ "src": "elem.nodeType === 1 || checkNonElements",
+ "evalFalse": 0,
+ "evalTrue": 84
+ },
+ {
+ "position": 11,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 84
+ }
+ ],
+ "2288": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 41,
+ "src": "elem[expando] || (elem[expando] = {})",
+ "evalFalse": 0,
+ "evalTrue": 84
+ }
+ ],
+ "2292": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 65,
+ "src": "outerCache[elem.uniqueID] || (outerCache[elem.uniqueID] = {})",
+ "evalFalse": 0,
+ "evalTrue": 84
+ }
+ ],
+ "2294": [
+ null,
+ {
+ "position": 253,
+ "nodeLength": 44,
+ "src": "skip && skip === elem.nodeName.toLowerCase()",
+ "evalFalse": 84,
+ "evalTrue": 0
+ },
+ {
+ "position": 261,
+ "nodeLength": 36,
+ "src": "skip === elem.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2295": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 19,
+ "src": "elem[dir] || elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2296": [
+ null,
+ {
+ "position": 357,
+ "nodeLength": 96,
+ "src": "(oldCache = uniqueCache[key]) && oldCache[0] === dirruns && oldCache[1] === doneName",
+ "evalFalse": 26,
+ "evalTrue": 58
+ }
+ ],
+ "2297": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 55,
+ "src": "oldCache[0] === dirruns && oldCache[1] === doneName",
+ "evalFalse": 18,
+ "evalTrue": 58
+ },
+ {
+ "position": 400,
+ "nodeLength": 25,
+ "src": "oldCache[0] === dirruns",
+ "evalFalse": 18,
+ "evalTrue": 58
+ },
+ {
+ "position": 28,
+ "nodeLength": 26,
+ "src": "oldCache[1] === doneName",
+ "evalFalse": 0,
+ "evalTrue": 58
+ }
+ ],
+ "2306": [
+ null,
+ {
+ "position": 199,
+ "nodeLength": 46,
+ "src": "(newCache[2] = matcher(elem, context, xml))",
+ "evalFalse": 21,
+ "evalTrue": 5
+ }
+ ],
+ "2318": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 19,
+ "src": "matchers.length > 1",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "2321": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 108,
+ "evalTrue": 229
+ }
+ ],
+ "2322": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 34,
+ "src": "!matchers[i](elem, context, xml)",
+ "evalFalse": 216,
+ "evalTrue": 13
+ }
+ ],
+ "2334": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2345": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 11,
+ "src": "map != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2347": [
+ null,
+ {
+ "position": 102,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2348": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 20,
+ "src": "(elem = unmatched[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2349": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 39,
+ "src": "!filter || filter(elem, context, xml)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2351": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 6,
+ "src": "mapped",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2362": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 36,
+ "src": "postFilter && !postFilter[expando]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2365": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 36,
+ "src": "postFinder && !postFinder[expando]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2375": [
+ null,
+ {
+ "position": 144,
+ "nodeLength": 89,
+ "src": "seed || multipleContexts(selector || \"*\", context.nodeType ? [context] : context, [])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 170,
+ "nodeLength": 15,
+ "src": "selector || \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187,
+ "nodeLength": 16,
+ "src": "context.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2378": [
+ null,
+ {
+ "position": 339,
+ "nodeLength": 34,
+ "src": "preFilter && (seed || !selector)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 354,
+ "nodeLength": 17,
+ "src": "seed || !selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2382": [
+ null,
+ {
+ "position": 461,
+ "nodeLength": 7,
+ "src": "matcher",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2384": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 62,
+ "src": "postFinder || (seed ? preFilter : preexisting || postFilter)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126,
+ "nodeLength": 4,
+ "src": "seed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 145,
+ "nodeLength": 25,
+ "src": "preexisting || postFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2394": [
+ null,
+ {
+ "position": 807,
+ "nodeLength": 7,
+ "src": "matcher",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2399": [
+ null,
+ {
+ "position": 904,
+ "nodeLength": 10,
+ "src": "postFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2405": [
+ null,
+ {
+ "position": 182,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2406": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 15,
+ "src": "(elem = temp[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2412": [
+ null,
+ {
+ "position": 1228,
+ "nodeLength": 4,
+ "src": "seed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2413": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 23,
+ "src": "postFinder || preFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2414": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 10,
+ "src": "postFinder",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2418": [
+ null,
+ {
+ "position": 148,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2419": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 21,
+ "src": "(elem = matcherOut[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2429": [
+ null,
+ {
+ "position": 517,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2430": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 91,
+ "src": "(elem = matcherOut[i]) && (temp = postFinder ? indexOf(seed, elem) : preMap[i]) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2431": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 59,
+ "src": "(temp = postFinder ? indexOf(seed, elem) : preMap[i]) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 10,
+ "src": "postFinder",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2441": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 22,
+ "src": "matcherOut === results",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2445": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 10,
+ "src": "postFinder",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2458": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 37,
+ "src": "leadingRelative || Expr.relative[\" \"]",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "2459": [
+ null,
+ {
+ "position": 171,
+ "nodeLength": 15,
+ "src": "leadingRelative",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2463": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 21,
+ "src": "elem === checkContext",
+ "evalFalse": 21,
+ "evalTrue": 5
+ }
+ ],
+ "2466": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 34,
+ "src": "indexOf(checkContext, elem) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2469": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 193,
+ "src": "(!leadingRelative && (xml || context !== outermostContext)) || ((checkContext = context).nodeType ? matchContext(elem, context, xml) : matchAnyContext(elem, context, xml))",
+ "evalFalse": 0,
+ "evalTrue": 108
+ },
+ {
+ "position": 16,
+ "nodeLength": 59,
+ "src": "!leadingRelative && (xml || context !== outermostContext)",
+ "evalFalse": 63,
+ "evalTrue": 45
+ },
+ {
+ "position": 38,
+ "nodeLength": 35,
+ "src": "xml || context !== outermostContext",
+ "evalFalse": 63,
+ "evalTrue": 45
+ },
+ {
+ "position": 45,
+ "nodeLength": 28,
+ "src": "context !== outermostContext",
+ "evalFalse": 63,
+ "evalTrue": 45
+ }
+ ],
+ "2470": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 32,
+ "src": "(checkContext = context).nodeType",
+ "evalFalse": 0,
+ "evalTrue": 63
+ }
+ ],
+ "2478": [
+ null,
+ {
+ "position": 898,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 8,
+ "evalTrue": 8
+ }
+ ],
+ "2479": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 42,
+ "src": "(matcher = Expr.relative[tokens[i].type])",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2485": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 18,
+ "src": "matcher[expando]",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2488": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 7,
+ "src": "j < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2489": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 31,
+ "src": "Expr.relative[tokens[j].type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2494": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 35,
+ "src": "i > 1 && elementMatcher(matchers)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 5,
+ "src": "i > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2495": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 236,
+ "src": "i > 1 && toSelector(tokens.slice(0, i - 1).concat({\n value: tokens[i - 2].type === \" \" ? \"*\" : \"\"})).replace(rtrim, \"$1\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 5,
+ "src": "i > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2497": [
+ null,
+ {
+ "position": 154,
+ "nodeLength": 28,
+ "src": "tokens[i - 2].type === \" \"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2500": [
+ null,
+ {
+ "position": 315,
+ "nodeLength": 50,
+ "src": "i < j && matcherFromTokens(tokens.slice(i, j))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 323,
+ "nodeLength": 5,
+ "src": "i < j",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2501": [
+ null,
+ {
+ "position": 372,
+ "nodeLength": 60,
+ "src": "j < len && matcherFromTokens((tokens = tokens.slice(j)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 380,
+ "nodeLength": 7,
+ "src": "j < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2502": [
+ null,
+ {
+ "position": 439,
+ "nodeLength": 31,
+ "src": "j < len && toSelector(tokens)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 447,
+ "nodeLength": 7,
+ "src": "j < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2513": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 22,
+ "src": "setMatchers.length > 0",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2514": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 26,
+ "src": "elementMatchers.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "2519": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 10,
+ "src": "seed && []",
+ "evalFalse": 5,
+ "evalTrue": 29
+ }
+ ],
+ "2523": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 55,
+ "src": "seed || byElement && Expr.find[\"TAG\"](\"*\", outermost)",
+ "evalFalse": 0,
+ "evalTrue": 34
+ },
+ {
+ "position": 232,
+ "nodeLength": 47,
+ "src": "byElement && Expr.find[\"TAG\"](\"*\", outermost)",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "2525": [
+ null,
+ {
+ "position": 374,
+ "nodeLength": 21,
+ "src": "contextBackup == null",
+ "evalFalse": 13,
+ "evalTrue": 21
+ },
+ {
+ "position": 402,
+ "nodeLength": 20,
+ "src": "Math.random() || 0.1",
+ "evalFalse": 0,
+ "evalTrue": 13
+ }
+ ],
+ "2528": [
+ null,
+ {
+ "position": 463,
+ "nodeLength": 9,
+ "src": "outermost",
+ "evalFalse": 13,
+ "evalTrue": 21
+ }
+ ],
+ "2529": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 44,
+ "src": "context === document || context || outermost",
+ "evalFalse": 0,
+ "evalTrue": 21
+ },
+ {
+ "position": 24,
+ "nodeLength": 20,
+ "src": "context === document",
+ "evalFalse": 21,
+ "evalTrue": 0
+ },
+ {
+ "position": 48,
+ "nodeLength": 20,
+ "src": "context || outermost",
+ "evalFalse": 0,
+ "evalTrue": 21
+ }
+ ],
+ "2535": [
+ null,
+ {
+ "position": 746,
+ "nodeLength": 38,
+ "src": "i !== len && (elem = elems[i]) != null",
+ "evalFalse": 34,
+ "evalTrue": 121
+ },
+ {
+ "position": 746,
+ "nodeLength": 9,
+ "src": "i !== len",
+ "evalFalse": 29,
+ "evalTrue": 126
+ },
+ {
+ "position": 760,
+ "nodeLength": 24,
+ "src": "(elem = elems[i]) != null",
+ "evalFalse": 5,
+ "evalTrue": 121
+ }
+ ],
+ "2536": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 17,
+ "src": "byElement && elem",
+ "evalFalse": 0,
+ "evalTrue": 121
+ }
+ ],
+ "2538": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 43,
+ "src": "!context && elem.ownerDocument !== document",
+ "evalFalse": 121,
+ "evalTrue": 0
+ },
+ {
+ "position": 35,
+ "nodeLength": 31,
+ "src": "elem.ownerDocument !== document",
+ "evalFalse": 58,
+ "evalTrue": 0
+ }
+ ],
+ "2542": [
+ null,
+ {
+ "position": 148,
+ "nodeLength": 31,
+ "src": "(matcher = elementMatchers[j++])",
+ "evalFalse": 13,
+ "evalTrue": 121
+ }
+ ],
+ "2543": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 40,
+ "src": "matcher(elem, context || document, xml)",
+ "evalFalse": 13,
+ "evalTrue": 108
+ },
+ {
+ "position": 27,
+ "nodeLength": 19,
+ "src": "context || document",
+ "evalFalse": 0,
+ "evalTrue": 121
+ }
+ ],
+ "2548": [
+ null,
+ {
+ "position": 308,
+ "nodeLength": 9,
+ "src": "outermost",
+ "evalFalse": 13,
+ "evalTrue": 108
+ }
+ ],
+ "2554": [
+ null,
+ {
+ "position": 455,
+ "nodeLength": 5,
+ "src": "bySet",
+ "evalFalse": 121,
+ "evalTrue": 0
+ }
+ ],
+ "2556": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 24,
+ "src": "(elem = !matcher && elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77,
+ "nodeLength": 16,
+ "src": "!matcher && elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2561": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 4,
+ "src": "seed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2578": [
+ null,
+ {
+ "position": 2136,
+ "nodeLength": 27,
+ "src": "bySet && i !== matchedCount",
+ "evalFalse": 34,
+ "evalTrue": 0
+ },
+ {
+ "position": 2145,
+ "nodeLength": 18,
+ "src": "i !== matchedCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2580": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 27,
+ "src": "(matcher = setMatchers[j++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2584": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 4,
+ "src": "seed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2586": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 16,
+ "src": "matchedCount > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2587": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2588": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 32,
+ "src": "!(unmatched[i] || setMatched[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15,
+ "nodeLength": 29,
+ "src": "unmatched[i] || setMatched[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2602": [
+ null,
+ {
+ "position": 659,
+ "nodeLength": 93,
+ "src": "outermost && !seed && setMatched.length > 0 && (matchedCount + setMatchers.length) > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 672,
+ "nodeLength": 80,
+ "src": "!seed && setMatched.length > 0 && (matchedCount + setMatchers.length) > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 681,
+ "nodeLength": 71,
+ "src": "setMatched.length > 0 && (matchedCount + setMatchers.length) > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 681,
+ "nodeLength": 21,
+ "src": "setMatched.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2603": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 39,
+ "src": "(matchedCount + setMatchers.length) > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2610": [
+ null,
+ {
+ "position": 3038,
+ "nodeLength": 9,
+ "src": "outermost",
+ "evalFalse": 13,
+ "evalTrue": 21
+ }
+ ],
+ "2618": [
+ null,
+ {
+ "position": 3309,
+ "nodeLength": 5,
+ "src": "bySet",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2629": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 7,
+ "src": "!cached",
+ "evalFalse": 15,
+ "evalTrue": 8
+ }
+ ],
+ "2631": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 6,
+ "src": "!match",
+ "evalFalse": 1,
+ "evalTrue": 7
+ }
+ ],
+ "2635": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 8,
+ "evalTrue": 8
+ }
+ ],
+ "2637": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 17,
+ "src": "cached[expando]",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2664": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 42,
+ "src": "typeof selector === \"function\" && selector",
+ "evalFalse": 21,
+ "evalTrue": 0
+ },
+ {
+ "position": 46,
+ "nodeLength": 30,
+ "src": "typeof selector === \"function\"",
+ "evalFalse": 21,
+ "evalTrue": 0
+ }
+ ],
+ "2665": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 63,
+ "src": "!seed && tokenize((selector = compiled.selector || selector))",
+ "evalFalse": 16,
+ "evalTrue": 5
+ },
+ {
+ "position": 131,
+ "nodeLength": 29,
+ "src": "compiled.selector || selector",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "2667": [
+ null,
+ {
+ "position": 180,
+ "nodeLength": 13,
+ "src": "results || []",
+ "evalFalse": 0,
+ "evalTrue": 21
+ }
+ ],
+ "2671": [
+ null,
+ {
+ "position": 335,
+ "nodeLength": 18,
+ "src": "match.length === 1",
+ "evalFalse": 16,
+ "evalTrue": 5
+ }
+ ],
+ "2675": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 137,
+ "src": "tokens.length > 2 && (token = tokens[0]).type === \"ID\" && context.nodeType === 9 && documentIsHTML && Expr.relative[tokens[1].type]",
+ "evalFalse": 5,
+ "evalTrue": 0
+ },
+ {
+ "position": 114,
+ "nodeLength": 17,
+ "src": "tokens.length > 2",
+ "evalFalse": 5,
+ "evalTrue": 0
+ },
+ {
+ "position": 136,
+ "nodeLength": 115,
+ "src": "(token = tokens[0]).type === \"ID\" && context.nodeType === 9 && documentIsHTML && Expr.relative[tokens[1].type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 136,
+ "nodeLength": 32,
+ "src": "(token = tokens[0]).type === \"ID\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2676": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 75,
+ "src": "context.nodeType === 9 && documentIsHTML && Expr.relative[tokens[1].type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179,
+ "nodeLength": 22,
+ "src": "context.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 49,
+ "src": "documentIsHTML && Expr.relative[tokens[1].type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2678": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 80,
+ "src": "Expr.find[\"ID\"](token.matches[0].replace(runescape, funescape), context) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2679": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 8,
+ "src": "!context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2683": [
+ null,
+ {
+ "position": 236,
+ "nodeLength": 8,
+ "src": "compiled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2691": [
+ null,
+ {
+ "position": 664,
+ "nodeLength": 42,
+ "src": "matchExpr[\"needsContext\"].test(selector)",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "2692": [
+ null,
+ {
+ "position": 738,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 5,
+ "evalTrue": 5
+ }
+ ],
+ "2696": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 36,
+ "src": "Expr.relative[(type = token.type)]",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "2699": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 25,
+ "src": "(find = Expr.find[type])",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "2701": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 159,
+ "src": "(seed = find(token.matches[0].replace(runescape, funescape), rsibling.test(tokens[0].type) && testContext(context.parentNode) || context))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2703": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 79,
+ "src": "rsibling.test(tokens[0].type) && testContext(context.parentNode) || context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73,
+ "nodeLength": 68,
+ "src": "rsibling.test(tokens[0].type) && testContext(context.parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2708": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 35,
+ "src": "seed.length && toSelector(tokens)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2709": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 9,
+ "src": "!selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2722": [
+ null,
+ {
+ "position": 1921,
+ "nodeLength": 38,
+ "src": "compiled || compile(selector, match)",
+ "evalFalse": 0,
+ "evalTrue": 21
+ }
+ ],
+ "2727": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 85,
+ "src": "!context || rsibling.test(selector) && testContext(context.parentNode) || context",
+ "evalFalse": 0,
+ "evalTrue": 21
+ },
+ {
+ "position": 105,
+ "nodeLength": 73,
+ "src": "rsibling.test(selector) && testContext(context.parentNode) || context",
+ "evalFalse": 0,
+ "evalTrue": 5
+ },
+ {
+ "position": 105,
+ "nodeLength": 62,
+ "src": "rsibling.test(selector) && testContext(context.parentNode)",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "2735": [
+ null,
+ {
+ "position": 62832,
+ "nodeLength": 56,
+ "src": "expando.split(\"\").sort(sortOrder).join(\"\") === expando",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2754": [
+ null,
+ {
+ "position": 63535,
+ "nodeLength": 116,
+ "src": "!assert(function(el) {\n el.innerHTML = \"\";\n return el.firstChild.getAttribute(\"href\") === \"#\";\n})",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2756": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 42,
+ "src": "el.firstChild.getAttribute(\"href\") === \"#\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2759": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 6,
+ "src": "!isXML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2760": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 29,
+ "src": "name.toLowerCase() === \"type\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2767": [
+ null,
+ {
+ "position": 63908,
+ "nodeLength": 176,
+ "src": "!support.attributes || !assert(function(el) {\n el.innerHTML = \"\";\n el.firstChild.setAttribute(\"value\", \"\");\n return el.firstChild.getAttribute(\"value\") === \"\";\n})",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2770": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 44,
+ "src": "el.firstChild.getAttribute(\"value\") === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2773": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 49,
+ "src": "!isXML && elem.nodeName.toLowerCase() === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 39,
+ "src": "elem.nodeName.toLowerCase() === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2781": [
+ null,
+ {
+ "position": 64331,
+ "nodeLength": 72,
+ "src": "!assert(function(el) {\n return el.getAttribute(\"disabled\") == null;\n})",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2782": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 35,
+ "src": "el.getAttribute(\"disabled\") == null",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2786": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 6,
+ "src": "!isXML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2787": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 21,
+ "src": "elem[name] === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2788": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 53,
+ "src": "(val = elem.getAttributeNode(name)) && val.specified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2817": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 19,
+ "src": "until !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2819": [
+ null,
+ {
+ "position": 66,
+ "nodeLength": 43,
+ "src": "(elem = elem[dir]) && elem.nodeType !== 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90,
+ "nodeLength": 19,
+ "src": "elem.nodeType !== 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2820": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2821": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 38,
+ "src": "truncate && jQuery(elem).is(until)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2834": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 18,
+ "evalTrue": 74
+ }
+ ],
+ "2835": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 30,
+ "src": "n.nodeType === 1 && n !== elem",
+ "evalFalse": 0,
+ "evalTrue": 74
+ },
+ {
+ "position": 8,
+ "nodeLength": 16,
+ "src": "n.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 74
+ },
+ {
+ "position": 28,
+ "nodeLength": 10,
+ "src": "n !== elem",
+ "evalFalse": 0,
+ "evalTrue": 74
+ }
+ ],
+ "2854": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 30,
+ "src": "jQuery.isFunction(qualifier)",
+ "evalFalse": 8,
+ "evalTrue": 1
+ }
+ ],
+ "2856": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 41,
+ "src": "!!qualifier.call(elem, i, elem) !== not",
+ "evalFalse": 2,
+ "evalTrue": 3
+ }
+ ],
+ "2861": [
+ null,
+ {
+ "position": 185,
+ "nodeLength": 18,
+ "src": "qualifier.nodeType",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2863": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 28,
+ "src": "(elem === qualifier) !== not",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 18,
+ "src": "elem === qualifier",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2868": [
+ null,
+ {
+ "position": 371,
+ "nodeLength": 29,
+ "src": "typeof qualifier !== \"string\"",
+ "evalFalse": 5,
+ "evalTrue": 3
+ }
+ ],
+ "2870": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 46,
+ "src": "(indexOf.call(qualifier, elem) > -1) !== not",
+ "evalFalse": 0,
+ "evalTrue": 13
+ },
+ {
+ "position": 13,
+ "nodeLength": 36,
+ "src": "indexOf.call(qualifier, elem) > -1",
+ "evalFalse": 13,
+ "evalTrue": 0
+ }
+ ],
+ "2875": [
+ null,
+ {
+ "position": 606,
+ "nodeLength": 27,
+ "src": "risSimple.test(qualifier)",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "2882": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 69,
+ "src": "(indexOf.call(qualifier, elem) > -1) !== not && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12,
+ "nodeLength": 46,
+ "src": "(indexOf.call(qualifier, elem) > -1) !== not",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12,
+ "nodeLength": 36,
+ "src": "indexOf.call(qualifier, elem) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2889": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 3,
+ "src": "not",
+ "evalFalse": 13,
+ "evalTrue": 3
+ }
+ ],
+ "2893": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 41,
+ "src": "elems.length === 1 && elem.nodeType === 1",
+ "evalFalse": 16,
+ "evalTrue": 0
+ },
+ {
+ "position": 81,
+ "nodeLength": 18,
+ "src": "elems.length === 1",
+ "evalFalse": 16,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2894": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 41,
+ "src": "jQuery.find.matchesSelector(elem, expr)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2898": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 45
+ }
+ ],
+ "2908": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 28,
+ "src": "typeof selector !== \"string\"",
+ "evalFalse": 65,
+ "evalTrue": 0
+ }
+ ],
+ "2910": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2911": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 34,
+ "src": "jQuery.contains(self[i], this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2920": [
+ null,
+ {
+ "position": 336,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 65,
+ "evalTrue": 95
+ }
+ ],
+ "2924": [
+ null,
+ {
+ "position": 411,
+ "nodeLength": 7,
+ "src": "len > 1",
+ "evalFalse": 53,
+ "evalTrue": 12
+ }
+ ],
+ "2927": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 14,
+ "src": "selector || []",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2930": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 14,
+ "src": "selector || []",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "2938": [
+ null,
+ {
+ "position": 183,
+ "nodeLength": 62,
+ "src": "typeof selector === \"string\" && rneedsContext.test(selector)",
+ "evalFalse": 2,
+ "evalTrue": 0
+ },
+ {
+ "position": 183,
+ "nodeLength": 28,
+ "src": "typeof selector === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "2940": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 14,
+ "src": "selector || []",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "2963": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 9,
+ "src": "!selector",
+ "evalFalse": 193,
+ "evalTrue": 153
+ }
+ ],
+ "2969": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 18,
+ "src": "root || rootjQuery",
+ "evalFalse": 1,
+ "evalTrue": 192
+ }
+ ],
+ "2972": [
+ null,
+ {
+ "position": 277,
+ "nodeLength": 28,
+ "src": "typeof selector === \"string\"",
+ "evalFalse": 136,
+ "evalTrue": 57
+ }
+ ],
+ "2973": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 96,
+ "src": "selector[0] === \"<\" && selector[selector.length - 1] === \">\" && selector.length >= 3",
+ "evalFalse": 49,
+ "evalTrue": 8
+ },
+ {
+ "position": 9,
+ "nodeLength": 21,
+ "src": "selector[0] === \"<\"",
+ "evalFalse": 49,
+ "evalTrue": 8
+ }
+ ],
+ "2974": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 67,
+ "src": "selector[selector.length - 1] === \">\" && selector.length >= 3",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 40,
+ "nodeLength": 39,
+ "src": "selector[selector.length - 1] === \">\"",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "2975": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 20,
+ "src": "selector.length >= 3",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "2985": [
+ null,
+ {
+ "position": 367,
+ "nodeLength": 35,
+ "src": "match && (match[1] || !context)",
+ "evalFalse": 32,
+ "evalTrue": 25
+ },
+ {
+ "position": 378,
+ "nodeLength": 22,
+ "src": "match[1] || !context",
+ "evalFalse": 0,
+ "evalTrue": 25
+ }
+ ],
+ "2988": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 10,
+ "src": "match[1]",
+ "evalFalse": 17,
+ "evalTrue": 8
+ }
+ ],
+ "2989": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 25,
+ "src": "context instanceof jQuery",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2995": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 27,
+ "src": "context && context.nodeType",
+ "evalFalse": 2,
+ "evalTrue": 6
+ },
+ {
+ "position": 71,
+ "nodeLength": 32,
+ "src": "context.ownerDocument || context",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "3000": [
+ null,
+ {
+ "position": 402,
+ "nodeLength": 64,
+ "src": "rsingleTag.test(match[1]) && jQuery.isPlainObject(context)",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "3004": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 34,
+ "src": "jQuery.isFunction(this[match])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3020": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 10,
+ "evalTrue": 7
+ }
+ ],
+ "3030": [
+ null,
+ {
+ "position": 1575,
+ "nodeLength": 26,
+ "src": "!context || context.jquery",
+ "evalFalse": 0,
+ "evalTrue": 32
+ }
+ ],
+ "3031": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 15,
+ "src": "context || root",
+ "evalFalse": 0,
+ "evalTrue": 32
+ }
+ ],
+ "3040": [
+ null,
+ {
+ "position": 2170,
+ "nodeLength": 17,
+ "src": "selector.nodeType",
+ "evalFalse": 31,
+ "evalTrue": 105
+ }
+ ],
+ "3047": [
+ null,
+ {
+ "position": 2327,
+ "nodeLength": 29,
+ "src": "jQuery.isFunction(selector)",
+ "evalFalse": 30,
+ "evalTrue": 1
+ }
+ ],
+ "3048": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 24,
+ "src": "root.ready !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3082": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3083": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 37,
+ "src": "jQuery.contains(this, targets[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3095": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 52,
+ "src": "typeof selectors !== \"string\" && jQuery(selectors)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 68,
+ "nodeLength": 29,
+ "src": "typeof selectors !== \"string\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3098": [
+ null,
+ {
+ "position": 210,
+ "nodeLength": 32,
+ "src": "!rneedsContext.test(selectors)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3099": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3100": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 22,
+ "src": "cur && cur !== context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35,
+ "nodeLength": 15,
+ "src": "cur !== context",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3103": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 193,
+ "src": "cur.nodeType < 11 && (targets ? targets.index(cur) > -1 : cur.nodeType === 1 && jQuery.find.matchesSelector(cur, selectors))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51,
+ "nodeLength": 17,
+ "src": "cur.nodeType < 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 7,
+ "src": "targets",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3104": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 25,
+ "src": "targets.index(cur) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3107": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 74,
+ "src": "cur.nodeType === 1 && jQuery.find.matchesSelector(cur, selectors)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94,
+ "nodeLength": 18,
+ "src": "cur.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3117": [
+ null,
+ {
+ "position": 686,
+ "nodeLength": 18,
+ "src": "matched.length > 1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3124": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 5,
+ "src": "!elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3125": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 35,
+ "src": "(this[0] && this[0].parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 33,
+ "src": "this[0] && this[0].parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3129": [
+ null,
+ {
+ "position": 181,
+ "nodeLength": 24,
+ "src": "typeof elem === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3137": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 11,
+ "src": "elem.jquery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3150": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 16,
+ "src": "selector == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3157": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 40,
+ "src": "(cur = cur[dir]) && cur.nodeType !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 18,
+ "src": "cur.nodeType !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3164": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 32,
+ "src": "parent && parent.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 16
+ },
+ {
+ "position": 52,
+ "nodeLength": 22,
+ "src": "parent.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "3191": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 21,
+ "src": "elem.parentNode || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3197": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 59,
+ "src": "elem.contentDocument || jQuery.merge([], elem.childNodes)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3203": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 28,
+ "src": "name.slice(-5) !== \"Until\"",
+ "evalFalse": 0,
+ "evalTrue": 25
+ }
+ ],
+ "3207": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 40,
+ "src": "selector && typeof selector === \"string\"",
+ "evalFalse": 14,
+ "evalTrue": 11
+ },
+ {
+ "position": 134,
+ "nodeLength": 28,
+ "src": "typeof selector === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 11
+ }
+ ],
+ "3211": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 15,
+ "src": "this.length > 1",
+ "evalFalse": 22,
+ "evalTrue": 3
+ }
+ ],
+ "3214": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 25,
+ "src": "!guaranteedUnique[name]",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "3219": [
+ null,
+ {
+ "position": 166,
+ "nodeLength": 25,
+ "src": "rparentsprev.test(name)",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "3234": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 36,
+ "src": "options.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 30
+ }
+ ],
+ "3266": [
+ null,
+ {
+ "position": 115,
+ "nodeLength": 27,
+ "src": "typeof options === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 30
+ }
+ ],
+ "3300": [
+ null,
+ {
+ "position": 205,
+ "nodeLength": 12,
+ "src": "queue.length",
+ "evalFalse": 10,
+ "evalTrue": 10
+ }
+ ],
+ "3302": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 27,
+ "src": "++firingIndex < list.length",
+ "evalFalse": 10,
+ "evalTrue": 24
+ }
+ ],
+ "3305": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 92,
+ "src": "list[firingIndex].apply(memory[0], memory[1]) === false && options.stopOnFalse",
+ "evalFalse": 24,
+ "evalTrue": 0
+ },
+ {
+ "position": 65,
+ "nodeLength": 63,
+ "src": "list[firingIndex].apply(memory[0], memory[1]) === false",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "3316": [
+ null,
+ {
+ "position": 665,
+ "nodeLength": 15,
+ "src": "!options.memory",
+ "evalFalse": 10,
+ "evalTrue": 0
+ }
+ ],
+ "3323": [
+ null,
+ {
+ "position": 784,
+ "nodeLength": 6,
+ "src": "locked",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "3326": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 6,
+ "src": "memory",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "3341": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 4,
+ "src": "list",
+ "evalFalse": 0,
+ "evalTrue": 37
+ }
+ ],
+ "3344": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 17,
+ "src": "memory && !firing",
+ "evalFalse": 37,
+ "evalTrue": 0
+ }
+ ],
+ "3351": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 24,
+ "src": "jQuery.isFunction(arg)",
+ "evalFalse": 0,
+ "evalTrue": 57
+ }
+ ],
+ "3352": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 35,
+ "src": "!options.unique || !self.has(arg)",
+ "evalFalse": 0,
+ "evalTrue": 57
+ }
+ ],
+ "3355": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 52,
+ "src": "arg && arg.length && jQuery.type(arg) !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 159,
+ "nodeLength": 45,
+ "src": "arg.length && jQuery.type(arg) !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173,
+ "nodeLength": 31,
+ "src": "jQuery.type(arg) !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3363": [
+ null,
+ {
+ "position": 567,
+ "nodeLength": 17,
+ "src": "memory && !firing",
+ "evalFalse": 37,
+ "evalTrue": 0
+ }
+ ],
+ "3374": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 49,
+ "src": "(index = jQuery.inArray(arg, list, index)) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3378": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 20,
+ "src": "index <= firingIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3389": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 2,
+ "src": "fn",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3390": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 31,
+ "src": "jQuery.inArray(fn, list) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3391": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 15,
+ "src": "list.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3396": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 4,
+ "src": "list",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3419": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 18,
+ "src": "!memory && !firing",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "3430": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 7,
+ "src": "!locked",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "3431": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 10,
+ "src": "args || []",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "3432": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 10,
+ "src": "args.slice",
+ "evalFalse": 7,
+ "evalTrue": 3
+ }
+ ],
+ "3434": [
+ null,
+ {
+ "position": 119,
+ "nodeLength": 7,
+ "src": "!firing",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "3470": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 56,
+ "src": "value && jQuery.isFunction((method = value.promise))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3474": [
+ null,
+ {
+ "position": 233,
+ "nodeLength": 53,
+ "src": "value && jQuery.isFunction((method = value.then))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3531": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 59,
+ "src": "jQuery.isFunction(fns[tuple[4]]) && fns[tuple[4]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3537": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 33,
+ "src": "fn && fn.apply(this, arguments)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3538": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 49,
+ "src": "returned && jQuery.isFunction(returned.promise)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3546": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 2,
+ "src": "fn",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3566": [
+ null,
+ {
+ "position": 190,
+ "nodeLength": 16,
+ "src": "depth < maxDepth",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3574": [
+ null,
+ {
+ "position": 400,
+ "nodeLength": 31,
+ "src": "returned === deferred.promise()",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3582": [
+ null,
+ {
+ "position": 714,
+ "nodeLength": 286,
+ "src": "returned && (typeof returned === \"object\" || typeof returned === \"function\") && returned.then",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3587": [
+ null,
+ {
+ "position": 183,
+ "nodeLength": 102,
+ "src": "(typeof returned === \"object\" || typeof returned === \"function\") && returned.then",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192,
+ "nodeLength": 73,
+ "src": "typeof returned === \"object\" || typeof returned === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192,
+ "nodeLength": 28,
+ "src": "typeof returned === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3588": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 30,
+ "src": "typeof returned === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3592": [
+ null,
+ {
+ "position": 1056,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(then)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3595": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 7,
+ "src": "special",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3622": [
+ null,
+ {
+ "position": 124,
+ "nodeLength": 20,
+ "src": "handler !== Identity",
+ "evalFalse": 2,
+ "evalTrue": 2
+ }
+ ],
+ "3629": [
+ null,
+ {
+ "position": 309,
+ "nodeLength": 31,
+ "src": "special || deferred.resolveWith",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3634": [
+ null,
+ {
+ "position": 2413,
+ "nodeLength": 7,
+ "src": "special",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3641": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 29,
+ "src": "jQuery.Deferred.exceptionHook",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3649": [
+ null,
+ {
+ "position": 316,
+ "nodeLength": 21,
+ "src": "depth + 1 >= maxDepth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3653": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 19,
+ "src": "handler !== Thrower",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3667": [
+ null,
+ {
+ "position": 3402,
+ "nodeLength": 5,
+ "src": "depth",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3673": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 28,
+ "src": "jQuery.Deferred.getStackHook",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3688": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 31,
+ "src": "jQuery.isFunction(onProgress)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3700": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 32,
+ "src": "jQuery.isFunction(onFulfilled)",
+ "evalFalse": 2,
+ "evalTrue": 2
+ }
+ ],
+ "3711": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 31,
+ "src": "jQuery.isFunction(onRejected)",
+ "evalFalse": 2,
+ "evalTrue": 2
+ }
+ ],
+ "3722": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 11,
+ "src": "obj != null",
+ "evalFalse": 8,
+ "evalTrue": 5
+ }
+ ],
+ "3738": [
+ null,
+ {
+ "position": 217,
+ "nodeLength": 11,
+ "src": "stateString",
+ "evalFalse": 5,
+ "evalTrue": 10
+ }
+ ],
+ "3765": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 17,
+ "src": "this === deferred",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3779": [
+ null,
+ {
+ "position": 8147,
+ "nodeLength": 4,
+ "src": "func",
+ "evalFalse": 1,
+ "evalTrue": 4
+ }
+ ],
+ "3808": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 20,
+ "src": "arguments.length > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3809": [
+ null,
+ {
+ "position": 127,
+ "nodeLength": 16,
+ "src": "!(--remaining)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3816": [
+ null,
+ {
+ "position": 708,
+ "nodeLength": 14,
+ "src": "remaining <= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3820": [
+ null,
+ {
+ "position": 157,
+ "nodeLength": 102,
+ "src": "master.state() === \"pending\" || jQuery.isFunction(resolveValues[i] && resolveValues[i].then)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 157,
+ "nodeLength": 28,
+ "src": "master.state() === \"pending\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3821": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 45,
+ "src": "resolveValues[i] && resolveValues[i].then",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3828": [
+ null,
+ {
+ "position": 1108,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3845": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 80,
+ "src": "window.console && window.console.warn && error && rerrorNames.test(error.name)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126,
+ "nodeLength": 62,
+ "src": "window.console.warn && error && rerrorNames.test(error.name)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 149,
+ "nodeLength": 39,
+ "src": "error && rerrorNames.test(error.name)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3891": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 4,
+ "src": "hold",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3902": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 51,
+ "src": "wait === true ? --jQuery.readyWait : jQuery.isReady",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 70,
+ "nodeLength": 13,
+ "src": "wait === true",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3910": [
+ null,
+ {
+ "position": 282,
+ "nodeLength": 39,
+ "src": "wait !== true && --jQuery.readyWait > 0",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 282,
+ "nodeLength": 13,
+ "src": "wait !== true",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 299,
+ "nodeLength": 22,
+ "src": "--jQuery.readyWait > 0",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3932": [
+ null,
+ {
+ "position": 105604,
+ "nodeLength": 114,
+ "src": "document.readyState === \"complete\" || (document.readyState !== \"loading\" && !document.documentElement.doScroll)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 105604,
+ "nodeLength": 34,
+ "src": "document.readyState === \"complete\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3933": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 71,
+ "src": "document.readyState !== \"loading\" && !document.documentElement.doScroll",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 40,
+ "nodeLength": 33,
+ "src": "document.readyState !== \"loading\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3955": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 11,
+ "src": "key == null",
+ "evalFalse": 193,
+ "evalTrue": 25
+ }
+ ],
+ "3958": [
+ null,
+ {
+ "position": 85,
+ "nodeLength": 31,
+ "src": "jQuery.type(key) === \"object\"",
+ "evalFalse": 183,
+ "evalTrue": 35
+ }
+ ],
+ "3965": [
+ null,
+ {
+ "position": 258,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 56,
+ "evalTrue": 127
+ }
+ ],
+ "3968": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 27,
+ "src": "!jQuery.isFunction(value)",
+ "evalFalse": 0,
+ "evalTrue": 127
+ }
+ ],
+ "3972": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 4,
+ "src": "bulk",
+ "evalFalse": 113,
+ "evalTrue": 14
+ }
+ ],
+ "3975": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 3,
+ "src": "raw",
+ "evalFalse": 0,
+ "evalTrue": 14
+ }
+ ],
+ "3988": [
+ null,
+ {
+ "position": 394,
+ "nodeLength": 2,
+ "src": "fn",
+ "evalFalse": 14,
+ "evalTrue": 113
+ }
+ ],
+ "3989": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 113,
+ "evalTrue": 187
+ }
+ ],
+ "3991": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 3,
+ "src": "raw",
+ "evalFalse": 0,
+ "evalTrue": 187
+ }
+ ],
+ "3999": [
+ null,
+ {
+ "position": 840,
+ "nodeLength": 9,
+ "src": "chainable",
+ "evalFalse": 56,
+ "evalTrue": 162
+ }
+ ],
+ "4004": [
+ null,
+ {
+ "position": 889,
+ "nodeLength": 4,
+ "src": "bulk",
+ "evalFalse": 45,
+ "evalTrue": 11
+ }
+ ],
+ "4008": [
+ null,
+ {
+ "position": 937,
+ "nodeLength": 3,
+ "src": "len",
+ "evalFalse": 2,
+ "evalTrue": 43
+ }
+ ],
+ "4018": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 68,
+ "src": "owner.nodeType === 1 || owner.nodeType === 9 || !(+owner.nodeType)",
+ "evalFalse": 0,
+ "evalTrue": 60
+ },
+ {
+ "position": 122,
+ "nodeLength": 20,
+ "src": "owner.nodeType === 1",
+ "evalFalse": 2,
+ "evalTrue": 58
+ },
+ {
+ "position": 146,
+ "nodeLength": 44,
+ "src": "owner.nodeType === 9 || !(+owner.nodeType)",
+ "evalFalse": 0,
+ "evalTrue": 2
+ },
+ {
+ "position": 146,
+ "nodeLength": 20,
+ "src": "owner.nodeType === 9",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "4038": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 6,
+ "src": "!value",
+ "evalFalse": 135,
+ "evalTrue": 53
+ }
+ ],
+ "4044": [
+ null,
+ {
+ "position": 165,
+ "nodeLength": 19,
+ "src": "acceptData(owner)",
+ "evalFalse": 0,
+ "evalTrue": 53
+ }
+ ],
+ "4048": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 14,
+ "src": "owner.nodeType",
+ "evalFalse": 1,
+ "evalTrue": 52
+ }
+ ],
+ "4071": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 24,
+ "src": "typeof data === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 23
+ }
+ ],
+ "4085": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 17,
+ "src": "key === undefined",
+ "evalFalse": 60,
+ "evalTrue": 165
+ }
+ ],
+ "4089": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 73,
+ "src": "owner[this.expando] && owner[this.expando][jQuery.camelCase(key)]",
+ "evalFalse": 36,
+ "evalTrue": 24
+ }
+ ],
+ "4104": [
+ null,
+ {
+ "position": 332,
+ "nodeLength": 86,
+ "src": "key === undefined || ((key && typeof key === \"string\") && value === undefined)",
+ "evalFalse": 6,
+ "evalTrue": 6
+ },
+ {
+ "position": 332,
+ "nodeLength": 17,
+ "src": "key === undefined",
+ "evalFalse": 12,
+ "evalTrue": 0
+ }
+ ],
+ "4105": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 55,
+ "src": "(key && typeof key === \"string\") && value === undefined",
+ "evalFalse": 6,
+ "evalTrue": 6
+ },
+ {
+ "position": 28,
+ "nodeLength": 30,
+ "src": "key && typeof key === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 12
+ },
+ {
+ "position": 35,
+ "nodeLength": 23,
+ "src": "typeof key === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 12
+ },
+ {
+ "position": 64,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 6,
+ "evalTrue": 6
+ }
+ ],
+ "4120": [
+ null,
+ {
+ "position": 825,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "4126": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 19,
+ "src": "cache === undefined",
+ "evalFalse": 26,
+ "evalTrue": 0
+ }
+ ],
+ "4130": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 17,
+ "src": "key !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 26
+ }
+ ],
+ "4133": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 21,
+ "src": "jQuery.isArray(key)",
+ "evalFalse": 26,
+ "evalTrue": 0
+ }
+ ],
+ "4143": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 12,
+ "src": "key in cache",
+ "evalFalse": 26,
+ "evalTrue": 0
+ }
+ ],
+ "4145": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 32,
+ "src": "key.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 26
+ }
+ ],
+ "4150": [
+ null,
+ {
+ "position": 497,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 26,
+ "evalTrue": 52
+ }
+ ],
+ "4156": [
+ null,
+ {
+ "position": 720,
+ "nodeLength": 50,
+ "src": "key === undefined || jQuery.isEmptyObject(cache)",
+ "evalFalse": 0,
+ "evalTrue": 26
+ },
+ {
+ "position": 720,
+ "nodeLength": 17,
+ "src": "key === undefined",
+ "evalFalse": 26,
+ "evalTrue": 0
+ }
+ ],
+ "4162": [
+ null,
+ {
+ "position": 242,
+ "nodeLength": 14,
+ "src": "owner.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 26
+ }
+ ],
+ "4171": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 53,
+ "src": "cache !== undefined && !jQuery.isEmptyObject(cache)",
+ "evalFalse": 14,
+ "evalTrue": 56
+ },
+ {
+ "position": 47,
+ "nodeLength": 19,
+ "src": "cache !== undefined",
+ "evalFalse": 14,
+ "evalTrue": 56
+ }
+ ],
+ "4194": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 15,
+ "src": "data === \"true\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4198": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 16,
+ "src": "data === \"false\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4202": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 15,
+ "src": "data === \"null\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4207": [
+ null,
+ {
+ "position": 205,
+ "nodeLength": 19,
+ "src": "data === +data + \"\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4211": [
+ null,
+ {
+ "position": 255,
+ "nodeLength": 19,
+ "src": "rbrace.test(data)",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4223": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 41,
+ "src": "data === undefined && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 4
+ },
+ {
+ "position": 114,
+ "nodeLength": 18,
+ "src": "data === undefined",
+ "evalFalse": 0,
+ "evalTrue": 4
+ },
+ {
+ "position": 136,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "4227": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 24,
+ "src": "typeof data === \"string\"",
+ "evalFalse": 2,
+ "evalTrue": 2
+ }
+ ],
+ "4243": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 52,
+ "src": "dataUser.hasData(elem) || dataPriv.hasData(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4269": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 23,
+ "src": "elem && elem.attributes",
+ "evalFalse": 0,
+ "evalTrue": 25
+ }
+ ],
+ "4272": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 17,
+ "src": "key === undefined",
+ "evalFalse": 24,
+ "evalTrue": 1
+ }
+ ],
+ "4273": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 11,
+ "src": "this.length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4276": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 60,
+ "src": "elem.nodeType === 1 && !dataPriv.get(elem, \"hasDataAttrs\")",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 44,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4278": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 1,
+ "evalTrue": 4
+ }
+ ],
+ "4282": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 10,
+ "src": "attrs[i]",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "4284": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 29,
+ "src": "name.indexOf(\"data-\") === 0",
+ "evalFalse": 2,
+ "evalTrue": 2
+ }
+ ],
+ "4298": [
+ null,
+ {
+ "position": 725,
+ "nodeLength": 23,
+ "src": "typeof key === \"object\"",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "4312": [
+ null,
+ {
+ "position": 352,
+ "nodeLength": 27,
+ "src": "elem && value === undefined",
+ "evalFalse": 14,
+ "evalTrue": 10
+ },
+ {
+ "position": 360,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 14,
+ "evalTrue": 10
+ }
+ ],
+ "4317": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 18,
+ "src": "data !== undefined",
+ "evalFalse": 2,
+ "evalTrue": 8
+ }
+ ],
+ "4324": [
+ null,
+ {
+ "position": 305,
+ "nodeLength": 18,
+ "src": "data !== undefined",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "4338": [
+ null,
+ {
+ "position": 999,
+ "nodeLength": 20,
+ "src": "arguments.length > 1",
+ "evalFalse": 10,
+ "evalTrue": 14
+ }
+ ],
+ "4353": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4354": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4358": [
+ null,
+ {
+ "position": 158,
+ "nodeLength": 4,
+ "src": "data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4359": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 32,
+ "src": "!queue || jQuery.isArray(data)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4365": [
+ null,
+ {
+ "position": 342,
+ "nodeLength": 11,
+ "src": "queue || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4370": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4381": [
+ null,
+ {
+ "position": 307,
+ "nodeLength": 19,
+ "src": "fn === \"inprogress\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4386": [
+ null,
+ {
+ "position": 384,
+ "nodeLength": 2,
+ "src": "fn",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4390": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 13,
+ "src": "type === \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4399": [
+ null,
+ {
+ "position": 664,
+ "nodeLength": 21,
+ "src": "!startLength && hooks",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4407": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 187,
+ "src": "dataPriv.get(elem, key) || dataPriv.access(elem, key, {\n empty: jQuery.Callbacks(\"once memory\").add(function() {\n dataPriv.remove(elem, [type + \"queue\", key]);\n})})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4419": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 24,
+ "src": "typeof type !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4425": [
+ null,
+ {
+ "position": 113,
+ "nodeLength": 25,
+ "src": "arguments.length < setter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4429": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 18,
+ "src": "data === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4437": [
+ null,
+ {
+ "position": 137,
+ "nodeLength": 44,
+ "src": "type === \"fx\" && queue[0] !== \"inprogress\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 137,
+ "nodeLength": 13,
+ "src": "type === \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 154,
+ "nodeLength": 27,
+ "src": "queue[0] !== \"inprogress\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4448": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4460": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 12,
+ "src": "!(--count)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4465": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 24,
+ "src": "typeof type !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4469": [
+ null,
+ {
+ "position": 296,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4471": [
+ null,
+ {
+ "position": 321,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4473": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 16,
+ "src": "tmp && tmp.empty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4493": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 10,
+ "src": "el || elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4496": [
+ null,
+ {
+ "position": 182,
+ "nodeLength": 345,
+ "src": "elem.style.display === \"none\" || elem.style.display === \"\" && jQuery.contains(elem.ownerDocument, elem) && jQuery.css(elem, \"display\") === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182,
+ "nodeLength": 29,
+ "src": "elem.style.display === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4497": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 309,
+ "src": "elem.style.display === \"\" && jQuery.contains(elem.ownerDocument, elem) && jQuery.css(elem, \"display\") === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 220,
+ "nodeLength": 25,
+ "src": "elem.style.display === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4503": [
+ null,
+ {
+ "position": 217,
+ "nodeLength": 91,
+ "src": "jQuery.contains(elem.ownerDocument, elem) && jQuery.css(elem, \"display\") === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4505": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 40,
+ "src": "jQuery.css(elem, \"display\") === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4518": [
+ null,
+ {
+ "position": 219,
+ "nodeLength": 10,
+ "src": "args || []",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "4535": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 5,
+ "src": "tween",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4543": [
+ null,
+ {
+ "position": 220,
+ "nodeLength": 73,
+ "src": "valueParts && valueParts[3] || (jQuery.cssNumber[prop] ? \"\" : \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 220,
+ "nodeLength": 29,
+ "src": "valueParts && valueParts[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 255,
+ "nodeLength": 24,
+ "src": "jQuery.cssNumber[prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4546": [
+ null,
+ {
+ "position": 390,
+ "nodeLength": 102,
+ "src": "(jQuery.cssNumber[prop] || unit !== \"px\" && +initial) && rcssNum.exec(jQuery.css(elem, prop))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 390,
+ "nodeLength": 53,
+ "src": "jQuery.cssNumber[prop] || unit !== \"px\" && +initial",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 418,
+ "nodeLength": 25,
+ "src": "unit !== \"px\" && +initial",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 418,
+ "nodeLength": 13,
+ "src": "unit !== \"px\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4549": [
+ null,
+ {
+ "position": 504,
+ "nodeLength": 44,
+ "src": "initialInUnit && initialInUnit[3] !== unit",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 521,
+ "nodeLength": 27,
+ "src": "initialInUnit[3] !== unit",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4552": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 26,
+ "src": "unit || initialInUnit[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4555": [
+ null,
+ {
+ "position": 150,
+ "nodeLength": 16,
+ "src": "valueParts || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4558": [
+ null,
+ {
+ "position": 246,
+ "nodeLength": 13,
+ "src": "+initial || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4564": [
+ null,
+ {
+ "position": 170,
+ "nodeLength": 13,
+ "src": "scale || \".5\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4573": [
+ null,
+ {
+ "position": 465,
+ "nodeLength": 80,
+ "src": "scale !== (scale = currentValue() / initial) && scale !== 1 && --maxIterations",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 731,
+ "nodeLength": 46,
+ "src": "scale !== (scale = currentValue() / initial)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49,
+ "nodeLength": 30,
+ "src": "scale !== 1 && --maxIterations",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 782,
+ "nodeLength": 11,
+ "src": "scale !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4577": [
+ null,
+ {
+ "position": 1378,
+ "nodeLength": 10,
+ "src": "valueParts",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4578": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 31,
+ "src": "+initialInUnit || +initial || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37,
+ "nodeLength": 13,
+ "src": "+initial || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4581": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 15,
+ "src": "valueParts[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4584": [
+ null,
+ {
+ "position": 223,
+ "nodeLength": 5,
+ "src": "tween",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4602": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 7,
+ "src": "display",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4611": [
+ null,
+ {
+ "position": 303,
+ "nodeLength": 18,
+ "src": "display === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4626": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "4628": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 11,
+ "src": "!elem.style",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4633": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 4,
+ "src": "show",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4638": [
+ null,
+ {
+ "position": 225,
+ "nodeLength": 18,
+ "src": "display === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4639": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 39,
+ "src": "dataPriv.get(elem, \"display\") || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4640": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 16,
+ "src": "!values[index]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4644": [
+ null,
+ {
+ "position": 390,
+ "nodeLength": 55,
+ "src": "elem.style.display === \"\" && isHiddenWithinTree(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 390,
+ "nodeLength": 25,
+ "src": "elem.style.display === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4648": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 18,
+ "src": "display !== \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4658": [
+ null,
+ {
+ "position": 1067,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "4659": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 23,
+ "src": "values[index] != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4675": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 26,
+ "src": "typeof state === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4676": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 5,
+ "src": "state",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4680": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 26,
+ "src": "isHiddenWithinTree(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4726": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 51,
+ "src": "typeof context.getElementsByTagName !== \"undefined\"",
+ "evalFalse": 6,
+ "evalTrue": 94
+ }
+ ],
+ "4727": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 10,
+ "src": "tag || \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 94
+ }
+ ],
+ "4729": [
+ null,
+ {
+ "position": 253,
+ "nodeLength": 47,
+ "src": "typeof context.querySelectorAll !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "4730": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 10,
+ "src": "tag || \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "4736": [
+ null,
+ {
+ "position": 385,
+ "nodeLength": 59,
+ "src": "tag === undefined || tag && jQuery.nodeName(context, tag)",
+ "evalFalse": 88,
+ "evalTrue": 12
+ },
+ {
+ "position": 385,
+ "nodeLength": 17,
+ "src": "tag === undefined",
+ "evalFalse": 88,
+ "evalTrue": 12
+ },
+ {
+ "position": 406,
+ "nodeLength": 38,
+ "src": "tag && jQuery.nodeName(context, tag)",
+ "evalFalse": 88,
+ "evalTrue": 0
+ }
+ ],
+ "4749": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 33,
+ "evalTrue": 0
+ }
+ ],
+ "4753": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 62,
+ "src": "!refElements || dataPriv.get(refElements[i], \"globalEval\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4768": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 27,
+ "evalTrue": 27
+ }
+ ],
+ "4771": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 18,
+ "src": "elem || elem === 0",
+ "evalFalse": 0,
+ "evalTrue": 27
+ },
+ {
+ "position": 38,
+ "nodeLength": 10,
+ "src": "elem === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4774": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 32,
+ "src": "jQuery.type(elem) === \"object\"",
+ "evalFalse": 12,
+ "evalTrue": 15
+ }
+ ],
+ "4778": [
+ null,
+ {
+ "position": 137,
+ "nodeLength": 13,
+ "src": "elem.nodeType",
+ "evalFalse": 15,
+ "evalTrue": 0
+ }
+ ],
+ "4781": [
+ null,
+ {
+ "position": 300,
+ "nodeLength": 19,
+ "src": "!rhtml.test(elem)",
+ "evalFalse": 12,
+ "evalTrue": 0
+ }
+ ],
+ "4786": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 61,
+ "src": "tmp || fragment.appendChild(context.createElement(\"div\"))",
+ "evalFalse": 0,
+ "evalTrue": 12
+ }
+ ],
+ "4789": [
+ null,
+ {
+ "position": 132,
+ "nodeLength": 35,
+ "src": "rtagName.exec(elem) || [\"\", \"\"]",
+ "evalFalse": 0,
+ "evalTrue": 12
+ }
+ ],
+ "4790": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 34,
+ "src": "wrapMap[tag] || wrapMap._default",
+ "evalFalse": 0,
+ "evalTrue": 12
+ }
+ ],
+ "4795": [
+ null,
+ {
+ "position": 396,
+ "nodeLength": 3,
+ "src": "j--",
+ "evalFalse": 12,
+ "evalTrue": 6
+ }
+ ],
+ "4816": [
+ null,
+ {
+ "position": 1463,
+ "nodeLength": 21,
+ "src": "(elem = nodes[i++])",
+ "evalFalse": 27,
+ "evalTrue": 45
+ }
+ ],
+ "4819": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 51,
+ "src": "selection && jQuery.inArray(elem, selection) > -1",
+ "evalFalse": 45,
+ "evalTrue": 0
+ },
+ {
+ "position": 87,
+ "nodeLength": 38,
+ "src": "jQuery.inArray(elem, selection) > -1",
+ "evalFalse": 38,
+ "evalTrue": 0
+ }
+ ],
+ "4820": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 7,
+ "src": "ignored",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4832": [
+ null,
+ {
+ "position": 388,
+ "nodeLength": 8,
+ "src": "contains",
+ "evalFalse": 29,
+ "evalTrue": 16
+ }
+ ],
+ "4837": [
+ null,
+ {
+ "position": 463,
+ "nodeLength": 7,
+ "src": "scripts",
+ "evalFalse": 45,
+ "evalTrue": 0
+ }
+ ],
+ "4839": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 19,
+ "src": "(elem = tmp[j++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4840": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 35,
+ "src": "rscriptType.test(elem.type || \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28,
+ "nodeLength": 15,
+ "src": "elem.type || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4904": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 25,
+ "src": "typeof types === \"object\"",
+ "evalFalse": 77,
+ "evalTrue": 0
+ }
+ ],
+ "4907": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 28,
+ "src": "typeof selector !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4910": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 16,
+ "src": "data || selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4919": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 26,
+ "src": "data == null && fn == null",
+ "evalFalse": 40,
+ "evalTrue": 37
+ },
+ {
+ "position": 376,
+ "nodeLength": 12,
+ "src": "data == null",
+ "evalFalse": 40,
+ "evalTrue": 37
+ },
+ {
+ "position": 392,
+ "nodeLength": 10,
+ "src": "fn == null",
+ "evalFalse": 0,
+ "evalTrue": 37
+ }
+ ],
+ "4924": [
+ null,
+ {
+ "position": 489,
+ "nodeLength": 10,
+ "src": "fn == null",
+ "evalFalse": 0,
+ "evalTrue": 40
+ }
+ ],
+ "4925": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 28,
+ "src": "typeof selector === \"string\"",
+ "evalFalse": 21,
+ "evalTrue": 19
+ }
+ ],
+ "4938": [
+ null,
+ {
+ "position": 719,
+ "nodeLength": 12,
+ "src": "fn === false",
+ "evalFalse": 77,
+ "evalTrue": 0
+ }
+ ],
+ "4940": [
+ null,
+ {
+ "position": 770,
+ "nodeLength": 3,
+ "src": "!fn",
+ "evalFalse": 77,
+ "evalTrue": 0
+ }
+ ],
+ "4944": [
+ null,
+ {
+ "position": 802,
+ "nodeLength": 9,
+ "src": "one === 1",
+ "evalFalse": 77,
+ "evalTrue": 0
+ }
+ ],
+ "4954": [
+ null,
+ {
+ "position": 242,
+ "nodeLength": 46,
+ "src": "origFn.guid || (origFn.guid = jQuery.guid++)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4977": [
+ null,
+ {
+ "position": 241,
+ "nodeLength": 9,
+ "src": "!elemData",
+ "evalFalse": 108,
+ "evalTrue": 0
+ }
+ ],
+ "4982": [
+ null,
+ {
+ "position": 350,
+ "nodeLength": 15,
+ "src": "handler.handler",
+ "evalFalse": 108,
+ "evalTrue": 0
+ }
+ ],
+ "4990": [
+ null,
+ {
+ "position": 635,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 89,
+ "evalTrue": 19
+ }
+ ],
+ "4995": [
+ null,
+ {
+ "position": 799,
+ "nodeLength": 13,
+ "src": "!handler.guid",
+ "evalFalse": 86,
+ "evalTrue": 22
+ }
+ ],
+ "5000": [
+ null,
+ {
+ "position": 941,
+ "nodeLength": 29,
+ "src": "!(events = elemData.events)",
+ "evalFalse": 61,
+ "evalTrue": 47
+ }
+ ],
+ "5003": [
+ null,
+ {
+ "position": 1020,
+ "nodeLength": 34,
+ "src": "!(eventHandle = elemData.handle)",
+ "evalFalse": 61,
+ "evalTrue": 47
+ }
+ ],
+ "5008": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 66,
+ "src": "typeof jQuery !== \"undefined\" && jQuery.event.triggered !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 134,
+ "nodeLength": 29,
+ "src": "typeof jQuery !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 167,
+ "nodeLength": 33,
+ "src": "jQuery.event.triggered !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5014": [
+ null,
+ {
+ "position": 1449,
+ "nodeLength": 46,
+ "src": "(types || \"\").match(rnothtmlwhite) || [\"\"]",
+ "evalFalse": 0,
+ "evalTrue": 108
+ },
+ {
+ "position": 1449,
+ "nodeLength": 11,
+ "src": "types || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 108
+ }
+ ],
+ "5016": [
+ null,
+ {
+ "position": 1527,
+ "nodeLength": 3,
+ "src": "t--",
+ "evalFalse": 108,
+ "evalTrue": 125
+ }
+ ],
+ "5017": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 39,
+ "src": "rtypenamespace.exec(types[t]) || []",
+ "evalFalse": 0,
+ "evalTrue": 125
+ }
+ ],
+ "5019": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 14,
+ "src": "tmp[2] || \"\"",
+ "evalFalse": 47,
+ "evalTrue": 78
+ }
+ ],
+ "5022": [
+ null,
+ {
+ "position": 214,
+ "nodeLength": 5,
+ "src": "!type",
+ "evalFalse": 125,
+ "evalTrue": 0
+ }
+ ],
+ "5027": [
+ null,
+ {
+ "position": 342,
+ "nodeLength": 34,
+ "src": "jQuery.event.special[type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 125
+ }
+ ],
+ "5030": [
+ null,
+ {
+ "position": 473,
+ "nodeLength": 60,
+ "src": "(selector ? special.delegateType : special.bindType) || type",
+ "evalFalse": 0,
+ "evalTrue": 125
+ },
+ {
+ "position": 473,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 101,
+ "evalTrue": 24
+ }
+ ],
+ "5033": [
+ null,
+ {
+ "position": 596,
+ "nodeLength": 34,
+ "src": "jQuery.event.special[type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 125
+ }
+ ],
+ "5043": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 59,
+ "src": "selector && jQuery.expr.match.needsContext.test(selector)",
+ "evalFalse": 125,
+ "evalTrue": 0
+ }
+ ],
+ "5048": [
+ null,
+ {
+ "position": 1040,
+ "nodeLength": 30,
+ "src": "!(handlers = events[type])",
+ "evalFalse": 26,
+ "evalTrue": 99
+ }
+ ],
+ "5053": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 90,
+ "src": "!special.setup || special.setup.call(elem, data, namespaces, eventHandle) === false",
+ "evalFalse": 6,
+ "evalTrue": 93
+ }
+ ],
+ "5054": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 67,
+ "src": "special.setup.call(elem, data, namespaces, eventHandle) === false",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5056": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 21,
+ "src": "elem.addEventListener",
+ "evalFalse": 0,
+ "evalTrue": 93
+ }
+ ],
+ "5062": [
+ null,
+ {
+ "position": 1439,
+ "nodeLength": 11,
+ "src": "special.add",
+ "evalFalse": 125,
+ "evalTrue": 0
+ }
+ ],
+ "5065": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 23,
+ "src": "!handleObj.handler.guid",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5071": [
+ null,
+ {
+ "position": 1658,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 101,
+ "evalTrue": 24
+ }
+ ],
+ "5089": [
+ null,
+ {
+ "position": 111,
+ "nodeLength": 48,
+ "src": "dataPriv.hasData(elem) && dataPriv.get(elem)",
+ "evalFalse": 2,
+ "evalTrue": 56
+ }
+ ],
+ "5091": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 42,
+ "src": "!elemData || !(events = elemData.events)",
+ "evalFalse": 56,
+ "evalTrue": 2
+ }
+ ],
+ "5096": [
+ null,
+ {
+ "position": 313,
+ "nodeLength": 46,
+ "src": "(types || \"\").match(rnothtmlwhite) || [\"\"]",
+ "evalFalse": 0,
+ "evalTrue": 56
+ },
+ {
+ "position": 313,
+ "nodeLength": 11,
+ "src": "types || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 56
+ }
+ ],
+ "5098": [
+ null,
+ {
+ "position": 391,
+ "nodeLength": 3,
+ "src": "t--",
+ "evalFalse": 56,
+ "evalTrue": 68
+ }
+ ],
+ "5099": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 39,
+ "src": "rtypenamespace.exec(types[t]) || []",
+ "evalFalse": 0,
+ "evalTrue": 68
+ }
+ ],
+ "5101": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 14,
+ "src": "tmp[2] || \"\"",
+ "evalFalse": 26,
+ "evalTrue": 42
+ }
+ ],
+ "5104": [
+ null,
+ {
+ "position": 220,
+ "nodeLength": 5,
+ "src": "!type",
+ "evalFalse": 68,
+ "evalTrue": 0
+ }
+ ],
+ "5111": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 34,
+ "src": "jQuery.event.special[type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 68
+ }
+ ],
+ "5112": [
+ null,
+ {
+ "position": 424,
+ "nodeLength": 60,
+ "src": "(selector ? special.delegateType : special.bindType) || type",
+ "evalFalse": 0,
+ "evalTrue": 68
+ },
+ {
+ "position": 424,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 68,
+ "evalTrue": 0
+ }
+ ],
+ "5113": [
+ null,
+ {
+ "position": 500,
+ "nodeLength": 20,
+ "src": "events[type] || []",
+ "evalFalse": 0,
+ "evalTrue": 68
+ }
+ ],
+ "5114": [
+ null,
+ {
+ "position": 531,
+ "nodeLength": 88,
+ "src": "tmp[2] && new RegExp(\"(^|\\\\.)\" + namespaces.join(\"\\\\.(?:.*\\\\.|)\") + \"(\\\\.|$)\")",
+ "evalFalse": 26,
+ "evalTrue": 42
+ }
+ ],
+ "5119": [
+ null,
+ {
+ "position": 698,
+ "nodeLength": 3,
+ "src": "j--",
+ "evalFalse": 68,
+ "evalTrue": 69
+ }
+ ],
+ "5122": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 262,
+ "src": "(mappedTypes || origType === handleObj.origType) && (!handler || handler.guid === handleObj.guid) && (!tmp || tmp.test(handleObj.namespace)) && (!selector || selector === handleObj.selector || selector === \"**\" && handleObj.selector)",
+ "evalFalse": 9,
+ "evalTrue": 60
+ },
+ {
+ "position": 44,
+ "nodeLength": 46,
+ "src": "mappedTypes || origType === handleObj.origType",
+ "evalFalse": 0,
+ "evalTrue": 69
+ },
+ {
+ "position": 59,
+ "nodeLength": 31,
+ "src": "origType === handleObj.origType",
+ "evalFalse": 0,
+ "evalTrue": 69
+ }
+ ],
+ "5123": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 203,
+ "src": "(!handler || handler.guid === handleObj.guid) && (!tmp || tmp.test(handleObj.namespace)) && (!selector || selector === handleObj.selector || selector === \"**\" && handleObj.selector)",
+ "evalFalse": 9,
+ "evalTrue": 60
+ },
+ {
+ "position": 105,
+ "nodeLength": 43,
+ "src": "!handler || handler.guid === handleObj.guid",
+ "evalFalse": 9,
+ "evalTrue": 60
+ },
+ {
+ "position": 117,
+ "nodeLength": 31,
+ "src": "handler.guid === handleObj.guid",
+ "evalFalse": 9,
+ "evalTrue": 60
+ }
+ ],
+ "5124": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 147,
+ "src": "(!tmp || tmp.test(handleObj.namespace)) && (!selector || selector === handleObj.selector || selector === \"**\" && handleObj.selector)",
+ "evalFalse": 0,
+ "evalTrue": 60
+ },
+ {
+ "position": 162,
+ "nodeLength": 39,
+ "src": "!tmp || tmp.test(handleObj.namespace)",
+ "evalFalse": 0,
+ "evalTrue": 60
+ }
+ ],
+ "5125": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 93,
+ "src": "!selector || selector === handleObj.selector || selector === \"**\" && handleObj.selector",
+ "evalFalse": 0,
+ "evalTrue": 60
+ },
+ {
+ "position": 64,
+ "nodeLength": 80,
+ "src": "selector === handleObj.selector || selector === \"**\" && handleObj.selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64,
+ "nodeLength": 31,
+ "src": "selector === handleObj.selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5126": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 39,
+ "src": "selector === \"**\" && handleObj.selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 108,
+ "nodeLength": 17,
+ "src": "selector === \"**\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5129": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 18,
+ "src": "handleObj.selector",
+ "evalFalse": 60,
+ "evalTrue": 0
+ }
+ ],
+ "5132": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 14,
+ "src": "special.remove",
+ "evalFalse": 60,
+ "evalTrue": 0
+ }
+ ],
+ "5140": [
+ null,
+ {
+ "position": 1395,
+ "nodeLength": 29,
+ "src": "origCount && !handlers.length",
+ "evalFalse": 13,
+ "evalTrue": 55
+ }
+ ],
+ "5141": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 94,
+ "src": "!special.teardown || special.teardown.call(elem, namespaces, elemData.handle) === false",
+ "evalFalse": 0,
+ "evalTrue": 55
+ }
+ ],
+ "5142": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 68,
+ "src": "special.teardown.call(elem, namespaces, elemData.handle) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5152": [
+ null,
+ {
+ "position": 2099,
+ "nodeLength": 30,
+ "src": "jQuery.isEmptyObject(events)",
+ "evalFalse": 30,
+ "evalTrue": 26
+ }
+ ],
+ "5164": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 58,
+ "src": "(dataPriv.get(this, \"events\") || {})[event.type] || []",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 105,
+ "nodeLength": 36,
+ "src": "dataPriv.get(this, \"events\") || {}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5165": [
+ null,
+ {
+ "position": 178,
+ "nodeLength": 40,
+ "src": "jQuery.event.special[event.type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5170": [
+ null,
+ {
+ "position": 448,
+ "nodeLength": 20,
+ "src": "i < arguments.length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5177": [
+ null,
+ {
+ "position": 632,
+ "nodeLength": 72,
+ "src": "special.preDispatch && special.preDispatch.call(this, event) === false",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 655,
+ "nodeLength": 49,
+ "src": "special.preDispatch.call(this, event) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5186": [
+ null,
+ {
+ "position": 912,
+ "nodeLength": 64,
+ "src": "(matched = handlerQueue[i++]) && !event.isPropagationStopped()",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "5190": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 83,
+ "src": "(handleObj = matched.handlers[j++]) && !event.isImmediatePropagationStopped()",
+ "evalFalse": 1,
+ "evalTrue": 3
+ }
+ ],
+ "5195": [
+ null,
+ {
+ "position": 175,
+ "nodeLength": 65,
+ "src": "!event.rnamespace || event.rnamespace.test(handleObj.namespace)",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "5200": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 84,
+ "src": "(jQuery.event.special[handleObj.origType] || {}).handle || handleObj.handler",
+ "evalFalse": 0,
+ "evalTrue": 3
+ },
+ {
+ "position": -1,
+ "nodeLength": 48,
+ "src": "jQuery.event.special[handleObj.origType] || {}",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "5203": [
+ null,
+ {
+ "position": 213,
+ "nodeLength": 17,
+ "src": "ret !== undefined",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "5204": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 30,
+ "src": "(event.result = ret) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5214": [
+ null,
+ {
+ "position": 1811,
+ "nodeLength": 20,
+ "src": "special.postDispatch",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5228": [
+ null,
+ {
+ "position": 185,
+ "nodeLength": 464,
+ "src": "delegateCount && cur.nodeType && !(event.type === \"click\" && event.button >= 1)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5232": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 366,
+ "src": "cur.nodeType && !(event.type === \"click\" && event.button >= 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5239": [
+ null,
+ {
+ "position": 320,
+ "nodeLength": 43,
+ "src": "event.type === \"click\" && event.button >= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 320,
+ "nodeLength": 22,
+ "src": "event.type === \"click\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 346,
+ "nodeLength": 17,
+ "src": "event.button >= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5241": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 12,
+ "src": "cur !== this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 22,
+ "src": "cur.parentNode || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5245": [
+ null,
+ {
+ "position": 132,
+ "nodeLength": 74,
+ "src": "cur.nodeType === 1 && !(event.type === \"click\" && cur.disabled === true)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 132,
+ "nodeLength": 18,
+ "src": "cur.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 157,
+ "nodeLength": 47,
+ "src": "event.type === \"click\" && cur.disabled === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 157,
+ "nodeLength": 22,
+ "src": "event.type === \"click\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 183,
+ "nodeLength": 21,
+ "src": "cur.disabled === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5248": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 17,
+ "src": "i < delegateCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5254": [
+ null,
+ {
+ "position": 151,
+ "nodeLength": 37,
+ "src": "matchedSelectors[sel] === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5255": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 22,
+ "src": "handleObj.needsContext",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5256": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 37,
+ "src": "jQuery(sel, this).index(cur) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5259": [
+ null,
+ {
+ "position": 374,
+ "nodeLength": 23,
+ "src": "matchedSelectors[sel]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5263": [
+ null,
+ {
+ "position": 569,
+ "nodeLength": 22,
+ "src": "matchedHandlers.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5272": [
+ null,
+ {
+ "position": 1680,
+ "nodeLength": 31,
+ "src": "delegateCount < handlers.length",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5284": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(hook)",
+ "evalFalse": 29,
+ "evalTrue": 1
+ }
+ ],
+ "5286": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 18,
+ "src": "this.originalEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5291": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 18,
+ "src": "this.originalEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5308": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 31,
+ "src": "originalEvent[jQuery.expando]",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5323": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 42,
+ "src": "this !== safeActiveElement() && this.focus",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 28,
+ "src": "this !== safeActiveElement()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5332": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 41,
+ "src": "this === safeActiveElement() && this.blur",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 28,
+ "src": "this === safeActiveElement()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5343": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 74,
+ "src": "this.type === \"checkbox\" && this.click && jQuery.nodeName(this, \"input\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 24,
+ "src": "this.type === \"checkbox\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38,
+ "nodeLength": 46,
+ "src": "this.click && jQuery.nodeName(this, \"input\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5360": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 49,
+ "src": "event.result !== undefined && event.originalEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 105,
+ "nodeLength": 26,
+ "src": "event.result !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5371": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 24,
+ "src": "elem.removeEventListener",
+ "evalFalse": 0,
+ "evalTrue": 55
+ }
+ ],
+ "5379": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 33,
+ "src": "!(this instanceof jQuery.Event)",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "5384": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 15,
+ "src": "src && src.type",
+ "evalFalse": 7,
+ "evalTrue": 1
+ }
+ ],
+ "5390": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 131,
+ "src": "src.defaultPrevented || src.defaultPrevented === undefined && src.returnValue === false",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5391": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 103,
+ "src": "src.defaultPrevented === undefined && src.returnValue === false",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 34,
+ "src": "src.defaultPrevented === undefined",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5394": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 25,
+ "src": "src.returnValue === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5401": [
+ null,
+ {
+ "position": 521,
+ "nodeLength": 41,
+ "src": "(src.target && src.target.nodeType === 3)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 521,
+ "nodeLength": 39,
+ "src": "src.target && src.target.nodeType === 3",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 535,
+ "nodeLength": 25,
+ "src": "src.target.nodeType === 3",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5414": [
+ null,
+ {
+ "position": 991,
+ "nodeLength": 5,
+ "src": "props",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "5419": [
+ null,
+ {
+ "position": 1113,
+ "nodeLength": 36,
+ "src": "src && src.timeStamp || jQuery.now()",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 1113,
+ "nodeLength": 20,
+ "src": "src && src.timeStamp",
+ "evalFalse": 7,
+ "evalTrue": 1
+ }
+ ],
+ "5439": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 22,
+ "src": "e && !this.isSimulated",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5448": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 22,
+ "src": "e && !this.isSimulated",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5457": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 22,
+ "src": "e && !this.isSimulated",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5501": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 51,
+ "src": "event.which == null && rkeyEvent.test(event.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68,
+ "nodeLength": 19,
+ "src": "event.which == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5502": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 22,
+ "src": "event.charCode != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5506": [
+ null,
+ {
+ "position": 267,
+ "nodeLength": 70,
+ "src": "!event.which && button !== undefined && rmouseEvent.test(event.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 283,
+ "nodeLength": 54,
+ "src": "button !== undefined && rmouseEvent.test(event.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 283,
+ "nodeLength": 20,
+ "src": "button !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5507": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 10,
+ "src": "button & 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5511": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 10,
+ "src": "button & 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5515": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 10,
+ "src": "button & 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5552": [
+ null,
+ {
+ "position": 259,
+ "nodeLength": 73,
+ "src": "!related || (related !== target && !jQuery.contains(target, related))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 273,
+ "nodeLength": 57,
+ "src": "related !== target && !jQuery.contains(target, related)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 273,
+ "nodeLength": 18,
+ "src": "related !== target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5572": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 48,
+ "src": "types && types.preventDefault && types.handleObj",
+ "evalFalse": 42,
+ "evalTrue": 0
+ },
+ {
+ "position": 40,
+ "nodeLength": 39,
+ "src": "types.preventDefault && types.handleObj",
+ "evalFalse": 42,
+ "evalTrue": 0
+ }
+ ],
+ "5577": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 19,
+ "src": "handleObj.namespace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5585": [
+ null,
+ {
+ "position": 381,
+ "nodeLength": 25,
+ "src": "typeof types === \"object\"",
+ "evalFalse": 42,
+ "evalTrue": 0
+ }
+ ],
+ "5593": [
+ null,
+ {
+ "position": 554,
+ "nodeLength": 52,
+ "src": "selector === false || typeof selector === \"function\"",
+ "evalFalse": 10,
+ "evalTrue": 32
+ },
+ {
+ "position": 554,
+ "nodeLength": 18,
+ "src": "selector === false",
+ "evalFalse": 42,
+ "evalTrue": 0
+ },
+ {
+ "position": 576,
+ "nodeLength": 30,
+ "src": "typeof selector === \"function\"",
+ "evalFalse": 10,
+ "evalTrue": 32
+ }
+ ],
+ "5599": [
+ null,
+ {
+ "position": 689,
+ "nodeLength": 12,
+ "src": "fn === false",
+ "evalFalse": 42,
+ "evalTrue": 0
+ }
+ ],
+ "5629": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 117,
+ "src": "jQuery.nodeName(elem, \"table\") && jQuery.nodeName(content.nodeType !== 11 ? content : content.firstChild, \"tr\")",
+ "evalFalse": 14,
+ "evalTrue": 0
+ }
+ ],
+ "5630": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 23,
+ "src": "content.nodeType !== 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5632": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 49,
+ "src": "elem.getElementsByTagName(\"tbody\")[0] || elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5640": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 36,
+ "src": "elem.getAttribute(\"type\") !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5646": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 5,
+ "src": "match",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5658": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 19,
+ "src": "dest.nodeType !== 1",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5663": [
+ null,
+ {
+ "position": 166,
+ "nodeLength": 23,
+ "src": "dataPriv.hasData(src)",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5668": [
+ null,
+ {
+ "position": 119,
+ "nodeLength": 6,
+ "src": "events",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5673": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5681": [
+ null,
+ {
+ "position": 568,
+ "nodeLength": 23,
+ "src": "dataUser.hasData(src)",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5694": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 55,
+ "src": "nodeName === \"input\" && rcheckableType.test(src.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 130,
+ "nodeLength": 20,
+ "src": "nodeName === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5698": [
+ null,
+ {
+ "position": 326,
+ "nodeLength": 47,
+ "src": "nodeName === \"input\" || nodeName === \"textarea\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 326,
+ "nodeLength": 20,
+ "src": "nodeName === \"input\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 350,
+ "nodeLength": 23,
+ "src": "nodeName === \"textarea\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5716": [
+ null,
+ {
+ "position": 311,
+ "nodeLength": 108,
+ "src": "isFunction || (l > 1 && typeof value === \"string\" && !support.checkClone && rchecked.test(value))",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "5717": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 87,
+ "src": "l > 1 && typeof value === \"string\" && !support.checkClone && rchecked.test(value)",
+ "evalFalse": 20,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 5,
+ "src": "l > 1",
+ "evalFalse": 20,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 78,
+ "src": "typeof value === \"string\" && !support.checkClone && rchecked.test(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 25,
+ "src": "typeof value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5718": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 45,
+ "src": "!support.checkClone && rchecked.test(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5721": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 10,
+ "src": "isFunction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5728": [
+ null,
+ {
+ "position": 655,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 20
+ }
+ ],
+ "5732": [
+ null,
+ {
+ "position": 135,
+ "nodeLength": 32,
+ "src": "fragment.childNodes.length === 1",
+ "evalFalse": 6,
+ "evalTrue": 14
+ }
+ ],
+ "5737": [
+ null,
+ {
+ "position": 295,
+ "nodeLength": 16,
+ "src": "first || ignored",
+ "evalFalse": 0,
+ "evalTrue": 20
+ }
+ ],
+ "5744": [
+ null,
+ {
+ "position": 279,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 20,
+ "evalTrue": 20
+ }
+ ],
+ "5747": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 14,
+ "src": "i !== iNoClone",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "5751": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 10,
+ "src": "hasScripts",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5762": [
+ null,
+ {
+ "position": 727,
+ "nodeLength": 10,
+ "src": "hasScripts",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "5769": [
+ null,
+ {
+ "position": 204,
+ "nodeLength": 14,
+ "src": "i < hasScripts",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5771": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 121,
+ "src": "rscriptType.test(node.type || \"\") && !dataPriv.access(node, \"globalEval\") && jQuery.contains(doc, node)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 15,
+ "src": "node.type || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5772": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 76,
+ "src": "!dataPriv.access(node, \"globalEval\") && jQuery.contains(doc, node)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5775": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 8,
+ "src": "node.src",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5778": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 15,
+ "src": "jQuery._evalUrl",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5795": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 10,
+ "evalTrue": 0
+ }
+ ],
+ "5798": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 27,
+ "src": "(node = nodes[i]) != null",
+ "evalFalse": 10,
+ "evalTrue": 17
+ }
+ ],
+ "5799": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 32,
+ "src": "!keepData && node.nodeType === 1",
+ "evalFalse": 17,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 19,
+ "src": "node.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5803": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 15,
+ "src": "node.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 17
+ }
+ ],
+ "5804": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 55,
+ "src": "keepData && jQuery.contains(node.ownerDocument, node)",
+ "evalFalse": 0,
+ "evalTrue": 17
+ }
+ ],
+ "5825": [
+ null,
+ {
+ "position": 167,
+ "nodeLength": 106,
+ "src": "!support.noCloneChecked && (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem)",
+ "evalFalse": 6,
+ "evalTrue": 0
+ },
+ {
+ "position": 196,
+ "nodeLength": 77,
+ "src": "(elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 196,
+ "nodeLength": 43,
+ "src": "elem.nodeType === 1 || elem.nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 196,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 219,
+ "nodeLength": 20,
+ "src": "elem.nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5832": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5838": [
+ null,
+ {
+ "position": 616,
+ "nodeLength": 13,
+ "src": "dataAndEvents",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5839": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 17,
+ "src": "deepDataAndEvents",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5840": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 29,
+ "src": "srcElements || getAll(elem)",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5841": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 31,
+ "src": "destElements || getAll(clone)",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5843": [
+ null,
+ {
+ "position": 144,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 6,
+ "evalTrue": 6
+ }
+ ],
+ "5853": [
+ null,
+ {
+ "position": 1036,
+ "nodeLength": 23,
+ "src": "destElements.length > 0",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5854": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 35,
+ "src": "!inPage && getAll(elem, \"script\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5866": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 33,
+ "src": "(elem = elems[i]) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5867": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 18,
+ "src": "acceptData(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5868": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 33,
+ "src": "(data = elem[dataPriv.expando])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5869": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 11,
+ "src": "data.events",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5871": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 15,
+ "src": "special[type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5885": [
+ null,
+ {
+ "position": 512,
+ "nodeLength": 24,
+ "src": "elem[dataUser.expando]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5907": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5910": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 66,
+ "src": "this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 19,
+ "src": "this.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 43,
+ "src": "this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 20,
+ "src": "this.nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58,
+ "nodeLength": 19,
+ "src": "this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5919": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 66,
+ "src": "this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 14
+ },
+ {
+ "position": 9,
+ "nodeLength": 19,
+ "src": "this.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 14
+ },
+ {
+ "position": 32,
+ "nodeLength": 43,
+ "src": "this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32,
+ "nodeLength": 20,
+ "src": "this.nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56,
+ "nodeLength": 19,
+ "src": "this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5928": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 66,
+ "src": "this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 19,
+ "src": "this.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32,
+ "nodeLength": 43,
+ "src": "this.nodeType === 11 || this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32,
+ "nodeLength": 20,
+ "src": "this.nodeType === 11",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56,
+ "nodeLength": 19,
+ "src": "this.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5937": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 15,
+ "src": "this.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5945": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 15,
+ "src": "this.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5955": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 26,
+ "src": "(elem = this[i]) != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5956": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5970": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 21,
+ "src": "dataAndEvents == null",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "5971": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 25,
+ "src": "deepDataAndEvents == null",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "5980": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 15,
+ "src": "this[0] || {}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5984": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 42,
+ "src": "value === undefined && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 73,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 96,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5989": [
+ null,
+ {
+ "position": 220,
+ "nodeLength": 135,
+ "src": "typeof value === \"string\" && !rnoInnerhtml.test(value) && !wrapMap[(rtagName.exec(value) || [\"\", \"\"])[1].toLowerCase()]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 220,
+ "nodeLength": 25,
+ "src": "typeof value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 249,
+ "nodeLength": 106,
+ "src": "!rnoInnerhtml.test(value) && !wrapMap[(rtagName.exec(value) || [\"\", \"\"])[1].toLowerCase()]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5990": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 36,
+ "src": "rtagName.exec(value) || [\"\", \"\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5995": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5996": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 15,
+ "src": "this[i] || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5999": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6011": [
+ null,
+ {
+ "position": 783,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6024": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 35,
+ "src": "jQuery.inArray(this, ignored) < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6026": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 6,
+ "src": "parent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6050": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 9,
+ "src": "i <= last",
+ "evalFalse": 7,
+ "evalTrue": 7
+ }
+ ],
+ "6051": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 10,
+ "src": "i === last",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "6073": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 21,
+ "src": "!view || !view.opener",
+ "evalFalse": 0,
+ "evalTrue": 72
+ }
+ ],
+ "6089": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 4,
+ "src": "!div",
+ "evalFalse": 1,
+ "evalTrue": 148
+ }
+ ],
+ "6102": [
+ null,
+ {
+ "position": 387,
+ "nodeLength": 21,
+ "src": "divStyle.top !== \"1%\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6105": [
+ null,
+ {
+ "position": 492,
+ "nodeLength": 29,
+ "src": "divStyle.marginLeft === \"2px\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6106": [
+ null,
+ {
+ "position": 548,
+ "nodeLength": 24,
+ "src": "divStyle.width === \"4px\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6111": [
+ null,
+ {
+ "position": 747,
+ "nodeLength": 30,
+ "src": "divStyle.marginRight === \"4px\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6125": [
+ null,
+ {
+ "position": 1393,
+ "nodeLength": 10,
+ "src": "!div.style",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6133": [
+ null,
+ {
+ "position": 1638,
+ "nodeLength": 42,
+ "src": "div.style.backgroundClip === \"content-box\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6164": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 29,
+ "src": "computed || getStyles(elem)",
+ "evalFalse": 0,
+ "evalTrue": 148
+ }
+ ],
+ "6168": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 8,
+ "src": "computed",
+ "evalFalse": 0,
+ "evalTrue": 148
+ }
+ ],
+ "6169": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 53,
+ "src": "computed.getPropertyValue(name) || computed[name]",
+ "evalFalse": 0,
+ "evalTrue": 148
+ }
+ ],
+ "6171": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 58,
+ "src": "ret === \"\" && !jQuery.contains(elem.ownerDocument, elem)",
+ "evalFalse": 148,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 10,
+ "src": "ret === \"\"",
+ "evalFalse": 148,
+ "evalTrue": 0
+ }
+ ],
+ "6180": [
+ null,
+ {
+ "position": 434,
+ "nodeLength": 76,
+ "src": "!support.pixelMarginRight() && rnumnonpx.test(ret) && rmargin.test(name)",
+ "evalFalse": 148,
+ "evalTrue": 0
+ },
+ {
+ "position": 465,
+ "nodeLength": 45,
+ "src": "rnumnonpx.test(ret) && rmargin.test(name)",
+ "evalFalse": 148,
+ "evalTrue": 0
+ }
+ ],
+ "6198": [
+ null,
+ {
+ "position": 1118,
+ "nodeLength": 17,
+ "src": "ret !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 148
+ }
+ ],
+ "6212": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 13,
+ "src": "conditionFn()",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6246": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 18,
+ "src": "name in emptyStyle",
+ "evalFalse": 0,
+ "evalTrue": 18
+ }
+ ],
+ "6254": [
+ null,
+ {
+ "position": 232,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6256": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 18,
+ "src": "name in emptyStyle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6267": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 7,
+ "src": "matches",
+ "evalFalse": 5,
+ "evalTrue": 20
+ }
+ ],
+ "6270": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 13,
+ "src": "subtract || 0",
+ "evalFalse": 20,
+ "evalTrue": 0
+ },
+ {
+ "position": 136,
+ "nodeLength": 20,
+ "src": "matches[3] || \"px\"",
+ "evalFalse": 0,
+ "evalTrue": 20
+ }
+ ],
+ "6279": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 48,
+ "src": "extra === (isBorderBox ? \"border\" : \"content\")",
+ "evalFalse": 24,
+ "evalTrue": 20
+ },
+ {
+ "position": 104,
+ "nodeLength": 11,
+ "src": "isBorderBox",
+ "evalFalse": 20,
+ "evalTrue": 24
+ }
+ ],
+ "6284": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 16,
+ "src": "name === \"width\"",
+ "evalFalse": 8,
+ "evalTrue": 16
+ }
+ ],
+ "6287": [
+ null,
+ {
+ "position": 273,
+ "nodeLength": 5,
+ "src": "i < 4",
+ "evalFalse": 44,
+ "evalTrue": 48
+ }
+ ],
+ "6290": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 18,
+ "src": "extra === \"margin\"",
+ "evalFalse": 32,
+ "evalTrue": 16
+ }
+ ],
+ "6294": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 11,
+ "src": "isBorderBox",
+ "evalFalse": 0,
+ "evalTrue": 48
+ }
+ ],
+ "6297": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 19,
+ "src": "extra === \"content\"",
+ "evalFalse": 16,
+ "evalTrue": 32
+ }
+ ],
+ "6302": [
+ null,
+ {
+ "position": 257,
+ "nodeLength": 18,
+ "src": "extra !== \"margin\"",
+ "evalFalse": 16,
+ "evalTrue": 32
+ }
+ ],
+ "6311": [
+ null,
+ {
+ "position": 208,
+ "nodeLength": 19,
+ "src": "extra !== \"padding\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6326": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 63,
+ "src": "jQuery.css(elem, \"boxSizing\", false, styles) === \"border-box\"",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "6331": [
+ null,
+ {
+ "position": 343,
+ "nodeLength": 28,
+ "src": "elem.getClientRects().length",
+ "evalFalse": 0,
+ "evalTrue": 24
+ }
+ ],
+ "6338": [
+ null,
+ {
+ "position": 648,
+ "nodeLength": 23,
+ "src": "val <= 0 || val == null",
+ "evalFalse": 24,
+ "evalTrue": 0
+ },
+ {
+ "position": 648,
+ "nodeLength": 8,
+ "src": "val <= 0",
+ "evalFalse": 24,
+ "evalTrue": 0
+ },
+ {
+ "position": 660,
+ "nodeLength": 11,
+ "src": "val == null",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "6342": [
+ null,
+ {
+ "position": 107,
+ "nodeLength": 22,
+ "src": "val < 0 || val == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 107,
+ "nodeLength": 7,
+ "src": "val < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 118,
+ "nodeLength": 11,
+ "src": "val == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6347": [
+ null,
+ {
+ "position": 231,
+ "nodeLength": 21,
+ "src": "rnumnonpx.test(val)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6353": [
+ null,
+ {
+ "position": 442,
+ "nodeLength": 79,
+ "src": "isBorderBox && (support.boxSizingReliable() || val === elem.style[name])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6354": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 57,
+ "src": "support.boxSizingReliable() || val === elem.style[name]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50,
+ "nodeLength": 26,
+ "src": "val === elem.style[name]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6357": [
+ null,
+ {
+ "position": 579,
+ "nodeLength": 22,
+ "src": "parseFloat(val) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6365": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 47,
+ "src": "extra || (isBorderBox ? \"border\" : \"content\")",
+ "evalFalse": 0,
+ "evalTrue": 24
+ },
+ {
+ "position": 62,
+ "nodeLength": 11,
+ "src": "isBorderBox",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6379": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "computed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6383": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 10,
+ "src": "ret === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6416": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 66,
+ "src": "!elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 57,
+ "src": "elem.nodeType === 3 || elem.nodeType === 8 || !elem.style",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 3",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 89,
+ "nodeLength": 34,
+ "src": "elem.nodeType === 8 || !elem.style",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 89,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 8",
+ "evalFalse": 35,
+ "evalTrue": 0
+ }
+ ],
+ "6425": [
+ null,
+ {
+ "position": 295,
+ "nodeLength": 106,
+ "src": "jQuery.cssProps[origName] || (jQuery.cssProps[origName] = vendorPropName(origName) || origName)",
+ "evalFalse": 0,
+ "evalTrue": 35
+ }
+ ],
+ "6426": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 38,
+ "src": "vendorPropName(origName) || origName",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "6429": [
+ null,
+ {
+ "position": 479,
+ "nodeLength": 54,
+ "src": "jQuery.cssHooks[name] || jQuery.cssHooks[origName]",
+ "evalFalse": 4,
+ "evalTrue": 31
+ }
+ ],
+ "6432": [
+ null,
+ {
+ "position": 579,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 35
+ }
+ ],
+ "6436": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 64,
+ "src": "type === \"string\" && (ret = rcssNum.exec(value)) && ret[1]",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 89,
+ "nodeLength": 17,
+ "src": "type === \"string\"",
+ "evalFalse": 26,
+ "evalTrue": 9
+ },
+ {
+ "position": 112,
+ "nodeLength": 41,
+ "src": "(ret = rcssNum.exec(value)) && ret[1]",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "6444": [
+ null,
+ {
+ "position": 319,
+ "nodeLength": 32,
+ "src": "value == null || value !== value",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 319,
+ "nodeLength": 13,
+ "src": "value == null",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 336,
+ "nodeLength": 15,
+ "src": "value !== value",
+ "evalFalse": 35,
+ "evalTrue": 0
+ }
+ ],
+ "6449": [
+ null,
+ {
+ "position": 464,
+ "nodeLength": 17,
+ "src": "type === \"number\"",
+ "evalFalse": 9,
+ "evalTrue": 26
+ }
+ ],
+ "6450": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 63,
+ "src": "ret && ret[3] || (jQuery.cssNumber[origName] ? \"\" : \"px\")",
+ "evalFalse": 6,
+ "evalTrue": 20
+ },
+ {
+ "position": 14,
+ "nodeLength": 15,
+ "src": "ret && ret[3]",
+ "evalFalse": 26,
+ "evalTrue": 0
+ },
+ {
+ "position": 35,
+ "nodeLength": 28,
+ "src": "jQuery.cssNumber[origName]",
+ "evalFalse": 20,
+ "evalTrue": 6
+ }
+ ],
+ "6454": [
+ null,
+ {
+ "position": 634,
+ "nodeLength": 78,
+ "src": "!support.clearCloneStyle && value === \"\" && name.indexOf(\"background\") === 0",
+ "evalFalse": 35,
+ "evalTrue": 0
+ },
+ {
+ "position": 662,
+ "nodeLength": 50,
+ "src": "value === \"\" && name.indexOf(\"background\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 662,
+ "nodeLength": 12,
+ "src": "value === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 678,
+ "nodeLength": 34,
+ "src": "name.indexOf(\"background\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6459": [
+ null,
+ {
+ "position": 847,
+ "nodeLength": 94,
+ "src": "!hooks || !(\"set\" in hooks) || (value = hooks.set(elem, value, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 35
+ },
+ {
+ "position": 857,
+ "nodeLength": 84,
+ "src": "!(\"set\" in hooks) || (value = hooks.set(elem, value, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 31
+ }
+ ],
+ "6460": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 55,
+ "src": "(value = hooks.set(elem, value, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 25
+ }
+ ],
+ "6468": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 86,
+ "src": "hooks && \"get\" in hooks && (ret = hooks.get(elem, false, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86,
+ "nodeLength": 77,
+ "src": "\"get\" in hooks && (ret = hooks.get(elem, false, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6469": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 53,
+ "src": "(ret = hooks.get(elem, false, extra)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6484": [
+ null,
+ {
+ "position": 128,
+ "nodeLength": 106,
+ "src": "jQuery.cssProps[origName] || (jQuery.cssProps[origName] = vendorPropName(origName) || origName)",
+ "evalFalse": 0,
+ "evalTrue": 172
+ }
+ ],
+ "6485": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 38,
+ "src": "vendorPropName(origName) || origName",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "6488": [
+ null,
+ {
+ "position": 302,
+ "nodeLength": 54,
+ "src": "jQuery.cssHooks[name] || jQuery.cssHooks[origName]",
+ "evalFalse": 144,
+ "evalTrue": 28
+ }
+ ],
+ "6491": [
+ null,
+ {
+ "position": 428,
+ "nodeLength": 23,
+ "src": "hooks && \"get\" in hooks",
+ "evalFalse": 144,
+ "evalTrue": 28
+ }
+ ],
+ "6496": [
+ null,
+ {
+ "position": 577,
+ "nodeLength": 17,
+ "src": "val === undefined",
+ "evalFalse": 28,
+ "evalTrue": 144
+ }
+ ],
+ "6501": [
+ null,
+ {
+ "position": 690,
+ "nodeLength": 46,
+ "src": "val === \"normal\" && name in cssNormalTransform",
+ "evalFalse": 172,
+ "evalTrue": 0
+ },
+ {
+ "position": 690,
+ "nodeLength": 16,
+ "src": "val === \"normal\"",
+ "evalFalse": 172,
+ "evalTrue": 0
+ }
+ ],
+ "6506": [
+ null,
+ {
+ "position": 868,
+ "nodeLength": 21,
+ "src": "extra === \"\" || extra",
+ "evalFalse": 68,
+ "evalTrue": 104
+ },
+ {
+ "position": 868,
+ "nodeLength": 12,
+ "src": "extra === \"\"",
+ "evalFalse": 172,
+ "evalTrue": 0
+ }
+ ],
+ "6508": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 33,
+ "src": "extra === true || isFinite(num)",
+ "evalFalse": 0,
+ "evalTrue": 104
+ },
+ {
+ "position": 39,
+ "nodeLength": 14,
+ "src": "extra === true",
+ "evalFalse": 24,
+ "evalTrue": 80
+ },
+ {
+ "position": 75,
+ "nodeLength": 8,
+ "src": "num || 0",
+ "evalFalse": 80,
+ "evalTrue": 24
+ }
+ ],
+ "6517": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 8,
+ "src": "computed",
+ "evalFalse": 0,
+ "evalTrue": 24
+ }
+ ],
+ "6521": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 411,
+ "src": "rdisplayswap.test(jQuery.css(elem, \"display\")) && (!elem.getClientRects().length || !elem.getBoundingClientRect().width)",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "6529": [
+ null,
+ {
+ "position": 340,
+ "nodeLength": 68,
+ "src": "!elem.getClientRects().length || !elem.getBoundingClientRect().width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6539": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 26,
+ "src": "extra && getStyles(elem)",
+ "evalFalse": 5,
+ "evalTrue": 20
+ }
+ ],
+ "6540": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 152,
+ "src": "extra && augmentWidthOrHeight(elem, name, extra, jQuery.css(elem, \"boxSizing\", false, styles) === \"border-box\", styles)",
+ "evalFalse": 25,
+ "evalTrue": 0
+ }
+ ],
+ "6544": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 63,
+ "src": "jQuery.css(elem, \"boxSizing\", false, styles) === \"border-box\"",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "6549": [
+ null,
+ {
+ "position": 290,
+ "nodeLength": 88,
+ "src": "subtract && (matches = rcssNum.exec(value)) && (matches[3] || \"px\") !== \"px\"",
+ "evalFalse": 25,
+ "evalTrue": 0
+ },
+ {
+ "position": 304,
+ "nodeLength": 74,
+ "src": "(matches = rcssNum.exec(value)) && (matches[3] || \"px\") !== \"px\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6550": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 31,
+ "src": "(matches[3] || \"px\") !== \"px\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 350,
+ "nodeLength": 20,
+ "src": "matches[3] || \"px\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6563": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 8,
+ "src": "computed",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "6564": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 191,
+ "src": "parseFloat(curCSS(elem, \"marginLeft\")) || elem.getBoundingClientRect().left - swap(elem, {\n marginLeft: 0}, function() {\n return elem.getBoundingClientRect().left;\n})",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "6586": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 25,
+ "src": "typeof value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6588": [
+ null,
+ {
+ "position": 166,
+ "nodeLength": 5,
+ "src": "i < 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6590": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 42,
+ "src": "parts[i] || parts[i - 2] || parts[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 28,
+ "src": "parts[i - 2] || parts[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6597": [
+ null,
+ {
+ "position": 392,
+ "nodeLength": 23,
+ "src": "!rmargin.test(prefix)",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "6609": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 22,
+ "src": "jQuery.isArray(name)",
+ "evalFalse": 15,
+ "evalTrue": 0
+ }
+ ],
+ "6613": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6620": [
+ null,
+ {
+ "position": 276,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 15
+ }
+ ],
+ "6623": [
+ null,
+ {
+ "position": 430,
+ "nodeLength": 20,
+ "src": "arguments.length > 1",
+ "evalFalse": 7,
+ "evalTrue": 4
+ }
+ ],
+ "6638": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 32,
+ "src": "easing || jQuery.easing._default",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6642": [
+ null,
+ {
+ "position": 187,
+ "nodeLength": 48,
+ "src": "unit || (jQuery.cssNumber[prop] ? \"\" : \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 197,
+ "nodeLength": 24,
+ "src": "jQuery.cssNumber[prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6647": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 18,
+ "src": "hooks && hooks.get",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6655": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 21,
+ "src": "this.options.duration",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6664": [
+ null,
+ {
+ "position": 336,
+ "nodeLength": 17,
+ "src": "this.options.step",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6668": [
+ null,
+ {
+ "position": 426,
+ "nodeLength": 18,
+ "src": "hooks && hooks.set",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6686": [
+ null,
+ {
+ "position": 163,
+ "nodeLength": 107,
+ "src": "tween.elem.nodeType !== 1 || tween.elem[tween.prop] != null && tween.elem.style[tween.prop] == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 163,
+ "nodeLength": 25,
+ "src": "tween.elem.nodeType !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6687": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 74,
+ "src": "tween.elem[tween.prop] != null && tween.elem.style[tween.prop] == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 198,
+ "nodeLength": 32,
+ "src": "tween.elem[tween.prop] != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35,
+ "nodeLength": 38,
+ "src": "tween.elem.style[tween.prop] == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6698": [
+ null,
+ {
+ "position": 719,
+ "nodeLength": 28,
+ "src": "!result || result === \"auto\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 730,
+ "nodeLength": 17,
+ "src": "result === \"auto\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6705": [
+ null,
+ {
+ "position": 151,
+ "nodeLength": 28,
+ "src": "jQuery.fx.step[tween.prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6707": [
+ null,
+ {
+ "position": 243,
+ "nodeLength": 132,
+ "src": "tween.elem.nodeType === 1 && (tween.elem.style[jQuery.cssProps[tween.prop]] != null || jQuery.cssHooks[tween.prop])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 243,
+ "nodeLength": 25,
+ "src": "tween.elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6708": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 95,
+ "src": "tween.elem.style[jQuery.cssProps[tween.prop]] != null || jQuery.cssHooks[tween.prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 57,
+ "src": "tween.elem.style[jQuery.cssProps[tween.prop]] != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6722": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 44,
+ "src": "tween.elem.nodeType && tween.elem.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6752": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 7,
+ "src": "timerId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6774": [
+ null,
+ {
+ "position": 194,
+ "nodeLength": 12,
+ "src": "includeWidth",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "6775": [
+ null,
+ {
+ "position": 225,
+ "nodeLength": 5,
+ "src": "i < 4",
+ "evalFalse": 3,
+ "evalTrue": 6
+ }
+ ],
+ "6780": [
+ null,
+ {
+ "position": 359,
+ "nodeLength": 12,
+ "src": "includeWidth",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "6789": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 32,
+ "src": "Animation.tweeners[prop] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6792": [
+ null,
+ {
+ "position": 154,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6793": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 60,
+ "src": "(tween = collection[index].call(animation, prop, value))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6803": [
+ null,
+ {
+ "position": 86,
+ "nodeLength": 37,
+ "src": "\"width\" in props || \"height\" in props",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6807": [
+ null,
+ {
+ "position": 186,
+ "nodeLength": 43,
+ "src": "elem.nodeType && isHiddenWithinTree(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6811": [
+ null,
+ {
+ "position": 336,
+ "nodeLength": 11,
+ "src": "!opts.queue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6813": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 22,
+ "src": "hooks.unqueued == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6817": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 15,
+ "src": "!hooks.unqueued",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6829": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 34,
+ "src": "!jQuery.queue(elem, \"fx\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6839": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 22,
+ "src": "rfxtypes.test(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6841": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 28,
+ "src": "toggle || value === \"toggle\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48,
+ "nodeLength": 18,
+ "src": "value === \"toggle\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6842": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 38,
+ "src": "value === (hidden ? \"hide\" : \"show\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88,
+ "nodeLength": 6,
+ "src": "hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6846": [
+ null,
+ {
+ "position": 115,
+ "nodeLength": 62,
+ "src": "value === \"show\" && dataShow && dataShow[prop] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 16,
+ "src": "value === \"show\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 135,
+ "nodeLength": 42,
+ "src": "dataShow && dataShow[prop] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 147,
+ "nodeLength": 30,
+ "src": "dataShow[prop] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6854": [
+ null,
+ {
+ "position": 423,
+ "nodeLength": 58,
+ "src": "dataShow && dataShow[prop] || jQuery.style(elem, prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 423,
+ "nodeLength": 28,
+ "src": "dataShow && dataShow[prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6860": [
+ null,
+ {
+ "position": 1554,
+ "nodeLength": 42,
+ "src": "!propTween && jQuery.isEmptyObject(orig)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6865": [
+ null,
+ {
+ "position": 1688,
+ "nodeLength": 28,
+ "src": "isBox && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1697,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6873": [
+ null,
+ {
+ "position": 346,
+ "nodeLength": 28,
+ "src": "dataShow && dataShow.display",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6874": [
+ null,
+ {
+ "position": 383,
+ "nodeLength": 22,
+ "src": "restoreDisplay == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6878": [
+ null,
+ {
+ "position": 517,
+ "nodeLength": 18,
+ "src": "display === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6879": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 14,
+ "src": "restoreDisplay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6885": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 36,
+ "src": "elem.style.display || restoreDisplay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6892": [
+ null,
+ {
+ "position": 897,
+ "nodeLength": 76,
+ "src": "display === \"inline\" || display === \"inline-block\" && restoreDisplay != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 897,
+ "nodeLength": 20,
+ "src": "display === \"inline\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 921,
+ "nodeLength": 52,
+ "src": "display === \"inline-block\" && restoreDisplay != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 921,
+ "nodeLength": 26,
+ "src": "display === \"inline-block\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 951,
+ "nodeLength": 22,
+ "src": "restoreDisplay != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6893": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 38,
+ "src": "jQuery.css(elem, \"float\") === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6896": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 10,
+ "src": "!propTween",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6900": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 22,
+ "src": "restoreDisplay == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6902": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 18,
+ "src": "display === \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6910": [
+ null,
+ {
+ "position": 3127,
+ "nodeLength": 13,
+ "src": "opts.overflow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6924": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 10,
+ "src": "!propTween",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6925": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 8,
+ "src": "dataShow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6926": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 20,
+ "src": "\"hidden\" in dataShow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6934": [
+ null,
+ {
+ "position": 269,
+ "nodeLength": 6,
+ "src": "toggle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6939": [
+ null,
+ {
+ "position": 367,
+ "nodeLength": 6,
+ "src": "hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6950": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 7,
+ "src": "!hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6961": [
+ null,
+ {
+ "position": 910,
+ "nodeLength": 6,
+ "src": "hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6962": [
+ null,
+ {
+ "position": 962,
+ "nodeLength": 21,
+ "src": "!(prop in dataShow)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6964": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 6,
+ "src": "hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6980": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 23,
+ "src": "jQuery.isArray(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6985": [
+ null,
+ {
+ "position": 208,
+ "nodeLength": 14,
+ "src": "index !== name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6991": [
+ null,
+ {
+ "position": 326,
+ "nodeLength": 26,
+ "src": "hooks && \"expand\" in hooks",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6998": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 19,
+ "src": "!(index in props)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7020": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 7,
+ "src": "stopped",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7023": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 22,
+ "src": "fxNow || createFxNow()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7028": [
+ null,
+ {
+ "position": 248,
+ "nodeLength": 35,
+ "src": "remaining / animation.duration || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7033": [
+ null,
+ {
+ "position": 422,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7039": [
+ null,
+ {
+ "position": 578,
+ "nodeLength": 21,
+ "src": "percent < 1 && length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 578,
+ "nodeLength": 11,
+ "src": "percent < 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7055": [
+ null,
+ {
+ "position": 244,
+ "nodeLength": 22,
+ "src": "fxNow || createFxNow()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7060": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 61,
+ "src": "animation.opts.specialEasing[prop] || animation.opts.easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7069": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 7,
+ "src": "gotoEnd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7070": [
+ null,
+ {
+ "position": 185,
+ "nodeLength": 7,
+ "src": "stopped",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7074": [
+ null,
+ {
+ "position": 253,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7079": [
+ null,
+ {
+ "position": 402,
+ "nodeLength": 7,
+ "src": "gotoEnd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7092": [
+ null,
+ {
+ "position": 2279,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7094": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 6,
+ "src": "result",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7095": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 32,
+ "src": "jQuery.isFunction(result.stop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7105": [
+ null,
+ {
+ "position": 2655,
+ "nodeLength": 41,
+ "src": "jQuery.isFunction(animation.opts.start)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7135": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 26,
+ "src": "jQuery.isFunction(props)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7146": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7148": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 32,
+ "src": "Animation.tweeners[prop] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7156": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 7,
+ "src": "prepend",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7165": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 34,
+ "src": "speed && typeof speed === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 25,
+ "src": "typeof speed === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7166": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 61,
+ "src": "fn || !fn && easing || jQuery.isFunction(speed) && speed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19,
+ "nodeLength": 55,
+ "src": "!fn && easing || jQuery.isFunction(speed) && speed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19,
+ "nodeLength": 13,
+ "src": "!fn && easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7167": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 35,
+ "src": "jQuery.isFunction(speed) && speed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7169": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 64,
+ "src": "fn && easing || easing && !jQuery.isFunction(easing) && easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 105,
+ "nodeLength": 12,
+ "src": "fn && easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121,
+ "nodeLength": 48,
+ "src": "easing && !jQuery.isFunction(easing) && easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 131,
+ "nodeLength": 38,
+ "src": "!jQuery.isFunction(easing) && easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7173": [
+ null,
+ {
+ "position": 323,
+ "nodeLength": 32,
+ "src": "jQuery.fx.off || document.hidden",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7177": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 32,
+ "src": "typeof opt.duration !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7178": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 32,
+ "src": "opt.duration in jQuery.fx.speeds",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7188": [
+ null,
+ {
+ "position": 665,
+ "nodeLength": 39,
+ "src": "opt.queue == null || opt.queue === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 665,
+ "nodeLength": 17,
+ "src": "opt.queue == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 686,
+ "nodeLength": 18,
+ "src": "opt.queue === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7196": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 28,
+ "src": "jQuery.isFunction(opt.old)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7200": [
+ null,
+ {
+ "position": 78,
+ "nodeLength": 9,
+ "src": "opt.queue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7226": [
+ null,
+ {
+ "position": 210,
+ "nodeLength": 39,
+ "src": "empty || dataPriv.get(this, \"finish\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7232": [
+ null,
+ {
+ "position": 464,
+ "nodeLength": 31,
+ "src": "empty || optall.queue === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 473,
+ "nodeLength": 22,
+ "src": "optall.queue === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7243": [
+ null,
+ {
+ "position": 120,
+ "nodeLength": 24,
+ "src": "typeof type !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7248": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 28,
+ "src": "clearQueue && type !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 242,
+ "nodeLength": 14,
+ "src": "type !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7249": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7254": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 35,
+ "src": "type != null && type + \"queueHooks\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31,
+ "nodeLength": 12,
+ "src": "type != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7258": [
+ null,
+ {
+ "position": 143,
+ "nodeLength": 5,
+ "src": "index",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7259": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 35,
+ "src": "data[index] && data[index].stop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7264": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 57,
+ "src": "data[index] && data[index].stop && rrun.test(index)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28,
+ "nodeLength": 40,
+ "src": "data[index].stop && rrun.test(index)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7270": [
+ null,
+ {
+ "position": 438,
+ "nodeLength": 7,
+ "src": "index--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7271": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 88,
+ "src": "timers[index].elem === this && (type == null || timers[index].queue === type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 29,
+ "src": "timers[index].elem === this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7272": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 46,
+ "src": "type == null || timers[index].queue === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39,
+ "nodeLength": 12,
+ "src": "type == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 30,
+ "src": "timers[index].queue === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7283": [
+ null,
+ {
+ "position": 852,
+ "nodeLength": 19,
+ "src": "dequeue || !gotoEnd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7289": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 14,
+ "src": "type !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7290": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7298": [
+ null,
+ {
+ "position": 161,
+ "nodeLength": 5,
+ "src": "queue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7306": [
+ null,
+ {
+ "position": 333,
+ "nodeLength": 19,
+ "src": "hooks && hooks.stop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7311": [
+ null,
+ {
+ "position": 484,
+ "nodeLength": 7,
+ "src": "index--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7312": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 63,
+ "src": "timers[index].elem === this && timers[index].queue === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 29,
+ "src": "timers[index].elem === this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43,
+ "nodeLength": 30,
+ "src": "timers[index].queue === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7319": [
+ null,
+ {
+ "position": 741,
+ "nodeLength": 14,
+ "src": "index < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7320": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 39,
+ "src": "queue[index] && queue[index].finish",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7334": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 43,
+ "src": "speed == null || typeof speed === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 10,
+ "nodeLength": 13,
+ "src": "speed == null",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 27,
+ "nodeLength": 26,
+ "src": "typeof speed === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7362": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 17,
+ "src": "i < timers.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7366": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 33,
+ "src": "!timer() && timers[i] === timer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95,
+ "nodeLength": 21,
+ "src": "timers[i] === timer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7371": [
+ null,
+ {
+ "position": 271,
+ "nodeLength": 14,
+ "src": "!timers.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7379": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 7,
+ "src": "timer()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7388": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 8,
+ "src": "!timerId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7389": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 28,
+ "src": "window.requestAnimationFrame",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7396": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 27,
+ "src": "window.cancelAnimationFrame",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7417": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 9,
+ "src": "jQuery.fx",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 32,
+ "src": "jQuery.fx.speeds[time] || time",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7418": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 12,
+ "src": "type || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7438": [
+ null,
+ {
+ "position": 289,
+ "nodeLength": 18,
+ "src": "input.value !== \"\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7449": [
+ null,
+ {
+ "position": 622,
+ "nodeLength": 19,
+ "src": "input.value === \"t\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "7458": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 20,
+ "src": "arguments.length > 1",
+ "evalFalse": 48,
+ "evalTrue": 46
+ }
+ ],
+ "7474": [
+ null,
+ {
+ "position": 120,
+ "nodeLength": 41,
+ "src": "nType === 3 || nType === 8 || nType === 2",
+ "evalFalse": 170,
+ "evalTrue": 0
+ },
+ {
+ "position": 120,
+ "nodeLength": 11,
+ "src": "nType === 3",
+ "evalFalse": 170,
+ "evalTrue": 0
+ },
+ {
+ "position": 135,
+ "nodeLength": 26,
+ "src": "nType === 8 || nType === 2",
+ "evalFalse": 170,
+ "evalTrue": 0
+ },
+ {
+ "position": 135,
+ "nodeLength": 11,
+ "src": "nType === 8",
+ "evalFalse": 170,
+ "evalTrue": 0
+ },
+ {
+ "position": 150,
+ "nodeLength": 11,
+ "src": "nType === 2",
+ "evalFalse": 170,
+ "evalTrue": 0
+ }
+ ],
+ "7479": [
+ null,
+ {
+ "position": 245,
+ "nodeLength": 40,
+ "src": "typeof elem.getAttribute === \"undefined\"",
+ "evalFalse": 170,
+ "evalTrue": 0
+ }
+ ],
+ "7485": [
+ null,
+ {
+ "position": 450,
+ "nodeLength": 39,
+ "src": "nType !== 1 || !jQuery.isXMLDoc(elem)",
+ "evalFalse": 0,
+ "evalTrue": 170
+ },
+ {
+ "position": 450,
+ "nodeLength": 11,
+ "src": "nType !== 1",
+ "evalFalse": 170,
+ "evalTrue": 0
+ }
+ ],
+ "7486": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 108,
+ "src": "jQuery.attrHooks[name.toLowerCase()] || (jQuery.expr.match.bool.test(name) ? boolHook : undefined)",
+ "evalFalse": 169,
+ "evalTrue": 1
+ }
+ ],
+ "7487": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 35,
+ "src": "jQuery.expr.match.bool.test(name)",
+ "evalFalse": 169,
+ "evalTrue": 0
+ }
+ ],
+ "7490": [
+ null,
+ {
+ "position": 627,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 18,
+ "evalTrue": 152
+ }
+ ],
+ "7491": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 14,
+ "src": "value === null",
+ "evalFalse": 152,
+ "evalTrue": 0
+ }
+ ],
+ "7496": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 85,
+ "src": "hooks && \"set\" in hooks && (ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 152,
+ "evalTrue": 0
+ },
+ {
+ "position": 100,
+ "nodeLength": 76,
+ "src": "\"set\" in hooks && (ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7497": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 52,
+ "src": "(ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7505": [
+ null,
+ {
+ "position": 924,
+ "nodeLength": 69,
+ "src": "hooks && \"get\" in hooks && (ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 18,
+ "evalTrue": 0
+ },
+ {
+ "position": 933,
+ "nodeLength": 60,
+ "src": "\"get\" in hooks && (ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 953,
+ "nodeLength": 40,
+ "src": "(ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7512": [
+ null,
+ {
+ "position": 1136,
+ "nodeLength": 11,
+ "src": "ret == null",
+ "evalFalse": 13,
+ "evalTrue": 5
+ }
+ ],
+ "7518": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 81,
+ "src": "!support.radioValue && value === \"radio\" && jQuery.nodeName(elem, \"input\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 58,
+ "src": "value === \"radio\" && jQuery.nodeName(elem, \"input\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 17,
+ "src": "value === \"radio\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7522": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 3,
+ "src": "val",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7537": [
+ null,
+ {
+ "position": 170,
+ "nodeLength": 37,
+ "src": "value && value.match(rnothtmlwhite)",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "7539": [
+ null,
+ {
+ "position": 221,
+ "nodeLength": 32,
+ "src": "attrNames && elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 16
+ },
+ {
+ "position": 234,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "7540": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 25,
+ "src": "(name = attrNames[i++])",
+ "evalFalse": 16,
+ "evalTrue": 16
+ }
+ ],
+ "7550": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 15,
+ "src": "value === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7562": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 38,
+ "src": "attrHandle[name] || jQuery.find.attr",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "7568": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 6,
+ "src": "!isXML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7573": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 35,
+ "src": "getter(elem, name, isXML) != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7590": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 20,
+ "src": "arguments.length > 1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7595": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 30,
+ "src": "jQuery.propFix[name] || name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7606": [
+ null,
+ {
+ "position": 120,
+ "nodeLength": 41,
+ "src": "nType === 3 || nType === 8 || nType === 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 120,
+ "nodeLength": 11,
+ "src": "nType === 3",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 135,
+ "nodeLength": 26,
+ "src": "nType === 8 || nType === 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 135,
+ "nodeLength": 11,
+ "src": "nType === 8",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 150,
+ "nodeLength": 11,
+ "src": "nType === 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7610": [
+ null,
+ {
+ "position": 189,
+ "nodeLength": 39,
+ "src": "nType !== 1 || !jQuery.isXMLDoc(elem)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 189,
+ "nodeLength": 11,
+ "src": "nType !== 1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7613": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 30,
+ "src": "jQuery.propFix[name] || name",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "7617": [
+ null,
+ {
+ "position": 357,
+ "nodeLength": 19,
+ "src": "value !== undefined",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7618": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 85,
+ "src": "hooks && \"set\" in hooks && (ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 76,
+ "src": "\"set\" in hooks && (ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7619": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 52,
+ "src": "(ret = hooks.set(elem, value, name)) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7626": [
+ null,
+ {
+ "position": 549,
+ "nodeLength": 69,
+ "src": "hooks && \"get\" in hooks && (ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 558,
+ "nodeLength": 60,
+ "src": "\"get\" in hooks && (ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 578,
+ "nodeLength": 40,
+ "src": "(ret = hooks.get(elem, name)) !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7644": [
+ null,
+ {
+ "position": 403,
+ "nodeLength": 8,
+ "src": "tabindex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7649": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 91,
+ "src": "rfocusable.test(elem.nodeName) || rclickable.test(elem.nodeName) && elem.href",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7650": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 50,
+ "src": "rclickable.test(elem.nodeName) && elem.href",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7675": [
+ null,
+ {
+ "position": 201023,
+ "nodeLength": 20,
+ "src": "!support.optSelected",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "7682": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 27,
+ "src": "parent && parent.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7692": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 6,
+ "src": "parent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7695": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 17,
+ "src": "parent.parentNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7724": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 34,
+ "src": "value.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 179
+ }
+ ],
+ "7730": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 55,
+ "src": "elem.getAttribute && elem.getAttribute(\"class\") || \"\"",
+ "evalFalse": 16,
+ "evalTrue": 75
+ },
+ {
+ "position": 9,
+ "nodeLength": 49,
+ "src": "elem.getAttribute && elem.getAttribute(\"class\")",
+ "evalFalse": 16,
+ "evalTrue": 75
+ }
+ ],
+ "7738": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 26,
+ "src": "jQuery.isFunction(value)",
+ "evalFalse": 41,
+ "evalTrue": 0
+ }
+ ],
+ "7744": [
+ null,
+ {
+ "position": 237,
+ "nodeLength": 34,
+ "src": "typeof value === \"string\" && value",
+ "evalFalse": 0,
+ "evalTrue": 41
+ },
+ {
+ "position": 237,
+ "nodeLength": 25,
+ "src": "typeof value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 41
+ }
+ ],
+ "7745": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 34,
+ "src": "value.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 41
+ }
+ ],
+ "7747": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 20,
+ "src": "(elem = this[i++])",
+ "evalFalse": 41,
+ "evalTrue": 46
+ }
+ ],
+ "7749": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 67,
+ "src": "elem.nodeType === 1 && (\" \" + stripAndCollapse(curValue) + \" \")",
+ "evalFalse": 0,
+ "evalTrue": 46
+ },
+ {
+ "position": 44,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 46
+ }
+ ],
+ "7751": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 3,
+ "src": "cur",
+ "evalFalse": 0,
+ "evalTrue": 46
+ }
+ ],
+ "7753": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 24,
+ "src": "(clazz = classes[j++])",
+ "evalFalse": 46,
+ "evalTrue": 46
+ }
+ ],
+ "7754": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 36,
+ "src": "cur.indexOf(\" \" + clazz + \" \") < 0",
+ "evalFalse": 0,
+ "evalTrue": 46
+ }
+ ],
+ "7761": [
+ null,
+ {
+ "position": 267,
+ "nodeLength": 23,
+ "src": "curValue !== finalValue",
+ "evalFalse": 0,
+ "evalTrue": 46
+ }
+ ],
+ "7775": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 26,
+ "src": "jQuery.isFunction(value)",
+ "evalFalse": 30,
+ "evalTrue": 0
+ }
+ ],
+ "7781": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 17,
+ "src": "!arguments.length",
+ "evalFalse": 30,
+ "evalTrue": 0
+ }
+ ],
+ "7785": [
+ null,
+ {
+ "position": 310,
+ "nodeLength": 34,
+ "src": "typeof value === \"string\" && value",
+ "evalFalse": 0,
+ "evalTrue": 30
+ },
+ {
+ "position": 310,
+ "nodeLength": 25,
+ "src": "typeof value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 30
+ }
+ ],
+ "7786": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 34,
+ "src": "value.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 30
+ }
+ ],
+ "7788": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 20,
+ "src": "(elem = this[i++])",
+ "evalFalse": 30,
+ "evalTrue": 42
+ }
+ ],
+ "7792": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 67,
+ "src": "elem.nodeType === 1 && (\" \" + stripAndCollapse(curValue) + \" \")",
+ "evalFalse": 0,
+ "evalTrue": 42
+ },
+ {
+ "position": 118,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 42
+ }
+ ],
+ "7794": [
+ null,
+ {
+ "position": 197,
+ "nodeLength": 3,
+ "src": "cur",
+ "evalFalse": 0,
+ "evalTrue": 42
+ }
+ ],
+ "7796": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 24,
+ "src": "(clazz = classes[j++])",
+ "evalFalse": 42,
+ "evalTrue": 121
+ }
+ ],
+ "7799": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 37,
+ "src": "cur.indexOf(\" \" + clazz + \" \") > -1",
+ "evalFalse": 121,
+ "evalTrue": 34
+ }
+ ],
+ "7806": [
+ null,
+ {
+ "position": 329,
+ "nodeLength": 23,
+ "src": "curValue !== finalValue",
+ "evalFalse": 19,
+ "evalTrue": 23
+ }
+ ],
+ "7819": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 50,
+ "src": "typeof stateVal === \"boolean\" && type === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 29,
+ "src": "typeof stateVal === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 17,
+ "src": "type === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7820": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 8,
+ "src": "stateVal",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7823": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 26,
+ "src": "jQuery.isFunction(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7835": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 17,
+ "src": "type === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7840": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 34,
+ "src": "value.match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7842": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 31,
+ "src": "(className = classNames[i++])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7845": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 26,
+ "src": "self.hasClass(className)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7853": [
+ null,
+ {
+ "position": 495,
+ "nodeLength": 41,
+ "src": "value === undefined || type === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 495,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 518,
+ "nodeLength": 18,
+ "src": "type === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7855": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 9,
+ "src": "className",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7865": [
+ null,
+ {
+ "position": 443,
+ "nodeLength": 17,
+ "src": "this.setAttribute",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7867": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 28,
+ "src": "className || value === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46,
+ "nodeLength": 15,
+ "src": "value === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7869": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 43,
+ "src": "dataPriv.get(this, \"__className__\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7881": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 20,
+ "src": "(elem = this[i++])",
+ "evalFalse": 3,
+ "evalTrue": 3
+ }
+ ],
+ "7882": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 105,
+ "src": "elem.nodeType === 1 && (\" \" + stripAndCollapse(getClass(elem)) + \" \").indexOf(className) > -1",
+ "evalFalse": 3,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "7883": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 76,
+ "src": "(\" \" + stripAndCollapse(getClass(elem)) + \" \").indexOf(className) > -1",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "7902": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 17,
+ "src": "!arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7903": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7904": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 83,
+ "src": "jQuery.valHooks[elem.type] || jQuery.valHooks[elem.nodeName.toLowerCase()]",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7907": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 87,
+ "src": "hooks && \"get\" in hooks && (ret = hooks.get(elem, \"value\")) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7908": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 73,
+ "src": "\"get\" in hooks && (ret = hooks.get(elem, \"value\")) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7909": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 48,
+ "src": "(ret = hooks.get(elem, \"value\")) !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "7917": [
+ null,
+ {
+ "position": 299,
+ "nodeLength": 23,
+ "src": "typeof ret === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7922": [
+ null,
+ {
+ "position": 441,
+ "nodeLength": 11,
+ "src": "ret == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7933": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 19,
+ "src": "this.nodeType !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7937": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 10,
+ "src": "isFunction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7944": [
+ null,
+ {
+ "position": 245,
+ "nodeLength": 11,
+ "src": "val == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7947": [
+ null,
+ {
+ "position": 292,
+ "nodeLength": 23,
+ "src": "typeof val === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7950": [
+ null,
+ {
+ "position": 352,
+ "nodeLength": 21,
+ "src": "jQuery.isArray(val)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7952": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 13,
+ "src": "value == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7956": [
+ null,
+ {
+ "position": 494,
+ "nodeLength": 78,
+ "src": "jQuery.valHooks[this.type] || jQuery.valHooks[this.nodeName.toLowerCase()]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7959": [
+ null,
+ {
+ "position": 643,
+ "nodeLength": 78,
+ "src": "!hooks || !(\"set\" in hooks) || hooks.set(this, val, \"value\") === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 653,
+ "nodeLength": 68,
+ "src": "!(\"set\" in hooks) || hooks.set(this, val, \"value\") === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 676,
+ "nodeLength": 45,
+ "src": "hooks.set(this, val, \"value\") === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7972": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 11,
+ "src": "val != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7987": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 26,
+ "src": "elem.type === \"select-one\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7988": [
+ null,
+ {
+ "position": 136,
+ "nodeLength": 3,
+ "src": "one",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7989": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 3,
+ "src": "one",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7991": [
+ null,
+ {
+ "position": 214,
+ "nodeLength": 9,
+ "src": "index < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7995": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 3,
+ "src": "one",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "7999": [
+ null,
+ {
+ "position": 346,
+ "nodeLength": 7,
+ "src": "i < max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8004": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 238,
+ "src": "(option.selected || i === index) && !option.disabled && (!option.parentNode.disabled || !jQuery.nodeName(option.parentNode, \"optgroup\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 134,
+ "nodeLength": 30,
+ "src": "option.selected || i === index",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 153,
+ "nodeLength": 11,
+ "src": "i === index",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8007": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 119,
+ "src": "!option.disabled && (!option.parentNode.disabled || !jQuery.nodeName(option.parentNode, \"optgroup\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8008": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 88,
+ "src": "!option.parentNode.disabled || !jQuery.nodeName(option.parentNode, \"optgroup\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8015": [
+ null,
+ {
+ "position": 147,
+ "nodeLength": 3,
+ "src": "one",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8033": [
+ null,
+ {
+ "position": 136,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8038": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 91,
+ "src": "option.selected = jQuery.inArray(jQuery.valHooks.option.get(option), values) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8039": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 67,
+ "src": "jQuery.inArray(jQuery.valHooks.option.get(option), values) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8048": [
+ null,
+ {
+ "position": 490,
+ "nodeLength": 10,
+ "src": "!optionSet",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8061": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 23,
+ "src": "jQuery.isArray(value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8062": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 50,
+ "src": "jQuery.inArray(jQuery(elem).val(), value) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8066": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 16,
+ "src": "!support.checkOn",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "8068": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 37,
+ "src": "elem.getAttribute(\"value\") === null",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "8086": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 16,
+ "src": "elem || document",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8087": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 28,
+ "src": "hasOwn.call(event, \"type\")",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8088": [
+ null,
+ {
+ "position": 167,
+ "nodeLength": 33,
+ "src": "hasOwn.call(event, \"namespace\")",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8090": [
+ null,
+ {
+ "position": 265,
+ "nodeLength": 16,
+ "src": "elem || document",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8093": [
+ null,
+ {
+ "position": 338,
+ "nodeLength": 42,
+ "src": "elem.nodeType === 3 || elem.nodeType === 8",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 338,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 3",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 361,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 8",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8098": [
+ null,
+ {
+ "position": 486,
+ "nodeLength": 49,
+ "src": "rfocusMorph.test(type + jQuery.event.triggered)",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8102": [
+ null,
+ {
+ "position": 563,
+ "nodeLength": 24,
+ "src": "type.indexOf(\".\") > -1",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8109": [
+ null,
+ {
+ "position": 769,
+ "nodeLength": 38,
+ "src": "type.indexOf(\":\") < 0 && \"on\" + type",
+ "evalFalse": 0,
+ "evalTrue": 7
+ },
+ {
+ "position": 769,
+ "nodeLength": 23,
+ "src": "type.indexOf(\":\") < 0",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8112": [
+ null,
+ {
+ "position": 904,
+ "nodeLength": 23,
+ "src": "event[jQuery.expando]",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8114": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 34,
+ "src": "typeof event === \"object\" && event",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 64,
+ "nodeLength": 25,
+ "src": "typeof event === \"object\"",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8117": [
+ null,
+ {
+ "position": 1103,
+ "nodeLength": 12,
+ "src": "onlyHandlers",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8119": [
+ null,
+ {
+ "position": 1190,
+ "nodeLength": 15,
+ "src": "event.namespace",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8125": [
+ null,
+ {
+ "position": 1382,
+ "nodeLength": 13,
+ "src": "!event.target",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8130": [
+ null,
+ {
+ "position": 1520,
+ "nodeLength": 12,
+ "src": "data == null",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8135": [
+ null,
+ {
+ "position": 1655,
+ "nodeLength": 34,
+ "src": "jQuery.event.special[type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8136": [
+ null,
+ {
+ "position": 1698,
+ "nodeLength": 81,
+ "src": "!onlyHandlers && special.trigger && special.trigger.apply(elem, data) === false",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 1715,
+ "nodeLength": 64,
+ "src": "special.trigger && special.trigger.apply(elem, data) === false",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 1734,
+ "nodeLength": 45,
+ "src": "special.trigger.apply(elem, data) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8142": [
+ null,
+ {
+ "position": 1974,
+ "nodeLength": 62,
+ "src": "!onlyHandlers && !special.noBubble && !jQuery.isWindow(elem)",
+ "evalFalse": 0,
+ "evalTrue": 7
+ },
+ {
+ "position": 1991,
+ "nodeLength": 45,
+ "src": "!special.noBubble && !jQuery.isWindow(elem)",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8144": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 28,
+ "src": "special.delegateType || type",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8145": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 38,
+ "src": "!rfocusMorph.test(bubbleType + type)",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8148": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 3,
+ "src": "cur",
+ "evalFalse": 7,
+ "evalTrue": 28
+ }
+ ],
+ "8154": [
+ null,
+ {
+ "position": 309,
+ "nodeLength": 42,
+ "src": "tmp === (elem.ownerDocument || document)",
+ "evalFalse": 0,
+ "evalTrue": 7
+ },
+ {
+ "position": 319,
+ "nodeLength": 30,
+ "src": "elem.ownerDocument || document",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8155": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 45,
+ "src": "tmp.defaultView || tmp.parentWindow || window",
+ "evalFalse": 0,
+ "evalTrue": 7
+ },
+ {
+ "position": 35,
+ "nodeLength": 26,
+ "src": "tmp.parentWindow || window",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8161": [
+ null,
+ {
+ "position": 2533,
+ "nodeLength": 57,
+ "src": "(cur = eventPath[i++]) && !event.isPropagationStopped()",
+ "evalFalse": 7,
+ "evalTrue": 42
+ }
+ ],
+ "8163": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 5,
+ "src": "i > 1",
+ "evalFalse": 7,
+ "evalTrue": 35
+ }
+ ],
+ "8165": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 24,
+ "src": "special.bindType || type",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8168": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 88,
+ "src": "(dataPriv.get(cur, \"events\") || {})[event.type] && dataPriv.get(cur, \"handle\")",
+ "evalFalse": 42,
+ "evalTrue": 0
+ },
+ {
+ "position": 109,
+ "nodeLength": 35,
+ "src": "dataPriv.get(cur, \"events\") || {}",
+ "evalFalse": 0,
+ "evalTrue": 42
+ }
+ ],
+ "8170": [
+ null,
+ {
+ "position": 207,
+ "nodeLength": 6,
+ "src": "handle",
+ "evalFalse": 42,
+ "evalTrue": 0
+ }
+ ],
+ "8175": [
+ null,
+ {
+ "position": 288,
+ "nodeLength": 23,
+ "src": "ontype && cur[ontype]",
+ "evalFalse": 42,
+ "evalTrue": 0
+ }
+ ],
+ "8176": [
+ null,
+ {
+ "position": 321,
+ "nodeLength": 43,
+ "src": "handle && handle.apply && acceptData(cur)",
+ "evalFalse": 42,
+ "evalTrue": 0
+ },
+ {
+ "position": 331,
+ "nodeLength": 33,
+ "src": "handle.apply && acceptData(cur)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8178": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 22,
+ "src": "event.result === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8186": [
+ null,
+ {
+ "position": 3173,
+ "nodeLength": 44,
+ "src": "!onlyHandlers && !event.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8188": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 110,
+ "src": "(!special._default || special._default.apply(eventPath.pop(), data) === false) && acceptData(elem)",
+ "evalFalse": 0,
+ "evalTrue": 7
+ },
+ {
+ "position": 12,
+ "nodeLength": 82,
+ "src": "!special._default || special._default.apply(eventPath.pop(), data) === false",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "8189": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 57,
+ "src": "special._default.apply(eventPath.pop(), data) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8194": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 71,
+ "src": "ontype && jQuery.isFunction(elem[type]) && !jQuery.isWindow(elem)",
+ "evalFalse": 7,
+ "evalTrue": 0
+ },
+ {
+ "position": 184,
+ "nodeLength": 61,
+ "src": "jQuery.isFunction(elem[type]) && !jQuery.isWindow(elem)",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "8199": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 3,
+ "src": "tmp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8208": [
+ null,
+ {
+ "position": 347,
+ "nodeLength": 3,
+ "src": "tmp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8244": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 4,
+ "src": "elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8258": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 20,
+ "src": "arguments.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "8266": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 15,
+ "src": "fnOut || fnOver",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8284": [
+ null,
+ {
+ "position": 216002,
+ "nodeLength": 16,
+ "src": "!support.focusin",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "8294": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 26,
+ "src": "this.ownerDocument || this",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "8297": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 9,
+ "src": "!attaches",
+ "evalFalse": 4,
+ "evalTrue": 2
+ }
+ ],
+ "8300": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 13,
+ "src": "attaches || 0",
+ "evalFalse": 2,
+ "evalTrue": 4
+ }
+ ],
+ "8303": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 26,
+ "src": "this.ownerDocument || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8306": [
+ null,
+ {
+ "position": 102,
+ "nodeLength": 9,
+ "src": "!attaches",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8328": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 33,
+ "src": "!data || typeof data !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26,
+ "nodeLength": 24,
+ "src": "typeof data !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8340": [
+ null,
+ {
+ "position": 280,
+ "nodeLength": 56,
+ "src": "!xml || xml.getElementsByTagName(\"parsererror\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8356": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 21,
+ "src": "jQuery.isArray(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8360": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 38,
+ "src": "traditional || rbracket.test(prefix)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8369": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 34,
+ "src": "typeof v === \"object\" && v != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 21,
+ "src": "typeof v === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59,
+ "nodeLength": 9,
+ "src": "v != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8377": [
+ null,
+ {
+ "position": 474,
+ "nodeLength": 47,
+ "src": "!traditional && jQuery.type(obj) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 490,
+ "nodeLength": 31,
+ "src": "jQuery.type(obj) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8399": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 36,
+ "src": "jQuery.isFunction(valueOrFunction)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8404": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 13,
+ "src": "value == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8408": [
+ null,
+ {
+ "position": 428,
+ "nodeLength": 65,
+ "src": "jQuery.isArray(a) || (a.jquery && !jQuery.isPlainObject(a))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 453,
+ "nodeLength": 38,
+ "src": "a.jquery && !jQuery.isPlainObject(a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8437": [
+ null,
+ {
+ "position": 132,
+ "nodeLength": 8,
+ "src": "elements",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8443": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 177,
+ "src": "this.name && !jQuery(this).is(\":disabled\") && rsubmittable.test(this.nodeName) && !rsubmitterTypes.test(type) && (this.checked || !rcheckableType.test(type))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 112,
+ "nodeLength": 164,
+ "src": "!jQuery(this).is(\":disabled\") && rsubmittable.test(this.nodeName) && !rsubmitterTypes.test(type) && (this.checked || !rcheckableType.test(type))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8444": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 123,
+ "src": "rsubmittable.test(this.nodeName) && !rsubmitterTypes.test(type) && (this.checked || !rcheckableType.test(type))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 194,
+ "nodeLength": 85,
+ "src": "!rsubmitterTypes.test(type) && (this.checked || !rcheckableType.test(type))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8445": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 44,
+ "src": "this.checked || !rcheckableType.test(type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8450": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 11,
+ "src": "val == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8454": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 21,
+ "src": "jQuery.isArray(val)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8508": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 38,
+ "src": "typeof dataTypeExpression !== \"string\"",
+ "evalFalse": 3,
+ "evalTrue": 2
+ }
+ ],
+ "8515": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 61,
+ "src": "dataTypeExpression.toLowerCase().match(rnothtmlwhite) || []",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "8517": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(func)",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "8520": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 29,
+ "src": "(dataType = dataTypes[i++])",
+ "evalFalse": 5,
+ "evalTrue": 6
+ }
+ ],
+ "8523": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 21,
+ "src": "dataType[0] === \"+\"",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "8524": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 26,
+ "src": "dataType.slice(1) || \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8525": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 27,
+ "src": "structure[dataType] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8529": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 27,
+ "src": "structure[dataType] || []",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "8540": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 24,
+ "src": "structure === transports",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8545": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 27,
+ "src": "structure[dataType] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8547": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 101,
+ "src": "typeof dataTypeOrTransport === \"string\" && !seekingTransport && !inspected[dataTypeOrTransport]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 39,
+ "src": "typeof dataTypeOrTransport === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8548": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 54,
+ "src": "!seekingTransport && !inspected[dataTypeOrTransport]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8553": [
+ null,
+ {
+ "position": 324,
+ "nodeLength": 16,
+ "src": "seekingTransport",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8560": [
+ null,
+ {
+ "position": 669,
+ "nodeLength": 72,
+ "src": "inspect(options.dataTypes[0]) || !inspected[\"*\"] && inspect(\"*\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 706,
+ "nodeLength": 35,
+ "src": "!inspected[\"*\"] && inspect(\"*\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8568": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 37,
+ "src": "jQuery.ajaxSettings.flatOptions || {}",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "8571": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 24,
+ "src": "src[key] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "8572": [
+ null,
+ {
+ "position": 6,
+ "nodeLength": 18,
+ "src": "flatOptions[key]",
+ "evalFalse": 5,
+ "evalTrue": 0
+ },
+ {
+ "position": 38,
+ "nodeLength": 21,
+ "src": "deep || (deep = {})",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "8575": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 4,
+ "src": "deep",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "8593": [
+ null,
+ {
+ "position": 170,
+ "nodeLength": 22,
+ "src": "dataTypes[0] === \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8595": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 16,
+ "src": "ct === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8596": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 55,
+ "src": "s.mimeType || jqXHR.getResponseHeader(\"Content-Type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8601": [
+ null,
+ {
+ "position": 378,
+ "nodeLength": 2,
+ "src": "ct",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8603": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 47,
+ "src": "contents[type] && contents[type].test(ct)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8611": [
+ null,
+ {
+ "position": 600,
+ "nodeLength": 27,
+ "src": "dataTypes[0] in responses",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8617": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 62,
+ "src": "!dataTypes[0] || s.converters[type + \" \" + dataTypes[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8621": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 14,
+ "src": "!firstDataType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8627": [
+ null,
+ {
+ "position": 288,
+ "nodeLength": 30,
+ "src": "finalDataType || firstDataType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8633": [
+ null,
+ {
+ "position": 1120,
+ "nodeLength": 13,
+ "src": "finalDataType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8634": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 32,
+ "src": "finalDataType !== dataTypes[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8652": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 14,
+ "src": "dataTypes[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8661": [
+ null,
+ {
+ "position": 427,
+ "nodeLength": 7,
+ "src": "current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8663": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 27,
+ "src": "s.responseFields[current]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8668": [
+ null,
+ {
+ "position": 143,
+ "nodeLength": 34,
+ "src": "!prev && isSuccess && s.dataFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 152,
+ "nodeLength": 25,
+ "src": "isSuccess && s.dataFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8675": [
+ null,
+ {
+ "position": 296,
+ "nodeLength": 7,
+ "src": "current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8678": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 15,
+ "src": "current === \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8683": [
+ null,
+ {
+ "position": 207,
+ "nodeLength": 32,
+ "src": "prev !== \"*\" && prev !== current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 207,
+ "nodeLength": 12,
+ "src": "prev !== \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 223,
+ "nodeLength": 16,
+ "src": "prev !== current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8686": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 66,
+ "src": "converters[prev + \" \" + current] || converters[\"* \" + current]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8689": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 5,
+ "src": "!conv",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8694": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 20,
+ "src": "tmp[1] === current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8697": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 76,
+ "src": "converters[prev + \" \" + tmp[0]] || converters[\"* \" + tmp[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8699": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 4,
+ "src": "conv",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8702": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 13,
+ "src": "conv === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8706": [
+ null,
+ {
+ "position": 190,
+ "nodeLength": 28,
+ "src": "converters[conv2] !== true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8717": [
+ null,
+ {
+ "position": 873,
+ "nodeLength": 13,
+ "src": "conv !== true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8720": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 16,
+ "src": "conv && s.throws",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8728": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 4,
+ "src": "conv",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8821": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "settings",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "8837": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 23,
+ "src": "typeof url === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8843": [
+ null,
+ {
+ "position": 180,
+ "nodeLength": 13,
+ "src": "options || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8876": [
+ null,
+ {
+ "position": 561,
+ "nodeLength": 14,
+ "src": "s.context || s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8879": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 71,
+ "src": "s.context && (callbackContext.nodeType || callbackContext.jquery)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8880": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 50,
+ "src": "callbackContext.nodeType || callbackContext.jquery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8889": [
+ null,
+ {
+ "position": 977,
+ "nodeLength": 18,
+ "src": "s.statusCode || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8905": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8906": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 16,
+ "src": "!responseHeaders",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8908": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 48,
+ "src": "(match = rheaders.exec(responseHeadersString))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8914": [
+ null,
+ {
+ "position": 326,
+ "nodeLength": 13,
+ "src": "match == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8919": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8924": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 17,
+ "src": "completed == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8926": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 49,
+ "src": "requestHeadersNames[name.toLowerCase()] || name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8934": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 17,
+ "src": "completed == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8943": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 3,
+ "src": "map",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8944": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8961": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 22,
+ "src": "statusText || strAbort",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8962": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 9,
+ "src": "transport",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8976": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 29,
+ "src": "url || s.url || location.href",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6,
+ "nodeLength": 22,
+ "src": "s.url || location.href",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8980": [
+ null,
+ {
+ "position": 3528,
+ "nodeLength": 52,
+ "src": "options.method || options.type || s.method || s.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3546,
+ "nodeLength": 34,
+ "src": "options.type || s.method || s.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3562,
+ "nodeLength": 18,
+ "src": "s.method || s.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8983": [
+ null,
+ {
+ "position": 3629,
+ "nodeLength": 66,
+ "src": "(s.dataType || \"*\").toLowerCase().match(rnothtmlwhite) || [\"\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3629,
+ "nodeLength": 17,
+ "src": "s.dataType || \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8986": [
+ null,
+ {
+ "position": 3795,
+ "nodeLength": 21,
+ "src": "s.crossDomain == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8998": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 100,
+ "src": "originAnchor.protocol + \"//\" + originAnchor.host !== urlAnchor.protocol + \"//\" + urlAnchor.host",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9009": [
+ null,
+ {
+ "position": 4561,
+ "nodeLength": 53,
+ "src": "s.data && s.processData && typeof s.data !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4571,
+ "nodeLength": 43,
+ "src": "s.processData && typeof s.data !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4588,
+ "nodeLength": 26,
+ "src": "typeof s.data !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9017": [
+ null,
+ {
+ "position": 4830,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9023": [
+ null,
+ {
+ "position": 5021,
+ "nodeLength": 24,
+ "src": "jQuery.event && s.global",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9026": [
+ null,
+ {
+ "position": 5092,
+ "nodeLength": 36,
+ "src": "fireGlobals && jQuery.active++ === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5107,
+ "nodeLength": 21,
+ "src": "jQuery.active++ === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9042": [
+ null,
+ {
+ "position": 5579,
+ "nodeLength": 13,
+ "src": "!s.hasContent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9048": [
+ null,
+ {
+ "position": 150,
+ "nodeLength": 6,
+ "src": "s.data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9049": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 23,
+ "src": "rquery.test(cacheURL)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9056": [
+ null,
+ {
+ "position": 377,
+ "nodeLength": 17,
+ "src": "s.cache === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9058": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 23,
+ "src": "rquery.test(cacheURL)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9065": [
+ null,
+ {
+ "position": 6336,
+ "nodeLength": 106,
+ "src": "s.data && s.processData && (s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6346,
+ "nodeLength": 96,
+ "src": "s.processData && (s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9066": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 74,
+ "src": "(s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6371,
+ "nodeLength": 19,
+ "src": "s.contentType || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9071": [
+ null,
+ {
+ "position": 6581,
+ "nodeLength": 12,
+ "src": "s.ifModified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9072": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 31,
+ "src": "jQuery.lastModified[cacheURL]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9075": [
+ null,
+ {
+ "position": 142,
+ "nodeLength": 23,
+ "src": "jQuery.etag[cacheURL]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9081": [
+ null,
+ {
+ "position": 6907,
+ "nodeLength": 72,
+ "src": "s.data && s.hasContent && s.contentType !== false || options.contentType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6907,
+ "nodeLength": 49,
+ "src": "s.data && s.hasContent && s.contentType !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6917,
+ "nodeLength": 39,
+ "src": "s.hasContent && s.contentType !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6933,
+ "nodeLength": 23,
+ "src": "s.contentType !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9088": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 49,
+ "src": "s.dataTypes[0] && s.accepts[s.dataTypes[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9090": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 24,
+ "src": "s.dataTypes[0] !== \"*\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9100": [
+ null,
+ {
+ "position": 7517,
+ "nodeLength": 92,
+ "src": "s.beforeSend && (s.beforeSend.call(callbackContext, jqXHR, s) === false || completed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9101": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 69,
+ "src": "s.beforeSend.call(callbackContext, jqXHR, s) === false || completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 56,
+ "src": "s.beforeSend.call(callbackContext, jqXHR, s) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9119": [
+ null,
+ {
+ "position": 8021,
+ "nodeLength": 10,
+ "src": "!transport",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9125": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 11,
+ "src": "fireGlobals",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9130": [
+ null,
+ {
+ "position": 206,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9135": [
+ null,
+ {
+ "position": 266,
+ "nodeLength": 24,
+ "src": "s.async && s.timeout > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 277,
+ "nodeLength": 13,
+ "src": "s.timeout > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9147": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9162": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 9,
+ "src": "completed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9169": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 12,
+ "src": "timeoutTimer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9178": [
+ null,
+ {
+ "position": 487,
+ "nodeLength": 13,
+ "src": "headers || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9181": [
+ null,
+ {
+ "position": 546,
+ "nodeLength": 10,
+ "src": "status > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9184": [
+ null,
+ {
+ "position": 612,
+ "nodeLength": 47,
+ "src": "status >= 200 && status < 300 || status === 304",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 612,
+ "nodeLength": 29,
+ "src": "status >= 200 && status < 300",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 612,
+ "nodeLength": 13,
+ "src": "status >= 200",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 629,
+ "nodeLength": 12,
+ "src": "status < 300",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 645,
+ "nodeLength": 14,
+ "src": "status === 304",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9187": [
+ null,
+ {
+ "position": 694,
+ "nodeLength": 9,
+ "src": "responses",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9195": [
+ null,
+ {
+ "position": 958,
+ "nodeLength": 9,
+ "src": "isSuccess",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9198": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 12,
+ "src": "s.ifModified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9200": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 8,
+ "src": "modified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9204": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 8,
+ "src": "modified",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9210": [
+ null,
+ {
+ "position": 413,
+ "nodeLength": 35,
+ "src": "status === 204 || s.type === \"HEAD\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 413,
+ "nodeLength": 14,
+ "src": "status === 204",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 431,
+ "nodeLength": 17,
+ "src": "s.type === \"HEAD\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9214": [
+ null,
+ {
+ "position": 525,
+ "nodeLength": 14,
+ "src": "status === 304",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9228": [
+ null,
+ {
+ "position": 101,
+ "nodeLength": 21,
+ "src": "status || !statusText",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9230": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 10,
+ "src": "status < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9238": [
+ null,
+ {
+ "position": 2042,
+ "nodeLength": 30,
+ "src": "nativeStatusText || statusText",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9241": [
+ null,
+ {
+ "position": 2110,
+ "nodeLength": 9,
+ "src": "isSuccess",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9251": [
+ null,
+ {
+ "position": 2397,
+ "nodeLength": 11,
+ "src": "fireGlobals",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9252": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 9,
+ "src": "isSuccess",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9253": [
+ null,
+ {
+ "position": 85,
+ "nodeLength": 9,
+ "src": "isSuccess",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9259": [
+ null,
+ {
+ "position": 2638,
+ "nodeLength": 11,
+ "src": "fireGlobals",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9263": [
+ null,
+ {
+ "position": 113,
+ "nodeLength": 20,
+ "src": "!(--jQuery.active)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9285": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(data)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9286": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 16,
+ "src": "type || callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9298": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 34,
+ "src": "jQuery.isPlainObject(url) && url",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9322": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 9,
+ "src": "this[0]",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "9323": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(html)",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "9330": [
+ null,
+ {
+ "position": 207,
+ "nodeLength": 20,
+ "src": "this[0].parentNode",
+ "evalFalse": 0,
+ "evalTrue": 6
+ }
+ ],
+ "9337": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 22,
+ "src": "elem.firstElementChild",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "9349": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 25,
+ "src": "jQuery.isFunction(html)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9359": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 15,
+ "src": "contents.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9372": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 10,
+ "src": "isFunction",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "9389": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 69,
+ "src": "elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 49,
+ "src": "elem.offsetHeight || elem.getClientRects().length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9412": [
+ null,
+ {
+ "position": 244511,
+ "nodeLength": 55,
+ "src": "!!xhrSupported && (\"withCredentials\" in xhrSupported)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "9419": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 52,
+ "src": "support.cors || xhrSupported && !options.crossDomain",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120,
+ "nodeLength": 36,
+ "src": "xhrSupported && !options.crossDomain",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9434": [
+ null,
+ {
+ "position": 211,
+ "nodeLength": 17,
+ "src": "options.xhrFields",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9441": [
+ null,
+ {
+ "position": 371,
+ "nodeLength": 40,
+ "src": "options.mimeType && xhr.overrideMimeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9450": [
+ null,
+ {
+ "position": 803,
+ "nodeLength": 54,
+ "src": "!options.crossDomain && !headers[\"X-Requested-With\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9462": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9466": [
+ null,
+ {
+ "position": 128,
+ "nodeLength": 16,
+ "src": "type === \"abort\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9468": [
+ null,
+ {
+ "position": 190,
+ "nodeLength": 16,
+ "src": "type === \"error\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9473": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 30,
+ "src": "typeof xhr.status !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9485": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 44,
+ "src": "xhrSuccessStatus[xhr.status] || xhr.status",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9491": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 89,
+ "src": "(xhr.responseType || \"text\") !== \"text\" || typeof xhr.responseText !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 263,
+ "nodeLength": 39,
+ "src": "(xhr.responseType || \"text\") !== \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 264,
+ "nodeLength": 26,
+ "src": "xhr.responseType || \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9492": [
+ null,
+ {
+ "position": 315,
+ "nodeLength": 36,
+ "src": "typeof xhr.responseText !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9509": [
+ null,
+ {
+ "position": 2494,
+ "nodeLength": 25,
+ "src": "xhr.onabort !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9515": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 20,
+ "src": "xhr.readyState === 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9522": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9536": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 42,
+ "src": "options.hasContent && options.data || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 34,
+ "src": "options.hasContent && options.data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9540": [
+ null,
+ {
+ "position": 86,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9547": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9560": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 13,
+ "src": "s.crossDomain",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9584": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 21,
+ "src": "s.cache === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9587": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 13,
+ "src": "s.crossDomain",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9596": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 13,
+ "src": "s.crossDomain",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9608": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 3,
+ "src": "evt",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9609": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 20,
+ "src": "evt.type === \"error\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9618": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9636": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 60,
+ "src": "oldCallbacks.pop() || (jQuery.expando + \"_\" + (nonce++))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9646": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 219,
+ "src": "s.jsonp !== false && (rjsonp.test(s.url) ? \"url\" : typeof s.data === \"string\" && (s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0 && rjsonp.test(s.data) && \"data\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62,
+ "nodeLength": 17,
+ "src": "s.jsonp !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85,
+ "nodeLength": 20,
+ "src": "rjsonp.test(s.url)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9648": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 155,
+ "src": "typeof s.data === \"string\" && (s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0 && rjsonp.test(s.data) && \"data\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37,
+ "nodeLength": 26,
+ "src": "typeof s.data === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9649": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 19,
+ "src": "s.contentType || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9650": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 119,
+ "src": "(s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0 && rjsonp.test(s.data) && \"data\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 80,
+ "src": "(s.contentType || \"\").indexOf(\"application/x-www-form-urlencoded\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9651": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 31,
+ "src": "rjsonp.test(s.data) && \"data\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9655": [
+ null,
+ {
+ "position": 373,
+ "nodeLength": 40,
+ "src": "jsonProp || s.dataTypes[0] === \"jsonp\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 385,
+ "nodeLength": 28,
+ "src": "s.dataTypes[0] === \"jsonp\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9658": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 36,
+ "src": "jQuery.isFunction(s.jsonpCallback)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9663": [
+ null,
+ {
+ "position": 243,
+ "nodeLength": 8,
+ "src": "jsonProp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9665": [
+ null,
+ {
+ "position": 344,
+ "nodeLength": 17,
+ "src": "s.jsonp !== false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9666": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 20,
+ "src": "rquery.test(s.url)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9671": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 18,
+ "src": "!responseContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9690": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 25,
+ "src": "overwritten === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9699": [
+ null,
+ {
+ "position": 273,
+ "nodeLength": 17,
+ "src": "s[callbackName]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9709": [
+ null,
+ {
+ "position": 575,
+ "nodeLength": 53,
+ "src": "responseContainer && jQuery.isFunction(overwritten)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9732": [
+ null,
+ {
+ "position": 124,
+ "nodeLength": 28,
+ "src": "body.childNodes.length === 2",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "9741": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 24,
+ "src": "typeof data !== \"string\"",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "9744": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 28,
+ "src": "typeof context === \"boolean\"",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "9751": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 8,
+ "src": "!context",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "9755": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 26,
+ "src": "support.createHTMLDocument",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9770": [
+ null,
+ {
+ "position": 746,
+ "nodeLength": 18,
+ "src": "!keepScripts && []",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "9773": [
+ null,
+ {
+ "position": 788,
+ "nodeLength": 6,
+ "src": "parsed",
+ "evalFalse": 7,
+ "evalTrue": 1
+ }
+ ],
+ "9779": [
+ null,
+ {
+ "position": 916,
+ "nodeLength": 25,
+ "src": "scripts && scripts.length",
+ "evalFalse": 7,
+ "evalTrue": 0
+ }
+ ],
+ "9795": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 8,
+ "src": "off > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9801": [
+ null,
+ {
+ "position": 208,
+ "nodeLength": 27,
+ "src": "jQuery.isFunction(params)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9808": [
+ null,
+ {
+ "position": 373,
+ "nodeLength": 36,
+ "src": "params && typeof params === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 383,
+ "nodeLength": 26,
+ "src": "typeof params === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9813": [
+ null,
+ {
+ "position": 492,
+ "nodeLength": 15,
+ "src": "self.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9820": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 13,
+ "src": "type || \"GET\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9828": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 8,
+ "src": "selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9840": [
+ null,
+ {
+ "position": 896,
+ "nodeLength": 157,
+ "src": "callback && function(jqXHR, status) {\n self.each(function() {\n callback.apply(this, response || [jqXHR.responseText, status, jqXHR]);\n});\n}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9842": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 49,
+ "src": "response || [jqXHR.responseText, status, jqXHR]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9872": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 16,
+ "src": "elem === fn.elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9883": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 23,
+ "src": "jQuery.isWindow(elem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 39,
+ "src": "elem.nodeType === 9 && elem.defaultView",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9894": [
+ null,
+ {
+ "position": 258,
+ "nodeLength": 21,
+ "src": "position === \"static\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9901": [
+ null,
+ {
+ "position": 466,
+ "nodeLength": 105,
+ "src": "(position === \"absolute\" || position === \"fixed\") && (curCSSTop + curCSSLeft).indexOf(\"auto\") > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 466,
+ "nodeLength": 47,
+ "src": "position === \"absolute\" || position === \"fixed\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 466,
+ "nodeLength": 23,
+ "src": "position === \"absolute\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 493,
+ "nodeLength": 20,
+ "src": "position === \"fixed\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9902": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 47,
+ "src": "(curCSSTop + curCSSLeft).indexOf(\"auto\") > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9906": [
+ null,
+ {
+ "position": 700,
+ "nodeLength": 17,
+ "src": "calculatePosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9912": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 28,
+ "src": "parseFloat(curCSSTop) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9913": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 29,
+ "src": "parseFloat(curCSSLeft) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9916": [
+ null,
+ {
+ "position": 929,
+ "nodeLength": 28,
+ "src": "jQuery.isFunction(options)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9922": [
+ null,
+ {
+ "position": 1130,
+ "nodeLength": 19,
+ "src": "options.top != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9925": [
+ null,
+ {
+ "position": 1222,
+ "nodeLength": 20,
+ "src": "options.left != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9929": [
+ null,
+ {
+ "position": 1320,
+ "nodeLength": 18,
+ "src": "\"using\" in options",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9942": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9943": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 21,
+ "src": "options === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9953": [
+ null,
+ {
+ "position": 264,
+ "nodeLength": 5,
+ "src": "!elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9960": [
+ null,
+ {
+ "position": 409,
+ "nodeLength": 29,
+ "src": "!elem.getClientRects().length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9967": [
+ null,
+ {
+ "position": 579,
+ "nodeLength": 25,
+ "src": "rect.width || rect.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9983": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 10,
+ "src": "!this[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9993": [
+ null,
+ {
+ "position": 254,
+ "nodeLength": 42,
+ "src": "jQuery.css(elem, \"position\") === \"fixed\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10005": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 45,
+ "src": "!jQuery.nodeName(offsetParent[0], \"html\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10037": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 67,
+ "src": "offsetParent && jQuery.css(offsetParent, \"position\") === \"static\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70,
+ "nodeLength": 51,
+ "src": "jQuery.css(offsetParent, \"position\") === \"static\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10041": [
+ null,
+ {
+ "position": 188,
+ "nodeLength": 31,
+ "src": "offsetParent || documentElement",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10048": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 22,
+ "src": "\"pageYOffset\" === prop",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "10054": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 17,
+ "src": "val === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10055": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 3,
+ "src": "win",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10058": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 3,
+ "src": "win",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10060": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 4,
+ "src": "!top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10061": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 3,
+ "src": "top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10080": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 8,
+ "src": "computed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10084": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 26,
+ "src": "rnumnonpx.test(computed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10100": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 67,
+ "src": "arguments.length && (defaultExtra || typeof margin !== \"boolean\")",
+ "evalFalse": 24,
+ "evalTrue": 8
+ },
+ {
+ "position": 42,
+ "nodeLength": 43,
+ "src": "defaultExtra || typeof margin !== \"boolean\"",
+ "evalFalse": 8,
+ "evalTrue": 8
+ },
+ {
+ "position": 58,
+ "nodeLength": 27,
+ "src": "typeof margin !== \"boolean\"",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "10101": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 75,
+ "src": "defaultExtra || (margin === true || value === true ? \"margin\" : \"border\")",
+ "evalFalse": 0,
+ "evalTrue": 32
+ },
+ {
+ "position": 114,
+ "nodeLength": 33,
+ "src": "margin === true || value === true",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 114,
+ "nodeLength": 15,
+ "src": "margin === true",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 133,
+ "nodeLength": 14,
+ "src": "value === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10106": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 23,
+ "src": "jQuery.isWindow(elem)",
+ "evalFalse": 44,
+ "evalTrue": 0
+ }
+ ],
+ "10109": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 33,
+ "src": "funcName.indexOf(\"outer\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10115": [
+ null,
+ {
+ "position": 320,
+ "nodeLength": 19,
+ "src": "elem.nodeType === 9",
+ "evalFalse": 44,
+ "evalTrue": 0
+ }
+ ],
+ "10127": [
+ null,
+ {
+ "position": 690,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 20,
+ "evalTrue": 24
+ }
+ ],
+ "10134": [
+ null,
+ {
+ "position": 981,
+ "nodeLength": 9,
+ "src": "chainable",
+ "evalFalse": 24,
+ "evalTrue": 8
+ }
+ ],
+ "10155": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 22,
+ "src": "arguments.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10157": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 16,
+ "src": "selector || \"**\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10179": [
+ null,
+ {
+ "position": 265349,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 265349,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "10197": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 19,
+ "src": "window.$ === jQuery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10201": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 32,
+ "src": "deep && window.jQuery === jQuery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 24,
+ "src": "window.jQuery === jQuery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10211": [
+ null,
+ {
+ "position": 265940,
+ "nodeLength": 9,
+ "src": "!noGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/jquery-ui.1.12.1.min.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "functionData": [
+ 1,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 10,
+ 5,
+ 0,
+ 1,
+ 0,
+ 27,
+ 0,
+ 13,
+ 514,
+ 441,
+ 0,
+ 0,
+ 0,
+ 0,
+ 172,
+ 27,
+ 0,
+ 0,
+ 0,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 8,
+ 0,
+ 1,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 41,
+ 1,
+ 30,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 8,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 15,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 4,
+ 1,
+ 4,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "branchData": {
+ "6": [
+ null,
+ {
+ "position": 1145,
+ "nodeLength": 37,
+ "src": "\"function\" == typeof define && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1145,
+ "nodeLength": 25,
+ "src": "\"function\" == typeof define",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1272,
+ "nodeLength": 13,
+ "src": "\"inherit\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1328,
+ "nodeLength": 12,
+ "src": "\"hidden\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1367,
+ "nodeLength": 25,
+ "src": "t.length && t[0] !== document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1377,
+ "nodeLength": 15,
+ "src": "t[0] !== document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1398,
+ "nodeLength": 116,
+ "src": "e = t.css(\"position\") , (\"absolute\" === e || \"relative\" === e || \"fixed\" === e) && (i = parseInt(t.css(\"zIndex\"), 10) , !isNaN(i) && 0 !== i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1419,
+ "nodeLength": 95,
+ "src": "(\"absolute\" === e || \"relative\" === e || \"fixed\" === e) && (i = parseInt(t.css(\"zIndex\"), 10) , !isNaN(i) && 0 !== i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1419,
+ "nodeLength": 43,
+ "src": "\"absolute\" === e || \"relative\" === e || \"fixed\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1419,
+ "nodeLength": 14,
+ "src": "\"absolute\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1435,
+ "nodeLength": 27,
+ "src": "\"relative\" === e || \"fixed\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1435,
+ "nodeLength": 14,
+ "src": "\"relative\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1451,
+ "nodeLength": 11,
+ "src": "\"fixed\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1497,
+ "nodeLength": 16,
+ "src": "!isNaN(i) && 0 !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1508,
+ "nodeLength": 5,
+ "src": "0 !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3770,
+ "nodeLength": 98,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-prev\") && t(this).removeClass(\"ui-datepicker-prev-hover\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3770,
+ "nodeLength": 49,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-prev\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3869,
+ "nodeLength": 98,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-next\") && t(this).removeClass(\"ui-datepicker-next-hover\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3869,
+ "nodeLength": 49,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-next\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4003,
+ "nodeLength": 388,
+ "src": "t.datepicker._isDisabledDatepicker(m.inline ? m.dpDiv.parent()[0] : m.input[0]) || (t(this).parents(\".ui-datepicker-calendar\").find(\"a\").removeClass(\"ui-state-hover\") , t(this).addClass(\"ui-state-hover\") , -1 !== this.className.indexOf(\"ui-datepicker-prev\") && t(this).addClass(\"ui-datepicker-prev-hover\") , -1 !== this.className.indexOf(\"ui-datepicker-next\") && t(this).addClass(\"ui-datepicker-next-hover\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4038,
+ "nodeLength": 8,
+ "src": "m.inline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4199,
+ "nodeLength": 95,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-prev\") && t(this).addClass(\"ui-datepicker-prev-hover\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4199,
+ "nodeLength": 49,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-prev\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4295,
+ "nodeLength": 95,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-next\") && t(this).addClass(\"ui-datepicker-next-hover\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4295,
+ "nodeLength": 49,
+ "src": "-1 !== this.className.indexOf(\"ui-datepicker-next\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4437,
+ "nodeLength": 23,
+ "src": "null == i[s] && (e[s] = i[s])",
+ "evalFalse": 150,
+ "evalTrue": 0
+ },
+ {
+ "position": 4437,
+ "nodeLength": 10,
+ "src": "null == i[s]",
+ "evalFalse": 150,
+ "evalTrue": 0
+ },
+ {
+ "position": 4567,
+ "nodeLength": 47,
+ "src": "e !== this.element.val() && this._trigger(\"change\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4567,
+ "nodeLength": 22,
+ "src": "e !== this.element.val()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4621,
+ "nodeLength": 8,
+ "src": "t.ui || {}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 4745,
+ "nodeLength": 14,
+ "src": "null != (n = i[o])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4790,
+ "nodeLength": 42,
+ "src": "s && s.remove && t(n).triggerHandler(\"remove\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4793,
+ "nodeLength": 39,
+ "src": "s.remove && t(n).triggerHandler(\"remove\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4960,
+ "nodeLength": 19,
+ "src": "s || (s = i , i = t.Widget)",
+ "evalFalse": 0,
+ "evalTrue": 27
+ },
+ {
+ "position": 4980,
+ "nodeLength": 53,
+ "src": "t.isArray(s) && (s = t.extend.apply(null, [{}].concat(s)))",
+ "evalFalse": 25,
+ "evalTrue": 2
+ },
+ {
+ "position": 5101,
+ "nodeLength": 8,
+ "src": "t[h] || {}",
+ "evalFalse": 0,
+ "evalTrue": 27
+ },
+ {
+ "position": 5151,
+ "nodeLength": 18,
+ "src": "this._createWidget",
+ "evalFalse": 0,
+ "evalTrue": 13
+ },
+ {
+ "position": 5171,
+ "nodeLength": 41,
+ "src": "arguments.length && this._createWidget(t, e)",
+ "evalFalse": 13,
+ "evalTrue": 0
+ },
+ {
+ "position": 5389,
+ "nodeLength": 15,
+ "src": "t.isFunction(s)",
+ "evalFalse": 73,
+ "evalTrue": 441
+ },
+ {
+ "position": 5766,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 19,
+ "evalTrue": 8
+ },
+ {
+ "position": 5768,
+ "nodeLength": 22,
+ "src": "a.widgetEventPrefix || e",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 5855,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 19,
+ "evalTrue": 8
+ },
+ {
+ "position": 6129,
+ "nodeLength": 3,
+ "src": "a > o",
+ "evalFalse": 172,
+ "evalTrue": 261
+ },
+ {
+ "position": 6161,
+ "nodeLength": 134,
+ "src": "n[o].hasOwnProperty(i) && void 0 !== s && (e[i] = t.isPlainObject(s) ? t.isPlainObject(e[i]) ? t.widget.extend({}, e[i], s) : t.widget.extend({}, s) : s)",
+ "evalFalse": 365,
+ "evalTrue": 1020
+ },
+ {
+ "position": 6185,
+ "nodeLength": 110,
+ "src": "void 0 !== s && (e[i] = t.isPlainObject(s) ? t.isPlainObject(e[i]) ? t.widget.extend({}, e[i], s) : t.widget.extend({}, s) : s)",
+ "evalFalse": 365,
+ "evalTrue": 1020
+ },
+ {
+ "position": 6185,
+ "nodeLength": 10,
+ "src": "void 0 !== s",
+ "evalFalse": 0,
+ "evalTrue": 1385
+ },
+ {
+ "position": 6203,
+ "nodeLength": 18,
+ "src": "t.isPlainObject(s)",
+ "evalFalse": 1267,
+ "evalTrue": 118
+ },
+ {
+ "position": 6222,
+ "nodeLength": 21,
+ "src": "t.isPlainObject(e[i])",
+ "evalFalse": 83,
+ "evalTrue": 35
+ },
+ {
+ "position": 6342,
+ "nodeLength": 29,
+ "src": "i.prototype.widgetFullName || e",
+ "evalFalse": 0,
+ "evalTrue": 27
+ },
+ {
+ "position": 6398,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6453,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6455,
+ "nodeLength": 27,
+ "src": "this.length || \"instance\" !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6468,
+ "nodeLength": 14,
+ "src": "\"instance\" !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6533,
+ "nodeLength": 14,
+ "src": "\"instance\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6557,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6559,
+ "nodeLength": 37,
+ "src": "t.isFunction(o[n]) && \"_\" !== n.charAt(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6579,
+ "nodeLength": 17,
+ "src": "\"_\" !== n.charAt(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6616,
+ "nodeLength": 17,
+ "src": "i !== o && void 0 !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6616,
+ "nodeLength": 5,
+ "src": "i !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6623,
+ "nodeLength": 10,
+ "src": "void 0 !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6637,
+ "nodeLength": 11,
+ "src": "i && i.jquery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6857,
+ "nodeLength": 55,
+ "src": "a.length && (n = t.widget.extend.apply(null, [n].concat(a)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6955,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6967,
+ "nodeLength": 5,
+ "src": "n || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6974,
+ "nodeLength": 18,
+ "src": "e._init && e._init()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7244,
+ "nodeLength": 28,
+ "src": "i || this.defaultElement || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5,
+ "nodeLength": 25,
+ "src": "this.defaultElement || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7444,
+ "nodeLength": 253,
+ "src": "i !== this && (t.data(i, this.widgetFullName, this) , this._on(!0, this.element, {\n remove: function(t) {\n t.target === i && this.destroy();\n}}) , this.document = t(i.style ? i.ownerDocument : i.document || i) , this.window = t(this.document[0].defaultView || this.document[0].parentWindow))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7444,
+ "nodeLength": 8,
+ "src": "i !== this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7535,
+ "nodeLength": 28,
+ "src": "t.target === i && this.destroy()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7535,
+ "nodeLength": 12,
+ "src": "t.target === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7583,
+ "nodeLength": 7,
+ "src": "i.style",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7607,
+ "nodeLength": 13,
+ "src": "i.document || i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7636,
+ "nodeLength": 59,
+ "src": "this.document[0].defaultView || this.document[0].parentWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7786,
+ "nodeLength": 69,
+ "src": "this.options.disabled && this._setOptionDisabled(this.options.disabled)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8405,
+ "nodeLength": 20,
+ "src": "0 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8469,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8491,
+ "nodeLength": 40,
+ "src": "a = {} , s = e.split(\".\") , e = s.shift() , s.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8584,
+ "nodeLength": 12,
+ "src": "s.length - 1 > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8609,
+ "nodeLength": 11,
+ "src": "n[s[o]] || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8634,
+ "nodeLength": 30,
+ "src": "e = s.pop() , 1 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8644,
+ "nodeLength": 20,
+ "src": "1 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8672,
+ "nodeLength": 13,
+ "src": "void 0 === n[e]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8711,
+ "nodeLength": 20,
+ "src": "1 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8739,
+ "nodeLength": 24,
+ "src": "void 0 === this.options[e]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8934,
+ "nodeLength": 40,
+ "src": "\"classes\" === t && this._setOptionClasses(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8934,
+ "nodeLength": 13,
+ "src": "\"classes\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8993,
+ "nodeLength": 42,
+ "src": "\"disabled\" === t && this._setOptionDisabled(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8993,
+ "nodeLength": 14,
+ "src": "\"disabled\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9124,
+ "nodeLength": 145,
+ "src": "e[i] !== this.options.classes[i] && n && n.length && (s = t(n.get()) , this._removeClass(n, i) , s.addClass(this._classes({\n element: s, \n keys: i, \n classes: e, \n add: !0})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9124,
+ "nodeLength": 30,
+ "src": "e[i] !== this.options.classes[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9156,
+ "nodeLength": 113,
+ "src": "n && n.length && (s = t(n.get()) , this._removeClass(n, i) , s.addClass(this._classes({\n element: s, \n keys: i, \n classes: e, \n add: !0})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9159,
+ "nodeLength": 110,
+ "src": "n.length && (s = t(n.get()) , this._removeClass(n, i) , s.addClass(this._classes({\n element: s, \n keys: i, \n classes: e, \n add: !0})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9376,
+ "nodeLength": 116,
+ "src": "t && (this._removeClass(this.hoverable, null, \"ui-state-hover\") , this._removeClass(this.focusable, null, \"ui-state-focus\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9664,
+ "nodeLength": 10,
+ "src": "i.length > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9681,
+ "nodeLength": 33,
+ "src": "n.classesElementLookup[i[r]] || t()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9717,
+ "nodeLength": 5,
+ "src": "e.add",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9838,
+ "nodeLength": 43,
+ "src": "o && e.classes[i[r]] && s.push(e.classes[i[r]])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9841,
+ "nodeLength": 40,
+ "src": "e.classes[i[r]] && s.push(e.classes[i[r]])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9946,
+ "nodeLength": 24,
+ "src": "this.options.classes || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10029,
+ "nodeLength": 38,
+ "src": "e.keys && i(e.keys.match(/\\S+/g) || [], !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10039,
+ "nodeLength": 24,
+ "src": "e.keys.match(/\\S+/g) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10068,
+ "nodeLength": 37,
+ "src": "e.extra && i(e.extra.match(/\\S+/g) || [])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10079,
+ "nodeLength": 25,
+ "src": "e.extra.match(/\\S+/g) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10209,
+ "nodeLength": 80,
+ "src": "-1 !== t.inArray(e.target, n) && (i.classesElementLookup[s] = t(n.not(e.target).get()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10209,
+ "nodeLength": 26,
+ "src": "-1 !== t.inArray(e.target, n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10453,
+ "nodeLength": 19,
+ "src": "\"boolean\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10483,
+ "nodeLength": 28,
+ "src": "\"string\" == typeof t || null === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10483,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10503,
+ "nodeLength": 8,
+ "src": "null === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10521,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10532,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10546,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10658,
+ "nodeLength": 35,
+ "src": "\"boolean\" != typeof e && (s = i , i = e , e = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10658,
+ "nodeLength": 19,
+ "src": "\"boolean\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10694,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10822,
+ "nodeLength": 66,
+ "src": "e || o.options.disabled !== !0 && !t(this).hasClass(\"ui-state-disabled\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10825,
+ "nodeLength": 63,
+ "src": "o.options.disabled !== !0 && !t(this).hasClass(\"ui-state-disabled\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10825,
+ "nodeLength": 23,
+ "src": "o.options.disabled !== !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10890,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10943,
+ "nodeLength": 60,
+ "src": "\"string\" != typeof a && (r.guid = a.guid = a.guid || r.guid || t.guid++)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10943,
+ "nodeLength": 18,
+ "src": "\"string\" != typeof a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10978,
+ "nodeLength": 24,
+ "src": "a.guid || r.guid || t.guid++",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10986,
+ "nodeLength": 16,
+ "src": "r.guid || t.guid++",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11071,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11120,
+ "nodeLength": 5,
+ "src": "i || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11382,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11459,
+ "nodeLength": 4,
+ "src": "e || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11995,
+ "nodeLength": 140,
+ "src": "s = s || {} , i = t.Event(i) , i.type = (e === this.widgetEventPrefix ? e : this.widgetEventPrefix + e).toLowerCase() , i.target = this.element[0] , o = i.originalEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11997,
+ "nodeLength": 5,
+ "src": "s || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12024,
+ "nodeLength": 26,
+ "src": "e === this.widgetEventPrefix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12147,
+ "nodeLength": 19,
+ "src": "n in i || (i[n] = o[n])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12202,
+ "nodeLength": 84,
+ "src": "t.isFunction(a) && a.apply(this.element[0], [i].concat(s)) === !1 || i.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12202,
+ "nodeLength": 60,
+ "src": "t.isFunction(a) && a.apply(this.element[0], [i].concat(s)) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12219,
+ "nodeLength": 43,
+ "src": "a.apply(this.element[0], [i].concat(s)) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12384,
+ "nodeLength": 34,
+ "src": "\"string\" == typeof n && (n = {\n effect: n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12384,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12427,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12429,
+ "nodeLength": 26,
+ "src": "n === !0 || \"number\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12429,
+ "nodeLength": 6,
+ "src": "n === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12437,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12458,
+ "nodeLength": 11,
+ "src": "n.effect || i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12474,
+ "nodeLength": 5,
+ "src": "n || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12480,
+ "nodeLength": 36,
+ "src": "\"number\" == typeof n && (n = {\n duration: n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12480,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12552,
+ "nodeLength": 25,
+ "src": "n.delay && s.delay(n.delay)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12578,
+ "nodeLength": 33,
+ "src": "a && t.effects && t.effects.effect[r]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12581,
+ "nodeLength": 30,
+ "src": "t.effects && t.effects.effect[r]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12620,
+ "nodeLength": 11,
+ "src": "r !== e && s[r]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12620,
+ "nodeLength": 5,
+ "src": "r !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12693,
+ "nodeLength": 15,
+ "src": "o && o.call(s[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12781,
+ "nodeLength": 12,
+ "src": "u.test(t[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12821,
+ "nodeLength": 12,
+ "src": "u.test(t[1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12867,
+ "nodeLength": 26,
+ "src": "parseInt(t.css(e, i), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12926,
+ "nodeLength": 14,
+ "src": "9 === i.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12999,
+ "nodeLength": 13,
+ "src": "t.isWindow(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13096,
+ "nodeLength": 16,
+ "src": "i.preventDefault",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13401,
+ "nodeLength": 10,
+ "src": "void 0 !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13676,
+ "nodeLength": 27,
+ "src": "e === i && (i = s[0].clientWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13676,
+ "nodeLength": 5,
+ "src": "e === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13754,
+ "nodeLength": 24,
+ "src": "e.isWindow || e.isDocument",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13812,
+ "nodeLength": 24,
+ "src": "e.isWindow || e.isDocument",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13870,
+ "nodeLength": 58,
+ "src": "\"scroll\" === i || \"auto\" === i && e.width < e.element[0].scrollWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13870,
+ "nodeLength": 12,
+ "src": "\"scroll\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13884,
+ "nodeLength": 44,
+ "src": "\"auto\" === i && e.width < e.element[0].scrollWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13884,
+ "nodeLength": 10,
+ "src": "\"auto\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13896,
+ "nodeLength": 32,
+ "src": "e.width < e.element[0].scrollWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13931,
+ "nodeLength": 60,
+ "src": "\"scroll\" === s || \"auto\" === s && e.height < e.element[0].scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13931,
+ "nodeLength": 12,
+ "src": "\"scroll\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13945,
+ "nodeLength": 46,
+ "src": "\"auto\" === s && e.height < e.element[0].scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13945,
+ "nodeLength": 10,
+ "src": "\"auto\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13957,
+ "nodeLength": 34,
+ "src": "e.height < e.element[0].scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14005,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14044,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14112,
+ "nodeLength": 9,
+ "src": "e || window",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14144,
+ "nodeLength": 25,
+ "src": "!!i[0] && 9 === i[0].nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14152,
+ "nodeLength": 17,
+ "src": "9 === i[0].nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14172,
+ "nodeLength": 6,
+ "src": "!s && !n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14227,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14384,
+ "nodeLength": 9,
+ "src": "!n || !n.of",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14538,
+ "nodeLength": 19,
+ "src": "n.collision || \"flip\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14589,
+ "nodeLength": 38,
+ "src": "v[0].preventDefault && (n.at = \"left top\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14718,
+ "nodeLength": 11,
+ "src": "n[this] || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14742,
+ "nodeLength": 105,
+ "src": "1 === i.length && (i = r.test(i[0]) ? i.concat([\"center\"]) : h.test(i[0]) ? [\"center\"].concat(i) : [\"center\", \"center\"])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14742,
+ "nodeLength": 12,
+ "src": "1 === i.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14759,
+ "nodeLength": 12,
+ "src": "r.test(i[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14793,
+ "nodeLength": 12,
+ "src": "h.test(i[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14853,
+ "nodeLength": 12,
+ "src": "r.test(i[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14885,
+ "nodeLength": 12,
+ "src": "h.test(i[1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14951,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14960,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15014,
+ "nodeLength": 25,
+ "src": "1 === w.length && (w[1] = w[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15014,
+ "nodeLength": 12,
+ "src": "1 === w.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15040,
+ "nodeLength": 17,
+ "src": "\"right\" === n.at[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15068,
+ "nodeLength": 33,
+ "src": "\"center\" === n.at[0] && (m.left += p / 2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15068,
+ "nodeLength": 18,
+ "src": "\"center\" === n.at[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15102,
+ "nodeLength": 18,
+ "src": "\"bottom\" === n.at[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15130,
+ "nodeLength": 32,
+ "src": "\"center\" === n.at[1] && (m.top += f / 2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15130,
+ "nodeLength": 18,
+ "src": "\"center\" === n.at[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15453,
+ "nodeLength": 17,
+ "src": "\"right\" === n.my[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15481,
+ "nodeLength": 33,
+ "src": "\"center\" === n.my[0] && (D.left -= l / 2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15481,
+ "nodeLength": 18,
+ "src": "\"center\" === n.my[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15515,
+ "nodeLength": 18,
+ "src": "\"bottom\" === n.my[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15543,
+ "nodeLength": 32,
+ "src": "\"center\" === n.my[1] && (D.top -= c / 2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15543,
+ "nodeLength": 18,
+ "src": "\"center\" === n.my[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15666,
+ "nodeLength": 218,
+ "src": "t.ui.position[w[e]] && t.ui.position[w[e]][i](D, {\n targetWidth: p, \n targetHeight: f, \n elemWidth: l, \n elemHeight: c, \n collisionPosition: s, \n collisionWidth: x, \n collisionHeight: C, \n offset: [u[0] + I[0], u[1] + I[1]], \n my: n.my, \n at: n.at, \n within: b, \n elem: h})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15887,
+ "nodeLength": 441,
+ "src": "n.using && (r = function(t) {\n var e = g.left - D.left, i = e + p - l, s = g.top - D.top, r = s + f - c, u = {\n target: {\n element: v, \n left: g.left, \n top: g.top, \n width: p, \n height: f}, \n element: {\n element: h, \n left: D.left, \n top: D.top, \n width: l, \n height: c}, \n horizontal: 0 > i ? \"left\" : e > 0 ? \"right\" : \"center\", \n vertical: 0 > r ? \"top\" : s > 0 ? \"bottom\" : \"middle\"};\n l > p && p > a(e + i) && (u.horizontal = \"center\") , c > f && f > a(s + r) && (u.vertical = \"middle\") , u.important = o(a(e), a(i)) > o(a(s), a(r)) ? \"horizontal\" : \"vertical\" , n.using.call(this, t, u);\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16092,
+ "nodeLength": 3,
+ "src": "0 > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16103,
+ "nodeLength": 3,
+ "src": "e > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16133,
+ "nodeLength": 3,
+ "src": "0 > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16143,
+ "nodeLength": 3,
+ "src": "s > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16166,
+ "nodeLength": 38,
+ "src": "l > p && p > a(e + i) && (u.horizontal = \"center\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16166,
+ "nodeLength": 3,
+ "src": "l > p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16171,
+ "nodeLength": 33,
+ "src": "p > a(e + i) && (u.horizontal = \"center\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16171,
+ "nodeLength": 8,
+ "src": "p > a(e + i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16205,
+ "nodeLength": 36,
+ "src": "c > f && f > a(s + r) && (u.vertical = \"middle\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16205,
+ "nodeLength": 3,
+ "src": "c > f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16210,
+ "nodeLength": 31,
+ "src": "f > a(s + r) && (u.vertical = \"middle\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16210,
+ "nodeLength": 8,
+ "src": "f > a(s + r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16254,
+ "nodeLength": 25,
+ "src": "o(a(e), a(i)) > o(a(s), a(r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16422,
+ "nodeLength": 10,
+ "src": "s.isWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16541,
+ "nodeLength": 18,
+ "src": "e.collisionWidth > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16560,
+ "nodeLength": 9,
+ "src": "h > 0 && 0 >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16560,
+ "nodeLength": 3,
+ "src": "h > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16565,
+ "nodeLength": 4,
+ "src": "0 >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16623,
+ "nodeLength": 9,
+ "src": "l > 0 && 0 >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16623,
+ "nodeLength": 3,
+ "src": "l > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16628,
+ "nodeLength": 4,
+ "src": "0 >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16635,
+ "nodeLength": 3,
+ "src": "h > l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16662,
+ "nodeLength": 3,
+ "src": "h > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16676,
+ "nodeLength": 3,
+ "src": "l > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16754,
+ "nodeLength": 10,
+ "src": "s.isWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16878,
+ "nodeLength": 19,
+ "src": "e.collisionHeight > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16898,
+ "nodeLength": 9,
+ "src": "h > 0 && 0 >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16898,
+ "nodeLength": 3,
+ "src": "h > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16903,
+ "nodeLength": 4,
+ "src": "0 >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16959,
+ "nodeLength": 9,
+ "src": "l > 0 && 0 >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16959,
+ "nodeLength": 3,
+ "src": "l > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16964,
+ "nodeLength": 4,
+ "src": "0 >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16971,
+ "nodeLength": 3,
+ "src": "h > l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16999,
+ "nodeLength": 3,
+ "src": "h > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17012,
+ "nodeLength": 3,
+ "src": "l > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17135,
+ "nodeLength": 10,
+ "src": "n.isWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17246,
+ "nodeLength": 16,
+ "src": "\"left\" === e.my[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17276,
+ "nodeLength": 17,
+ "src": "\"right\" === e.my[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17310,
+ "nodeLength": 16,
+ "src": "\"left\" === e.at[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17341,
+ "nodeLength": 17,
+ "src": "\"right\" === e.at[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17393,
+ "nodeLength": 3,
+ "src": "0 > c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17435,
+ "nodeLength": 29,
+ "src": "(0 > i || a(c) > i) && (t.left += d + p + f)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17435,
+ "nodeLength": 11,
+ "src": "0 > i || a(c) > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17435,
+ "nodeLength": 3,
+ "src": "0 > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17440,
+ "nodeLength": 6,
+ "src": "a(c) > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17466,
+ "nodeLength": 85,
+ "src": "u > 0 && (s = t.left - e.collisionPosition.marginLeft + d + p + f - h , (s > 0 || u > a(s)) && (t.left += d + p + f))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17466,
+ "nodeLength": 3,
+ "src": "u > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17521,
+ "nodeLength": 29,
+ "src": "(s > 0 || u > a(s)) && (t.left += d + p + f)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17521,
+ "nodeLength": 11,
+ "src": "s > 0 || u > a(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17521,
+ "nodeLength": 3,
+ "src": "s > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17526,
+ "nodeLength": 6,
+ "src": "u > a(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17630,
+ "nodeLength": 10,
+ "src": "n.isWindow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17738,
+ "nodeLength": 15,
+ "src": "\"top\" === e.my[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17756,
+ "nodeLength": 1,
+ "src": "d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17772,
+ "nodeLength": 18,
+ "src": "\"bottom\" === e.my[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17808,
+ "nodeLength": 15,
+ "src": "\"top\" === e.at[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17839,
+ "nodeLength": 18,
+ "src": "\"bottom\" === e.at[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17893,
+ "nodeLength": 3,
+ "src": "0 > c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17935,
+ "nodeLength": 28,
+ "src": "(0 > s || a(c) > s) && (t.top += p + f + g)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17935,
+ "nodeLength": 11,
+ "src": "0 > s || a(c) > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17935,
+ "nodeLength": 3,
+ "src": "0 > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17940,
+ "nodeLength": 6,
+ "src": "a(c) > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17965,
+ "nodeLength": 82,
+ "src": "u > 0 && (i = t.top - e.collisionPosition.marginTop + p + f + g - h , (i > 0 || u > a(i)) && (t.top += p + f + g))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17965,
+ "nodeLength": 3,
+ "src": "u > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18018,
+ "nodeLength": 28,
+ "src": "(i > 0 || u > a(i)) && (t.top += p + f + g)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18018,
+ "nodeLength": 11,
+ "src": "i > 0 || u > a(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18018,
+ "nodeLength": 3,
+ "src": "i > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18023,
+ "nodeLength": 6,
+ "src": "u > a(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18318,
+ "nodeLength": 19,
+ "src": "t.expr.createPseudo",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 18501,
+ "nodeLength": 47,
+ "src": "\"onselectstart\" in document.createElement(\"div\")",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 18869,
+ "nodeLength": 13,
+ "src": "u[e.type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18890,
+ "nodeLength": 7,
+ "src": "null == t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18898,
+ "nodeLength": 9,
+ "src": "i || !e.def",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18922,
+ "nodeLength": 7,
+ "src": "s.floor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18948,
+ "nodeLength": 8,
+ "src": "isNaN(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18963,
+ "nodeLength": 5,
+ "src": "s.mod",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18985,
+ "nodeLength": 3,
+ "src": "0 > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18991,
+ "nodeLength": 7,
+ "src": "t > s.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19111,
+ "nodeLength": 13,
+ "src": "r && o.parse(r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19127,
+ "nodeLength": 15,
+ "src": "o.space || \"rgba\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19150,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19217,
+ "nodeLength": 8,
+ "src": "n.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19227,
+ "nodeLength": 47,
+ "src": "\"0,0,0,0\" === n.join() && t.extend(n, o.transparent)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19227,
+ "nodeLength": 20,
+ "src": "\"0,0,0,0\" === n.join()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19318,
+ "nodeLength": 5,
+ "src": "1 > 6 * i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19336,
+ "nodeLength": 5,
+ "src": "1 > 2 * i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19344,
+ "nodeLength": 5,
+ "src": "2 > 3 * i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20764,
+ "nodeLength": 42,
+ "src": "p.style.backgroundColor.indexOf(\"rgba\") > -1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 20938,
+ "nodeLength": 5,
+ "src": "n === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20990,
+ "nodeLength": 42,
+ "src": "(n.jquery || n.nodeType) && (n = t(n).css(a) , a = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20990,
+ "nodeLength": 20,
+ "src": "n.jquery || n.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21079,
+ "nodeLength": 30,
+ "src": "a !== e && (n = [n, a, r, h] , d = \"array\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21079,
+ "nodeLength": 5,
+ "src": "a !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21110,
+ "nodeLength": 12,
+ "src": "\"string\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21134,
+ "nodeLength": 16,
+ "src": "s(n) || o._default",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21152,
+ "nodeLength": 11,
+ "src": "\"array\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21225,
+ "nodeLength": 12,
+ "src": "\"object\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21239,
+ "nodeLength": 14,
+ "src": "n instanceof l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21272,
+ "nodeLength": 43,
+ "src": "n[e.cache] && (u[e.cache] = n[e.cache].slice())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21377,
+ "nodeLength": 11,
+ "src": "!u[o] && s.to",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21393,
+ "nodeLength": 23,
+ "src": "\"alpha\" === t || null == n[t]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21393,
+ "nodeLength": 11,
+ "src": "\"alpha\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21406,
+ "nodeLength": 10,
+ "src": "null == n[t]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21470,
+ "nodeLength": 83,
+ "src": "u[o] && 0 > t.inArray(null, u[o].slice(0, 3)) && (u[o][3] = 1 , s.from && (u._rgba = s.from(u[o])))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21476,
+ "nodeLength": 77,
+ "src": "0 > t.inArray(null, u[o].slice(0, 3)) && (u[o][3] = 1 , s.from && (u._rgba = s.from(u[o])))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21476,
+ "nodeLength": 33,
+ "src": "0 > t.inArray(null, u[o].slice(0, 3))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21522,
+ "nodeLength": 30,
+ "src": "s.from && (u._rgba = s.from(u[o]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21654,
+ "nodeLength": 114,
+ "src": "r && (a = n[o.cache] || o.to && o.to(n._rgba) || [] , f(o.props, function(t, i) {\n return null != r[i.idx] ? s = r[i.idx] === a[i.idx] : e;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21660,
+ "nodeLength": 35,
+ "src": "n[o.cache] || o.to && o.to(n._rgba) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21672,
+ "nodeLength": 23,
+ "src": "o.to && o.to(n._rgba) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21672,
+ "nodeLength": 19,
+ "src": "o.to && o.to(n._rgba)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21727,
+ "nodeLength": 14,
+ "src": "null != r[i.idx]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21744,
+ "nodeLength": 19,
+ "src": "r[i.idx] === a[i.idx]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21835,
+ "nodeLength": 21,
+ "src": "e[s.cache] && t.push(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21926,
+ "nodeLength": 16,
+ "src": "0 === this.alpha()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21967,
+ "nodeLength": 25,
+ "src": "a[o.cache] || o.to(a._rgba)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22077,
+ "nodeLength": 13,
+ "src": "u[n.type] || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22091,
+ "nodeLength": 103,
+ "src": "null !== l && (null === a ? h[o] = l : (c.mod && (l - a > c.mod / 2 ? a += c.mod : a - l > c.mod / 2 && (a -= c.mod)) , h[o] = i((l - a) * e + a, n)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22091,
+ "nodeLength": 8,
+ "src": "null !== l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22102,
+ "nodeLength": 8,
+ "src": "null === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22119,
+ "nodeLength": 53,
+ "src": "c.mod && (l - a > c.mod / 2 ? a += c.mod : a - l > c.mod / 2 && (a -= c.mod))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22127,
+ "nodeLength": 11,
+ "src": "l - a > c.mod / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22148,
+ "nodeLength": 23,
+ "src": "a - l > c.mod / 2 && (a -= c.mod)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22148,
+ "nodeLength": 11,
+ "src": "a - l > c.mod / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22230,
+ "nodeLength": 17,
+ "src": "1 === this._rgba[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22442,
+ "nodeLength": 7,
+ "src": "null == t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22450,
+ "nodeLength": 3,
+ "src": "e > 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22469,
+ "nodeLength": 28,
+ "src": "1 === i[3] && (i.pop() , e = \"rgb(\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22469,
+ "nodeLength": 8,
+ "src": "1 === i[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22593,
+ "nodeLength": 20,
+ "src": "null == t && (t = e > 2 ? 1 : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22593,
+ "nodeLength": 7,
+ "src": "null == t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22605,
+ "nodeLength": 3,
+ "src": "e > 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22614,
+ "nodeLength": 33,
+ "src": "e && 3 > e && (t = Math.round(100 * t) + \"%\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22617,
+ "nodeLength": 30,
+ "src": "3 > e && (t = Math.round(100 * t) + \"%\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22617,
+ "nodeLength": 3,
+ "src": "3 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22659,
+ "nodeLength": 28,
+ "src": "1 === i[3] && (i.pop() , e = \"hsl(\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22659,
+ "nodeLength": 8,
+ "src": "1 === i[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22770,
+ "nodeLength": 20,
+ "src": "e && i.push(~~(255 * s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22825,
+ "nodeLength": 4,
+ "src": "t || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22844,
+ "nodeLength": 12,
+ "src": "1 === t.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22904,
+ "nodeLength": 17,
+ "src": "0 === this._rgba[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23010,
+ "nodeLength": 34,
+ "src": "null == t[0] || null == t[1] || null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23010,
+ "nodeLength": 10,
+ "src": "null == t[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23022,
+ "nodeLength": 22,
+ "src": "null == t[1] || null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23022,
+ "nodeLength": 10,
+ "src": "null == t[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23034,
+ "nodeLength": 10,
+ "src": "null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23185,
+ "nodeLength": 5,
+ "src": "h === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23193,
+ "nodeLength": 5,
+ "src": "s === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23214,
+ "nodeLength": 5,
+ "src": "n === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23252,
+ "nodeLength": 5,
+ "src": "0 === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23260,
+ "nodeLength": 5,
+ "src": ".5 >= u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23301,
+ "nodeLength": 7,
+ "src": "null == a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23342,
+ "nodeLength": 34,
+ "src": "null == t[0] || null == t[1] || null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23342,
+ "nodeLength": 10,
+ "src": "null == t[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23354,
+ "nodeLength": 22,
+ "src": "null == t[1] || null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23354,
+ "nodeLength": 10,
+ "src": "null == t[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23366,
+ "nodeLength": 10,
+ "src": "null == t[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23443,
+ "nodeLength": 5,
+ "src": ".5 >= s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23648,
+ "nodeLength": 42,
+ "src": "h && !this[a] && (this[a] = h(this._rgba)) , s === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23648,
+ "nodeLength": 36,
+ "src": "h && !this[a] && (this[a] = h(this._rgba))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23651,
+ "nodeLength": 33,
+ "src": "!this[a] && (this[a] = h(this._rgba))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23685,
+ "nodeLength": 5,
+ "src": "s === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23734,
+ "nodeLength": 25,
+ "src": "\"array\" === r || \"object\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23734,
+ "nodeLength": 11,
+ "src": "\"array\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23747,
+ "nodeLength": 12,
+ "src": "\"object\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23823,
+ "nodeLength": 12,
+ "src": "\"object\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23845,
+ "nodeLength": 21,
+ "src": "null == s && (s = d[e.idx])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23845,
+ "nodeLength": 7,
+ "src": "null == s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23885,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23932,
+ "nodeLength": 304,
+ "src": "l.fn[e] || (l.fn[e] = function(n) {\n var o, a = t.type(n), h = \"alpha\" === e ? this._hsla ? \"hsla\" : \"rgba\" : s, l = this[h](), c = l[i.idx];\n return \"undefined\" === a ? c : (\"function\" === a && (n = n.call(this, c) , a = t.type(n)) , null == n && i.empty ? this : (\"string\" === a && (o = r.exec(n) , o && (n = c + parseFloat(o[2]) * (\"+\" === o[1] ? 1 : -1))) , l[i.idx] = n , this[h](l)));\n})",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 23982,
+ "nodeLength": 11,
+ "src": "\"alpha\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23994,
+ "nodeLength": 10,
+ "src": "this._hsla",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24050,
+ "nodeLength": 15,
+ "src": "\"undefined\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24069,
+ "nodeLength": 46,
+ "src": "\"function\" === a && (n = n.call(this, c) , a = t.type(n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24069,
+ "nodeLength": 14,
+ "src": "\"function\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24116,
+ "nodeLength": 16,
+ "src": "null == n && i.empty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24116,
+ "nodeLength": 7,
+ "src": "null == n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24139,
+ "nodeLength": 71,
+ "src": "\"string\" === a && (o = r.exec(n) , o && (n = c + parseFloat(o[2]) * (\"+\" === o[1] ? 1 : -1)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24139,
+ "nodeLength": 12,
+ "src": "\"string\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24166,
+ "nodeLength": 43,
+ "src": "o && (n = c + parseFloat(o[2]) * (\"+\" === o[1] ? 1 : -1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24192,
+ "nodeLength": 10,
+ "src": "\"+\" === o[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24346,
+ "nodeLength": 51,
+ "src": "\"transparent\" !== n && (\"string\" !== t.type(n) || (o = s(n)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24346,
+ "nodeLength": 17,
+ "src": "\"transparent\" !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24366,
+ "nodeLength": 30,
+ "src": "\"string\" !== t.type(n) || (o = s(n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24366,
+ "nodeLength": 20,
+ "src": "\"string\" !== t.type(n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24402,
+ "nodeLength": 33,
+ "src": "n = l(o || n) , !d.rgba && 1 !== n._rgba[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24406,
+ "nodeLength": 4,
+ "src": "o || n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24412,
+ "nodeLength": 23,
+ "src": "!d.rgba && 1 !== n._rgba[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24421,
+ "nodeLength": 14,
+ "src": "1 !== n._rgba[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24443,
+ "nodeLength": 21,
+ "src": "\"backgroundColor\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24481,
+ "nodeLength": 38,
+ "src": "(\"\" === r || \"transparent\" === r) && a && a.style",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24481,
+ "nodeLength": 25,
+ "src": "\"\" === r || \"transparent\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24481,
+ "nodeLength": 6,
+ "src": "\"\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24489,
+ "nodeLength": 17,
+ "src": "\"transparent\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24509,
+ "nodeLength": 10,
+ "src": "a && a.style",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24589,
+ "nodeLength": 20,
+ "src": "r && \"transparent\" !== r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24592,
+ "nodeLength": 17,
+ "src": "\"transparent\" !== r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24698,
+ "nodeLength": 64,
+ "src": "e.colorInit || (e.start = l(e.elem, i) , e.end = l(e.end) , e.colorInit = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25333,
+ "nodeLength": 27,
+ "src": "e.ownerDocument.defaultView",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25437,
+ "nodeLength": 26,
+ "src": "n && n.length && n[0] && n[n[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25440,
+ "nodeLength": 23,
+ "src": "n.length && n[0] && n[n[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25450,
+ "nodeLength": 13,
+ "src": "n[0] && n[n[0]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25479,
+ "nodeLength": 3,
+ "src": "s--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25491,
+ "nodeLength": 47,
+ "src": "\"string\" == typeof n[i] && (o[t.camelCase(i)] = n[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25491,
+ "nodeLength": 21,
+ "src": "\"string\" == typeof n[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25555,
+ "nodeLength": 34,
+ "src": "\"string\" == typeof n[i] && (o[i] = n[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25555,
+ "nodeLength": 21,
+ "src": "\"string\" == typeof n[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25646,
+ "nodeLength": 65,
+ "src": "e[s] !== o && (n[s] || (t.fx.step[s] || !isNaN(parseFloat(o))) && (a[s] = o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25646,
+ "nodeLength": 8,
+ "src": "e[s] !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25657,
+ "nodeLength": 53,
+ "src": "n[s] || (t.fx.step[s] || !isNaN(parseFloat(o))) && (a[s] = o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25664,
+ "nodeLength": 46,
+ "src": "(t.fx.step[s] || !isNaN(parseFloat(o))) && (a[s] = o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25664,
+ "nodeLength": 35,
+ "src": "t.fx.step[s] || !isNaN(parseFloat(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25990,
+ "nodeLength": 90,
+ "src": "(\"none\" !== t.end && !t.setAttr || 1 === t.pos && !t.setAttr) && (p.style(t.elem, i, t.end) , t.setAttr = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25990,
+ "nodeLength": 49,
+ "src": "\"none\" !== t.end && !t.setAttr || 1 === t.pos && !t.setAttr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25990,
+ "nodeLength": 26,
+ "src": "\"none\" !== t.end && !t.setAttr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25990,
+ "nodeLength": 14,
+ "src": "\"none\" !== t.end",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26018,
+ "nodeLength": 21,
+ "src": "1 === t.pos && !t.setAttr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26018,
+ "nodeLength": 9,
+ "src": "1 === t.pos",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26084,
+ "nodeLength": 108,
+ "src": "t.fn.addBack || (t.fn.addBack = function(t) {\n return this.add(null == t ? this.prevObject : this.prevObject.filter(t));\n})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 26140,
+ "nodeLength": 7,
+ "src": "null == t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26302,
+ "nodeLength": 19,
+ "src": "a.attr(\"class\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26324,
+ "nodeLength": 10,
+ "src": "h.children",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26457,
+ "nodeLength": 24,
+ "src": "n[e] && a[e + \"Class\"](n[e])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26977,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 41,
+ "evalTrue": 0
+ },
+ {
+ "position": 27124,
+ "nodeLength": 18,
+ "src": "arguments.length > 1",
+ "evalFalse": 30,
+ "evalTrue": 0
+ },
+ {
+ "position": 27295,
+ "nodeLength": 31,
+ "src": "\"boolean\" == typeof s || void 0 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27295,
+ "nodeLength": 19,
+ "src": "\"boolean\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27316,
+ "nodeLength": 10,
+ "src": "void 0 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27327,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27362,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37,
+ "nodeLength": 36,
+ "src": "t.isPlainObject(e) && (i = e , e = e.effect)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87,
+ "nodeLength": 15,
+ "src": "null == i && (i = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87,
+ "nodeLength": 7,
+ "src": "null == i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 34,
+ "src": "t.isFunction(i) && (n = i , s = null , i = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 139,
+ "nodeLength": 51,
+ "src": "(\"number\" == typeof i || t.fx.speeds[i]) && (n = s , s = i , i = {})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 139,
+ "nodeLength": 34,
+ "src": "\"number\" == typeof i || t.fx.speeds[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 139,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191,
+ "nodeLength": 29,
+ "src": "t.isFunction(s) && (n = s , s = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 221,
+ "nodeLength": 16,
+ "src": "i && t.extend(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 240,
+ "nodeLength": 13,
+ "src": "s || i.duration",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 265,
+ "nodeLength": 8,
+ "src": "t.fx.off",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 276,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 297,
+ "nodeLength": 16,
+ "src": "s in t.fx.speeds",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 361,
+ "nodeLength": 13,
+ "src": "n || i.complete",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 397,
+ "nodeLength": 38,
+ "src": "!e || \"number\" == typeof e || t.fx.speeds[e]",
+ "evalFalse": 0,
+ "evalTrue": 8
+ },
+ {
+ "position": 401,
+ "nodeLength": 34,
+ "src": "\"number\" == typeof e || t.fx.speeds[e]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 401,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 439,
+ "nodeLength": 39,
+ "src": "\"string\" != typeof e || t.effects.effect[e]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 439,
+ "nodeLength": 18,
+ "src": "\"string\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 479,
+ "nodeLength": 15,
+ "src": "t.isFunction(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 498,
+ "nodeLength": 28,
+ "src": "\"object\" != typeof e || e.effect",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 498,
+ "nodeLength": 18,
+ "src": "\"object\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 731,
+ "nodeLength": 23,
+ "src": "n.exec(t) || [\"\", 0, i, s, 0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 766,
+ "nodeLength": 19,
+ "src": "parseFloat(o[1]) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 792,
+ "nodeLength": 13,
+ "src": "\"auto\" === o[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 832,
+ "nodeLength": 13,
+ "src": "\"auto\" === o[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 870,
+ "nodeLength": 19,
+ "src": "parseFloat(o[4]) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 891,
+ "nodeLength": 159,
+ "src": "t.expr && t.expr.filters && t.expr.filters.animated && (t.expr.filters.animated = function(e) {\n return function(i) {\n return !!t(i).data(d) || e(i);\n};\n}(t.expr.filters.animated))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 899,
+ "nodeLength": 151,
+ "src": "t.expr.filters && t.expr.filters.animated && (t.expr.filters.animated = function(e) {\n return function(i) {\n return !!t(i).data(d) || e(i);\n};\n}(t.expr.filters.animated))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 915,
+ "nodeLength": 135,
+ "src": "t.expr.filters.animated && (t.expr.filters.animated = function(e) {\n return function(i) {\n return !!t(i).data(d) || e(i);\n};\n}(t.expr.filters.animated))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1002,
+ "nodeLength": 20,
+ "src": "!!t(i).data(d) || e(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1051,
+ "nodeLength": 1339,
+ "src": "t.uiBackCompat !== !1 && t.extend(t.effects, {\n save: function(t, e) {\n for (var i = 0, s = e.length; s > i; i++) \n null !== e[i] && t.data(c + e[i], t[0].style[e[i]]);\n}, \n restore: function(t, e) {\n for (var i, s = 0, n = e.length; n > s; s++) \n null !== e[s] && (i = t.data(c + e[s]) , t.css(e[s], i));\n}, \n setMode: function(t, e) {\n return \"toggle\" === e && (e = t.is(\":hidden\") ? \"show\" : \"hide\") , e;\n}, \n createWrapper: function(e) {\n if (e.parent().is(\".ui-effects-wrapper\")) \n return e.parent();\n var i = {\n width: e.outerWidth(!0), \n height: e.outerHeight(!0), \n \"float\": e.css(\"float\")}, s = t(\"\").addClass(\"ui-effects-wrapper\").css({\n fontSize: \"100%\", \n background: \"transparent\", \n border: \"none\", \n margin: 0, \n padding: 0}), n = {\n width: e.width(), \n height: e.height()}, o = document.activeElement;\n try {\n o.id;\n } catch (a) {\n o = document.body;\n}\n return e.wrap(s) , (e[0] === o || t.contains(e[0], o)) && t(o).trigger(\"focus\") , s = e.parent() , \"static\" === e.css(\"position\") ? (s.css({\n position: \"relative\"}) , e.css({\n position: \"relative\"})) : (t.extend(i, {\n position: e.css(\"position\"), \n zIndex: e.css(\"z-index\")}) , t.each([\"top\", \"left\", \"bottom\", \"right\"], function(t, s) {\n i[s] = e.css(s) , isNaN(parseInt(i[s], 10)) && (i[s] = \"auto\");\n}) , e.css({\n position: \"relative\", \n top: 0, \n left: 0, \n right: \"auto\", \n bottom: \"auto\"})) , e.css(n) , s.css(i).show();\n}, \n removeWrapper: function(e) {\n var i = document.activeElement;\n return e.parent().is(\".ui-effects-wrapper\") && (e.parent().replaceWith(e) , (e[0] === i || t.contains(e[0], i)) && t(i).trigger(\"focus\")) , e;\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1051,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1134,
+ "nodeLength": 3,
+ "src": "s > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1142,
+ "nodeLength": 44,
+ "src": "null !== e[i] && t.data(c + e[i], t[0].style[e[i]])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1142,
+ "nodeLength": 11,
+ "src": "null !== e[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1235,
+ "nodeLength": 3,
+ "src": "n > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1243,
+ "nodeLength": 45,
+ "src": "null !== e[s] && (i = t.data(c + e[s]) , t.css(e[s], i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1243,
+ "nodeLength": 11,
+ "src": "null !== e[s]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1318,
+ "nodeLength": 47,
+ "src": "\"toggle\" === e && (e = t.is(\":hidden\") ? \"show\" : \"hide\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1318,
+ "nodeLength": 12,
+ "src": "\"toggle\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1335,
+ "nodeLength": 15,
+ "src": "t.is(\":hidden\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1398,
+ "nodeLength": 36,
+ "src": "e.parent().is(\".ui-effects-wrapper\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1778,
+ "nodeLength": 52,
+ "src": "(e[0] === o || t.contains(e[0], o)) && t(o).trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1778,
+ "nodeLength": 28,
+ "src": "e[0] === o || t.contains(e[0], o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1778,
+ "nodeLength": 8,
+ "src": "e[0] === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1844,
+ "nodeLength": 28,
+ "src": "\"static\" === e.css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2066,
+ "nodeLength": 39,
+ "src": "isNaN(parseInt(i[s], 10)) && (i[s] = \"auto\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2266,
+ "nodeLength": 119,
+ "src": "e.parent().is(\".ui-effects-wrapper\") && (e.parent().replaceWith(e) , (e[0] === i || t.contains(e[0], i)) && t(i).trigger(\"focus\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2332,
+ "nodeLength": 52,
+ "src": "(e[0] === i || t.contains(e[0], i)) && t(i).trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2332,
+ "nodeLength": 28,
+ "src": "e[0] === i || t.contains(e[0], i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2332,
+ "nodeLength": 8,
+ "src": "e[0] === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2458,
+ "nodeLength": 19,
+ "src": "s || (s = i , i = \"effect\")",
+ "evalFalse": 0,
+ "evalTrue": 15
+ },
+ {
+ "position": 2566,
+ "nodeLength": 5,
+ "src": "0 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2630,
+ "nodeLength": 16,
+ "src": "\"horizontal\" !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2648,
+ "nodeLength": 6,
+ "src": "e || 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2664,
+ "nodeLength": 14,
+ "src": "\"vertical\" !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2680,
+ "nodeLength": 6,
+ "src": "e || 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2965,
+ "nodeLength": 50,
+ "src": "e > 1 && s.splice.apply(s, [1, 0].concat(s.splice(e, i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2965,
+ "nodeLength": 3,
+ "src": "e > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3125,
+ "nodeLength": 13,
+ "src": "t.data(u) || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3203,
+ "nodeLength": 33,
+ "src": "\"toggle\" === e && (e = i ? \"show\" : \"hide\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3203,
+ "nodeLength": 12,
+ "src": "\"toggle\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3220,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3238,
+ "nodeLength": 36,
+ "src": "(i ? \"hide\" === e : \"show\" === e) && (e = \"none\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3238,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3240,
+ "nodeLength": 10,
+ "src": "\"hide\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3251,
+ "nodeLength": 10,
+ "src": "\"show\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3807,
+ "nodeLength": 452,
+ "src": "/^(static|relative)/.test(s) && (s = \"absolute\" , i = t(\"<\" + e[0].nodeName + \">\").insertAfter(e).css({\n display: /^(inline|ruby)/.test(e.css(\"display\")) ? \"inline-block\" : \"block\", \n visibility: \"hidden\", \n marginTop: e.css(\"marginTop\"), \n marginBottom: e.css(\"marginBottom\"), \n marginLeft: e.css(\"marginLeft\"), \n marginRight: e.css(\"marginRight\"), \n \"float\": e.css(\"float\")}).outerWidth(e.outerWidth()).outerHeight(e.outerHeight()).addClass(\"ui-effects-placeholder\") , e.data(c + \"placeholder\", i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3906,
+ "nodeLength": 39,
+ "src": "/^(inline|ruby)/.test(e.css(\"display\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4369,
+ "nodeLength": 31,
+ "src": "i && (i.remove() , t.removeData(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4521,
+ "nodeLength": 5,
+ "src": "n || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4569,
+ "nodeLength": 26,
+ "src": "o[0] > 0 && (n[i] = o[0] * s + o[1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4569,
+ "nodeLength": 6,
+ "src": "o[0] > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4698,
+ "nodeLength": 25,
+ "src": "\"hide\" === s.mode && r.hide()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4698,
+ "nodeLength": 15,
+ "src": "\"hide\" === s.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4741,
+ "nodeLength": 29,
+ "src": "t.isFunction(h) && h.call(r[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4771,
+ "nodeLength": 20,
+ "src": "t.isFunction(e) && e()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4823,
+ "nodeLength": 22,
+ "src": "t.uiBackCompat === !1 || o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4823,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4846,
+ "nodeLength": 15,
+ "src": "\"none\" === s.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4893,
+ "nodeLength": 38,
+ "src": "(r.is(\":hidden\") ? \"hide\" === l : \"show\" === l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4893,
+ "nodeLength": 15,
+ "src": "r.is(\":hidden\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4909,
+ "nodeLength": 10,
+ "src": "\"hide\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4920,
+ "nodeLength": 10,
+ "src": "\"show\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5042,
+ "nodeLength": 7,
+ "src": "a || \"fx\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5107,
+ "nodeLength": 22,
+ "src": "t.effects.mode(i, l) || o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5153,
+ "nodeLength": 44,
+ "src": "o && (\"show\" === s || s === o && \"hide\" === s) && i.show()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5157,
+ "nodeLength": 40,
+ "src": "(\"show\" === s || s === o && \"hide\" === s) && i.show()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5157,
+ "nodeLength": 29,
+ "src": "\"show\" === s || s === o && \"hide\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5157,
+ "nodeLength": 10,
+ "src": "\"show\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5169,
+ "nodeLength": 17,
+ "src": "s === o && \"hide\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5169,
+ "nodeLength": 5,
+ "src": "s === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5176,
+ "nodeLength": 10,
+ "src": "\"hide\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5198,
+ "nodeLength": 37,
+ "src": "o && \"none\" === s || t.effects.saveStyle(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5198,
+ "nodeLength": 13,
+ "src": "o && \"none\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5201,
+ "nodeLength": 10,
+ "src": "\"none\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5236,
+ "nodeLength": 20,
+ "src": "t.isFunction(e) && e()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5265,
+ "nodeLength": 12,
+ "src": "t.fx.off || !n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5278,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5323,
+ "nodeLength": 15,
+ "src": "h && h.call(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5341,
+ "nodeLength": 6,
+ "src": "a === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 4,
+ "src": "i(s)",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "7": [
+ null,
+ {
+ "position": 985,
+ "nodeLength": 4,
+ "src": "i(s)",
+ "evalFalse": 0,
+ "evalTrue": 4
+ },
+ {
+ "position": 1151,
+ "nodeLength": 25,
+ "src": "i(s) || \"boolean\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1157,
+ "nodeLength": 19,
+ "src": "\"boolean\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1393,
+ "nodeLength": 37,
+ "src": "i.indexOf(e) > 0 && (s = [parseFloat(i), e])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1393,
+ "nodeLength": 14,
+ "src": "i.indexOf(e) > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1463,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1619,
+ "nodeLength": 27,
+ "src": "\"fixed\" === n.css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1661,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1681,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1970,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2045,
+ "nodeLength": 20,
+ "src": "t.isFunction(i) && i()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5822,
+ "nodeLength": 103,
+ "src": "e.clipInit || (e.start = t(e.elem).cssClip() , \"string\" == typeof e.end && (e.end = s(e.end, e.elem)) , e.clipInit = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5863,
+ "nodeLength": 47,
+ "src": "\"string\" == typeof e.end && (e.end = s(e.end, e.elem))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5863,
+ "nodeLength": 22,
+ "src": "\"string\" == typeof e.end",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16085,
+ "nodeLength": 12,
+ "src": "0 === t || 1 === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16085,
+ "nodeLength": 5,
+ "src": "0 === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16092,
+ "nodeLength": 5,
+ "src": "1 === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16230,
+ "nodeLength": 26,
+ "src": "((e = Math.pow(2, --i)) - 1) / 11 > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16458,
+ "nodeLength": 4,
+ "src": ".5 > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34297,
+ "nodeLength": 17,
+ "src": "e.direction || \"up\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34418,
+ "nodeLength": 78,
+ "src": "\"show\" === e.mode && (n.cssClip(r.clip) , h && h.css(t.effects.clipToBox(r)) , r.clip = a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34418,
+ "nodeLength": 15,
+ "src": "\"show\" === e.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34454,
+ "nodeLength": 32,
+ "src": "h && h.css(t.effects.clipToBox(r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34497,
+ "nodeLength": 56,
+ "src": "h && h.animate(t.effects.clipToBox(r), e.duration, e.easing)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34698,
+ "nodeLength": 10,
+ "src": "\"hide\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34711,
+ "nodeLength": 10,
+ "src": "\"show\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34724,
+ "nodeLength": 17,
+ "src": "e.direction || \"up\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34757,
+ "nodeLength": 10,
+ "src": "e.times || 5",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34775,
+ "nodeLength": 4,
+ "src": "l || h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34813,
+ "nodeLength": 20,
+ "src": "\"up\" === c || \"down\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34813,
+ "nodeLength": 8,
+ "src": "\"up\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34823,
+ "nodeLength": 10,
+ "src": "\"down\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34849,
+ "nodeLength": 20,
+ "src": "\"up\" === c || \"left\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34849,
+ "nodeLength": 8,
+ "src": "\"up\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34859,
+ "nodeLength": 10,
+ "src": "\"left\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34939,
+ "nodeLength": 50,
+ "src": "u || (u = a[\"top\" === m ? \"outerHeight\" : \"outerWidth\"]() / 3)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34947,
+ "nodeLength": 9,
+ "src": "\"top\" === m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34990,
+ "nodeLength": 77,
+ "src": "l && (n = {\n opacity: 1} , n[m] = o , a.css(\"opacity\", 0).css(m, _ ? 2 * -u : 2 * u).animate(n, f, g))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35040,
+ "nodeLength": 1,
+ "src": "_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35068,
+ "nodeLength": 23,
+ "src": "h && (u /= Math.pow(2, d - 1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35104,
+ "nodeLength": 3,
+ "src": "d > v",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35123,
+ "nodeLength": 1,
+ "src": "_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35172,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35182,
+ "nodeLength": 56,
+ "src": "h && (s = {\n opacity: 0} , s[m] = (_ ? \"-=\" : \"+=\") + u , a.animate(s, f, g))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35206,
+ "nodeLength": 1,
+ "src": "_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35347,
+ "nodeLength": 23,
+ "src": "e.direction || \"vertical\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35373,
+ "nodeLength": 10,
+ "src": "\"both\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35386,
+ "nodeLength": 19,
+ "src": "r || \"horizontal\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35389,
+ "nodeLength": 16,
+ "src": "\"horizontal\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35408,
+ "nodeLength": 17,
+ "src": "r || \"vertical\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35411,
+ "nodeLength": 14,
+ "src": "\"vertical\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35452,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35485,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35521,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35556,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35616,
+ "nodeLength": 45,
+ "src": "\"show\" === e.mode && (o.cssClip(n.clip) , n.clip = s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35616,
+ "nodeLength": 15,
+ "src": "\"show\" === e.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35807,
+ "nodeLength": 10,
+ "src": "\"show\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35820,
+ "nodeLength": 19,
+ "src": "e.direction || \"left\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35842,
+ "nodeLength": 20,
+ "src": "\"up\" === r || \"down\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35842,
+ "nodeLength": 8,
+ "src": "\"up\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35852,
+ "nodeLength": 10,
+ "src": "\"down\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35878,
+ "nodeLength": 20,
+ "src": "\"up\" === r || \"left\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35878,
+ "nodeLength": 8,
+ "src": "\"up\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35888,
+ "nodeLength": 10,
+ "src": "\"left\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35911,
+ "nodeLength": 8,
+ "src": "\"+=\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35977,
+ "nodeLength": 57,
+ "src": "e.distance || n[\"top\" === h ? \"outerHeight\" : \"outerWidth\"](!0) / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35991,
+ "nodeLength": 9,
+ "src": "\"top\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36044,
+ "nodeLength": 34,
+ "src": "a && (n.css(u) , u[h] = c + s , u.opacity = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36226,
+ "nodeLength": 19,
+ "src": "b.length === u * d && n()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36226,
+ "nodeLength": 14,
+ "src": "b.length === u * d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36325,
+ "nodeLength": 8,
+ "src": "e.pieces",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36393,
+ "nodeLength": 10,
+ "src": "\"show\" === f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36525,
+ "nodeLength": 3,
+ "src": "u > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36565,
+ "nodeLength": 3,
+ "src": "d > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36820,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36836,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36853,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36878,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36894,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36911,
+ "nodeLength": 1,
+ "src": "g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36918,
+ "nodeLength": 15,
+ "src": "e.duration || 500",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37001,
+ "nodeLength": 15,
+ "src": "\"show\" === e.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37039,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37063,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37201,
+ "nodeLength": 10,
+ "src": "\"show\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37214,
+ "nodeLength": 10,
+ "src": "\"hide\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37227,
+ "nodeLength": 10,
+ "src": "e.size || 15",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37279,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37468,
+ "nodeLength": 37,
+ "src": "h && (r = parseInt(h[1], 10) / 100 * m[a ? 0 : 1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37498,
+ "nodeLength": 1,
+ "src": "a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37551,
+ "nodeLength": 64,
+ "src": "o && (s.cssClip(g.clip) , d && d.css(t.effects.clipToBox(g)) , g.clip = p)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37573,
+ "nodeLength": 32,
+ "src": "d && d.css(t.effects.clipToBox(g))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37636,
+ "nodeLength": 90,
+ "src": "d && d.animate(t.effects.clipToBox(f), u, e.easing).animate(t.effects.clipToBox(g), u, e.easing)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37922,
+ "nodeLength": 30,
+ "src": "\"hide\" === e.mode && (n.opacity = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37922,
+ "nodeLength": 15,
+ "src": "\"hide\" === e.mode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38022,
+ "nodeLength": 18,
+ "src": "e.color || \"#ffff99\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38338,
+ "nodeLength": 12,
+ "src": "\"effect\" !== c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38353,
+ "nodeLength": 15,
+ "src": "e.scale || \"both\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38371,
+ "nodeLength": 29,
+ "src": "e.origin || [\"middle\", \"center\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38470,
+ "nodeLength": 9,
+ "src": "e.from || m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38482,
+ "nodeLength": 37,
+ "src": "e.to || t.effects.scaledDimensions(a, 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38551,
+ "nodeLength": 25,
+ "src": "\"show\" === c && (o = _ , _ = v , v = o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38551,
+ "nodeLength": 10,
+ "src": "\"show\" === c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38670,
+ "nodeLength": 231,
+ "src": "(\"box\" === d || \"both\" === d) && (n.from.y !== n.to.y && (_ = t.effects.setTransition(a, h, n.from.y, _) , v = t.effects.setTransition(a, h, n.to.y, v)) , n.from.x !== n.to.x && (_ = t.effects.setTransition(a, l, n.from.x, _) , v = t.effects.setTransition(a, l, n.to.x, v)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38670,
+ "nodeLength": 21,
+ "src": "\"box\" === d || \"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38670,
+ "nodeLength": 9,
+ "src": "\"box\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38681,
+ "nodeLength": 10,
+ "src": "\"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38695,
+ "nodeLength": 102,
+ "src": "n.from.y !== n.to.y && (_ = t.effects.setTransition(a, h, n.from.y, _) , v = t.effects.setTransition(a, h, n.to.y, v))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38695,
+ "nodeLength": 17,
+ "src": "n.from.y !== n.to.y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38798,
+ "nodeLength": 102,
+ "src": "n.from.x !== n.to.x && (_ = t.effects.setTransition(a, l, n.from.x, _) , v = t.effects.setTransition(a, l, n.to.x, v))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38798,
+ "nodeLength": 17,
+ "src": "n.from.x !== n.to.x",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38903,
+ "nodeLength": 130,
+ "src": "(\"content\" === d || \"both\" === d) && n.from.y !== n.to.y && (_ = t.effects.setTransition(a, r, n.from.y, _) , v = t.effects.setTransition(a, r, n.to.y, v))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38903,
+ "nodeLength": 25,
+ "src": "\"content\" === d || \"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38903,
+ "nodeLength": 13,
+ "src": "\"content\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38918,
+ "nodeLength": 10,
+ "src": "\"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38931,
+ "nodeLength": 102,
+ "src": "n.from.y !== n.to.y && (_ = t.effects.setTransition(a, r, n.from.y, _) , v = t.effects.setTransition(a, r, n.to.y, v))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38931,
+ "nodeLength": 17,
+ "src": "n.from.y !== n.to.y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39034,
+ "nodeLength": 217,
+ "src": "p && (s = t.effects.getBaseline(p, m) , _.top = (m.outerHeight - _.outerHeight) * s.y + g.top , _.left = (m.outerWidth - _.outerWidth) * s.x + g.left , v.top = (m.outerHeight - v.outerHeight) * s.y + g.top , v.left = (m.outerWidth - v.outerWidth) * s.x + g.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39262,
+ "nodeLength": 741,
+ "src": "(\"content\" === d || \"both\" === d) && (h = h.concat([\"marginTop\", \"marginBottom\"]).concat(r) , l = l.concat([\"marginLeft\", \"marginRight\"]) , a.find(\"*[width]\").each(function() {\n var i = t(this), s = t.effects.scaledDimensions(i), o = {\n height: s.height * n.from.y, \n width: s.width * n.from.x, \n outerHeight: s.outerHeight * n.from.y, \n outerWidth: s.outerWidth * n.from.x}, a = {\n height: s.height * n.to.y, \n width: s.width * n.to.x, \n outerHeight: s.height * n.to.y, \n outerWidth: s.width * n.to.x};\n n.from.y !== n.to.y && (o = t.effects.setTransition(i, h, n.from.y, o) , a = t.effects.setTransition(i, h, n.to.y, a)) , n.from.x !== n.to.x && (o = t.effects.setTransition(i, l, n.from.x, o) , a = t.effects.setTransition(i, l, n.to.x, a)) , u && t.effects.saveStyle(i) , i.css(o) , i.animate(a, e.duration, e.easing, function() {\n u && t.effects.restoreStyle(i);\n});\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39262,
+ "nodeLength": 25,
+ "src": "\"content\" === d || \"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39262,
+ "nodeLength": 13,
+ "src": "\"content\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39277,
+ "nodeLength": 10,
+ "src": "\"both\" === d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39686,
+ "nodeLength": 102,
+ "src": "n.from.y !== n.to.y && (o = t.effects.setTransition(i, h, n.from.y, o) , a = t.effects.setTransition(i, h, n.to.y, a))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39686,
+ "nodeLength": 17,
+ "src": "n.from.y !== n.to.y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39789,
+ "nodeLength": 102,
+ "src": "n.from.x !== n.to.x && (o = t.effects.setTransition(i, l, n.from.x, o) , a = t.effects.setTransition(i, l, n.to.x, a))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39789,
+ "nodeLength": 17,
+ "src": "n.from.x !== n.to.x",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39892,
+ "nodeLength": 25,
+ "src": "u && t.effects.saveStyle(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39970,
+ "nodeLength": 28,
+ "src": "u && t.effects.restoreStyle(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40099,
+ "nodeLength": 41,
+ "src": "0 === v.opacity && a.css(\"opacity\", _.opacity)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40099,
+ "nodeLength": 13,
+ "src": "0 === v.opacity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40141,
+ "nodeLength": 81,
+ "src": "u || (a.css(\"position\", \"static\" === f ? \"relative\" : f).offset(e) , t.effects.saveStyle(a))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40162,
+ "nodeLength": 12,
+ "src": "\"static\" === f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40296,
+ "nodeLength": 73,
+ "src": "parseInt(e.percent, 10) || (0 === parseInt(e.percent, 10) ? 0 : \"effect\" !== n ? 0 : 100)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40321,
+ "nodeLength": 26,
+ "src": "0 === parseInt(e.percent, 10)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40350,
+ "nodeLength": 12,
+ "src": "\"effect\" !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40454,
+ "nodeLength": 19,
+ "src": "e.direction || \"both\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40482,
+ "nodeLength": 29,
+ "src": "e.origin || [\"middle\", \"center\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40516,
+ "nodeLength": 41,
+ "src": "e.fade && (a.from.opacity = 1 , a.to.opacity = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40682,
+ "nodeLength": 27,
+ "src": "parseInt(e.percent, 10) || 150",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40825,
+ "nodeLength": 10,
+ "src": "\"show\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40838,
+ "nodeLength": 10,
+ "src": "\"hide\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40851,
+ "nodeLength": 4,
+ "src": "o || a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40861,
+ "nodeLength": 10,
+ "src": "e.times || 5",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40874,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40928,
+ "nodeLength": 54,
+ "src": "(o || !s.is(\":visible\")) && (s.css(\"opacity\", 0).show() , c = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40928,
+ "nodeLength": 20,
+ "src": "o || !s.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40798,
+ "nodeLength": 1,
+ "src": "visit2889_7_146((visit2890_7_147(o || !s.is(\":visible\"))) && (s.css(\"opacity\", 0).show() , c = 1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40928,
+ "nodeLength": 54,
+ "src": "(visit2890_7_147(o || !s.is(\":visible\"))) && (s.css(\"opacity\", 0).show() , c = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 130,
+ "nodeLength": 20,
+ "src": "o || !s.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41164,
+ "nodeLength": 19,
+ "src": "e.direction || \"left\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41186,
+ "nodeLength": 14,
+ "src": "e.distance || 20",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41203,
+ "nodeLength": 10,
+ "src": "e.times || 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41251,
+ "nodeLength": 20,
+ "src": "\"up\" === o || \"down\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41251,
+ "nodeLength": 8,
+ "src": "\"up\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41261,
+ "nodeLength": 10,
+ "src": "\"down\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41287,
+ "nodeLength": 20,
+ "src": "\"up\" === o || \"left\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41287,
+ "nodeLength": 8,
+ "src": "\"up\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41297,
+ "nodeLength": 10,
+ "src": "\"left\" === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41383,
+ "nodeLength": 1,
+ "src": "u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41404,
+ "nodeLength": 1,
+ "src": "u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41427,
+ "nodeLength": 1,
+ "src": "u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41468,
+ "nodeLength": 3,
+ "src": "r > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41774,
+ "nodeLength": 19,
+ "src": "e.direction || \"left\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41796,
+ "nodeLength": 20,
+ "src": "\"up\" === h || \"down\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41796,
+ "nodeLength": 8,
+ "src": "\"up\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41806,
+ "nodeLength": 10,
+ "src": "\"down\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41832,
+ "nodeLength": 20,
+ "src": "\"up\" === h || \"left\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41832,
+ "nodeLength": 8,
+ "src": "\"up\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41842,
+ "nodeLength": 10,
+ "src": "\"left\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41855,
+ "nodeLength": 55,
+ "src": "e.distance || o[\"top\" === l ? \"outerHeight\" : \"outerWidth\"](!0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41869,
+ "nodeLength": 9,
+ "src": "\"top\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41985,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42048,
+ "nodeLength": 61,
+ "src": "\"show\" === r && (o.cssClip(d.clip) , o.css(l, d[l]) , d.clip = s , d[l] = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42048,
+ "nodeLength": 10,
+ "src": "\"show\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42189,
+ "nodeLength": 90,
+ "src": "t.uiBackCompat !== !1 && (f = t.effects.define(\"transfer\", function(e, i) {\n t(this).transfer(e, i);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 42189,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 42356,
+ "nodeLength": 10,
+ "src": "\"area\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42392,
+ "nodeLength": 43,
+ "src": "i.href && o && \"map\" === n.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42400,
+ "nodeLength": 35,
+ "src": "o && \"map\" === n.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42403,
+ "nodeLength": 32,
+ "src": "\"map\" === n.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42465,
+ "nodeLength": 28,
+ "src": "a.length > 0 && a.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42465,
+ "nodeLength": 10,
+ "src": "a.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42500,
+ "nodeLength": 49,
+ "src": "/^(input|select|textarea|button|object)$/.test(l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42565,
+ "nodeLength": 53,
+ "src": "r && (h = t(i).closest(\"fieldset\")[0] , h && (r = !h.disabled))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42599,
+ "nodeLength": 18,
+ "src": "h && (r = !h.disabled)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42622,
+ "nodeLength": 7,
+ "src": "\"a\" === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42630,
+ "nodeLength": 9,
+ "src": "i.href || s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42642,
+ "nodeLength": 31,
+ "src": "r && t(i).is(\":visible\") && e(t(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42645,
+ "nodeLength": 28,
+ "src": "t(i).is(\":visible\") && e(t(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42744,
+ "nodeLength": 26,
+ "src": "null != t.attr(e, \"tabindex\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42817,
+ "nodeLength": 29,
+ "src": "\"string\" == typeof this[0].form",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43087,
+ "nodeLength": 46,
+ "src": "this.form = this.element.form() , this.form.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43141,
+ "nodeLength": 45,
+ "src": "this.form.data(\"ui-form-reset-instances\") || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43187,
+ "nodeLength": 68,
+ "src": "t.length || this.form.on(\"reset.ui-form-reset\", this._formResetHandler)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43353,
+ "nodeLength": 16,
+ "src": "this.form.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43449,
+ "nodeLength": 8,
+ "src": "e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43580,
+ "nodeLength": 815,
+ "src": "\"1.7\" === t.fn.jquery.substring(0, 3) && (t.each([\"Width\", \"Height\"], function(e, i) {\n function s(e, i, s, o) {\n return t.each(n, function() {\n i -= parseFloat(t.css(e, \"padding\" + this)) || 0 , s && (i -= parseFloat(t.css(e, \"border\" + this + \"Width\")) || 0) , o && (i -= parseFloat(t.css(e, \"margin\" + this)) || 0);\n}) , i;\n }\n var n = \"Width\" === i ? [\"Left\", \"Right\"] : [\"Top\", \"Bottom\"], o = i.toLowerCase(), a = {\n innerWidth: t.fn.innerWidth, \n innerHeight: t.fn.innerHeight, \n outerWidth: t.fn.outerWidth, \n outerHeight: t.fn.outerHeight};\n t.fn[\"inner\" + i] = function(e) {\n return void 0 === e ? a[\"inner\" + i].call(this) : this.each(function() {\n t(this).css(o, s(this, e) + \"px\");\n});\n} , t.fn[\"outer\" + i] = function(e, n) {\n return \"number\" != typeof e ? a[\"outer\" + i].call(this, e) : this.each(function() {\n t(this).css(o, s(this, e, !0, n) + \"px\");\n});\n};\n}) , t.fn.addBack = function(t) {\n return this.add(null == t ? this.prevObject : this.prevObject.filter(t));\n})",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 43580,
+ "nodeLength": 34,
+ "src": "\"1.7\" === t.fn.jquery.substring(0, 3)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 43707,
+ "nodeLength": 38,
+ "src": "parseFloat(t.css(e, \"padding\" + this)) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43746,
+ "nodeLength": 53,
+ "src": "s && (i -= parseFloat(t.css(e, \"border\" + this + \"Width\")) || 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43753,
+ "nodeLength": 45,
+ "src": "parseFloat(t.css(e, \"border\" + this + \"Width\")) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43800,
+ "nodeLength": 45,
+ "src": "o && (i -= parseFloat(t.css(e, \"margin\" + this)) || 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43807,
+ "nodeLength": 37,
+ "src": "parseFloat(t.css(e, \"margin\" + this)) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43856,
+ "nodeLength": 11,
+ "src": "\"Width\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44071,
+ "nodeLength": 10,
+ "src": "void 0 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44196,
+ "nodeLength": 18,
+ "src": "\"number\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44343,
+ "nodeLength": 7,
+ "src": "null == t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44728,
+ "nodeLength": 37,
+ "src": "this[0].labels && this[0].labels.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44846,
+ "nodeLength": 155,
+ "src": "s && (e = this.eq(0).parents().last() , o = e.add(e.length ? e.siblings() : this.siblings()) , i = \"label[for='\" + t.ui.escapeSelector(s) + \"']\" , n = n.add(o.find(i).addBack(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44888,
+ "nodeLength": 8,
+ "src": "e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45081,
+ "nodeLength": 14,
+ "src": "\"absolute\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45098,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45195,
+ "nodeLength": 31,
+ "src": "s && \"static\" === e.css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45198,
+ "nodeLength": 28,
+ "src": "\"static\" === e.css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45310,
+ "nodeLength": 21,
+ "src": "\"fixed\" !== i && o.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45310,
+ "nodeLength": 11,
+ "src": "\"fixed\" !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45336,
+ "nodeLength": 31,
+ "src": "this[0].ownerDocument || document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45442,
+ "nodeLength": 7,
+ "src": "null != i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45457,
+ "nodeLength": 30,
+ "src": "(!s || i >= 0) && t.ui.focusable(e, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45457,
+ "nodeLength": 8,
+ "src": "!s || i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45461,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45578,
+ "nodeLength": 32,
+ "src": "this.id || (this.id = \"ui-id-\" + ++t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45671,
+ "nodeLength": 53,
+ "src": "/^ui-id-\\d+$/.test(this.id) && t(this).removeAttr(\"id\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46531,
+ "nodeLength": 58,
+ "src": "e.collapsible || e.active !== !1 && null != e.active || (e.active = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46546,
+ "nodeLength": 43,
+ "src": "e.active !== !1 && null != e.active || (e.active = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46546,
+ "nodeLength": 29,
+ "src": "e.active !== !1 && null != e.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46546,
+ "nodeLength": 13,
+ "src": "e.active !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46561,
+ "nodeLength": 14,
+ "src": "null != e.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46612,
+ "nodeLength": 43,
+ "src": "0 > e.active && (e.active += this.headers.length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46612,
+ "nodeLength": 10,
+ "src": "0 > e.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46736,
+ "nodeLength": 18,
+ "src": "this.active.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46833,
+ "nodeLength": 269,
+ "src": "s && (e = t(\"\") , this._addClass(e, \"ui-accordion-header-icon\", \"ui-icon \" + s.header) , e.prependTo(this.headers) , i = this.active.children(\".ui-accordion-header-icon\") , this._removeClass(i, s.header)._addClass(i, null, s.activeHeader)._addClass(this.headers, \"ui-accordion-icons\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47526,
+ "nodeLength": 56,
+ "src": "\"content\" !== this.options.heightStyle && t.css(\"height\", \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47526,
+ "nodeLength": 36,
+ "src": "\"content\" !== this.options.heightStyle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47615,
+ "nodeLength": 12,
+ "src": "\"active\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47656,
+ "nodeLength": 98,
+ "src": "\"event\" === t && (this.options.event && this._off(this.headers, this.options.event) , this._setupEvents(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47656,
+ "nodeLength": 11,
+ "src": "\"event\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47670,
+ "nodeLength": 62,
+ "src": "this.options.event && this._off(this.headers, this.options.event)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47772,
+ "nodeLength": 65,
+ "src": "\"collapsible\" !== t || e || this.options.active !== !1 || this._activate(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47772,
+ "nodeLength": 17,
+ "src": "\"collapsible\" !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47791,
+ "nodeLength": 46,
+ "src": "e || this.options.active !== !1 || this._activate(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47794,
+ "nodeLength": 43,
+ "src": "this.options.active !== !1 || this._activate(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47794,
+ "nodeLength": 24,
+ "src": "this.options.active !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47838,
+ "nodeLength": 58,
+ "src": "\"icons\" === t && (this._destroyIcons() , e && this._createIcons())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47838,
+ "nodeLength": 11,
+ "src": "\"icons\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47873,
+ "nodeLength": 22,
+ "src": "e && this._createIcons()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48148,
+ "nodeLength": 21,
+ "src": "!e.altKey && !e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48496,
+ "nodeLength": 101,
+ "src": "o && (t(e.target).attr(\"tabIndex\", -1) , t(o).attr(\"tabIndex\", 0) , t(o).trigger(\"focus\") , e.preventDefault())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48626,
+ "nodeLength": 82,
+ "src": "e.keyCode === t.ui.keyCode.UP && e.ctrlKey && t(e.currentTarget).prev().trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48626,
+ "nodeLength": 27,
+ "src": "e.keyCode === t.ui.keyCode.UP",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48655,
+ "nodeLength": 53,
+ "src": "e.ctrlKey && t(e.currentTarget).prev().trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48770,
+ "nodeLength": 55,
+ "src": "e.active === !1 && e.collapsible === !0 || !this.headers.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48770,
+ "nodeLength": 33,
+ "src": "e.active === !1 && e.collapsible === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48770,
+ "nodeLength": 13,
+ "src": "e.active === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48785,
+ "nodeLength": 18,
+ "src": "e.collapsible === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48856,
+ "nodeLength": 13,
+ "src": "e.active === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48888,
+ "nodeLength": 63,
+ "src": "this.active.length && !t.contains(this.element[0], this.active[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48952,
+ "nodeLength": 68,
+ "src": "this.headers.length === this.headers.find(\".ui-state-disabled\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49551,
+ "nodeLength": 65,
+ "src": "e && (this._off(t.not(this.headers)) , this._off(e.not(this.panels)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50315,
+ "nodeLength": 18,
+ "src": "this.active.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50533,
+ "nodeLength": 10,
+ "src": "\"fill\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50642,
+ "nodeLength": 51,
+ "src": "\"absolute\" !== s && \"fixed\" !== s && (e -= i.outerHeight(!0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50642,
+ "nodeLength": 14,
+ "src": "\"absolute\" !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50658,
+ "nodeLength": 35,
+ "src": "\"fixed\" !== s && (e -= i.outerHeight(!0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50658,
+ "nodeLength": 11,
+ "src": "\"fixed\" !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50885,
+ "nodeLength": 178,
+ "src": "\"auto\" === s && (e = 0 , this.headers.next().each(function() {\n var i = t(this).is(\":visible\");\n i || t(this).show() , e = Math.max(e, t(this).css(\"height\", \"\").height()) , i || t(this).hide();\n}).height(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50885,
+ "nodeLength": 10,
+ "src": "\"auto\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50967,
+ "nodeLength": 17,
+ "src": "i || t(this).show()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51033,
+ "nodeLength": 17,
+ "src": "i || t(this).hide()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51116,
+ "nodeLength": 110,
+ "src": "i !== this.active[0] && (i = i || this.active[0] , this._eventHandler({\n target: i, \n currentTarget: i, \n preventDefault: t.noop}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51116,
+ "nodeLength": 18,
+ "src": "i !== this.active[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51139,
+ "nodeLength": 17,
+ "src": "i || this.active[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51258,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51353,
+ "nodeLength": 59,
+ "src": "e && t.each(e.split(\" \"), function(t, e) {\n i[e] = \"_eventHandler\";\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51690,
+ "nodeLength": 11,
+ "src": "a[0] === o[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51704,
+ "nodeLength": 16,
+ "src": "r && n.collapsible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51723,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51785,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51824,
+ "nodeLength": 641,
+ "src": "r && !n.collapsible || this._trigger(\"beforeActivate\", e, u) === !1 || (n.active = h ? !1 : this.headers.index(a) , this.active = r ? t() : a , this._toggle(u) , this._removeClass(o, \"ui-accordion-header-active\", \"ui-state-active\") , n.icons && (i = o.children(\".ui-accordion-header-icon\") , this._removeClass(i, null, n.icons.activeHeader)._addClass(i, null, n.icons.header)) , r || (this._removeClass(a, \"ui-accordion-header-collapsed\")._addClass(a, \"ui-accordion-header-active\", \"ui-state-active\") , n.icons && (s = a.children(\".ui-accordion-header-icon\") , this._removeClass(s, null, n.icons.header)._addClass(s, null, n.icons.activeHeader)) , this._addClass(a.next(), \"ui-accordion-content-active\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51824,
+ "nodeLength": 17,
+ "src": "r && !n.collapsible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51843,
+ "nodeLength": 622,
+ "src": "this._trigger(\"beforeActivate\", e, u) === !1 || (n.active = h ? !1 : this.headers.index(a) , this.active = r ? t() : a , this._toggle(u) , this._removeClass(o, \"ui-accordion-header-active\", \"ui-state-active\") , n.icons && (i = o.children(\".ui-accordion-header-icon\") , this._removeClass(i, null, n.icons.activeHeader)._addClass(i, null, n.icons.header)) , r || (this._removeClass(a, \"ui-accordion-header-collapsed\")._addClass(a, \"ui-accordion-header-active\", \"ui-state-active\") , n.icons && (s = a.children(\".ui-accordion-header-icon\") , this._removeClass(s, null, n.icons.header)._addClass(s, null, n.icons.activeHeader)) , this._addClass(a.next(), \"ui-accordion-content-active\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51843,
+ "nodeLength": 40,
+ "src": "this._trigger(\"beforeActivate\", e, u) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51895,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51934,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52026,
+ "nodeLength": 132,
+ "src": "n.icons && (i = o.children(\".ui-accordion-header-icon\") , this._removeClass(i, null, n.icons.activeHeader)._addClass(i, null, n.icons.header))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52159,
+ "nodeLength": 305,
+ "src": "r || (this._removeClass(a, \"ui-accordion-header-collapsed\")._addClass(a, \"ui-accordion-header-active\", \"ui-state-active\") , n.icons && (s = a.children(\".ui-accordion-header-icon\") , this._removeClass(s, null, n.icons.header)._addClass(s, null, n.icons.activeHeader)) , this._addClass(a.next(), \"ui-accordion-content-active\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52276,
+ "nodeLength": 132,
+ "src": "n.icons && (s = a.children(\".ui-accordion-header-icon\") , this._removeClass(s, null, n.icons.header)._addClass(s, null, n.icons.activeHeader))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52506,
+ "nodeLength": 20,
+ "src": "this.prevShow.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52629,
+ "nodeLength": 20,
+ "src": "this.options.animate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52811,
+ "nodeLength": 18,
+ "src": "i.length && s.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52883,
+ "nodeLength": 111,
+ "src": "i.length && this.headers.filter(function() {\n return 0 === parseInt(t(this).attr(\"tabIndex\"), 10);\n}).attr(\"tabIndex\", -1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52931,
+ "nodeLength": 41,
+ "src": "0 === parseInt(t(this).attr(\"tabIndex\"), 10)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53168,
+ "nodeLength": 42,
+ "src": "t.length && (!e.length || t.index() < e.index())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53179,
+ "nodeLength": 30,
+ "src": "!e.length || t.index() < e.index()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53190,
+ "nodeLength": 19,
+ "src": "t.index() < e.index()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53213,
+ "nodeLength": 24,
+ "src": "this.options.animate || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53240,
+ "nodeLength": 12,
+ "src": "l && c.down || c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53240,
+ "nodeLength": 9,
+ "src": "l && c.down",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53294,
+ "nodeLength": 25,
+ "src": "\"number\" == typeof u && (o = u)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53294,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53320,
+ "nodeLength": 25,
+ "src": "\"string\" == typeof u && (n = u)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53320,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53348,
+ "nodeLength": 21,
+ "src": "n || u.easing || c.easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53351,
+ "nodeLength": 18,
+ "src": "u.easing || c.easing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53372,
+ "nodeLength": 25,
+ "src": "o || u.duration || c.duration",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53375,
+ "nodeLength": 22,
+ "src": "u.duration || c.duration",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53398,
+ "nodeLength": 8,
+ "src": "e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53407,
+ "nodeLength": 8,
+ "src": "t.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53633,
+ "nodeLength": 17,
+ "src": "\"height\" !== i.prop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53651,
+ "nodeLength": 29,
+ "src": "\"content-box\" === h && (r += i.now)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53651,
+ "nodeLength": 17,
+ "src": "\"content-box\" === h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53681,
+ "nodeLength": 78,
+ "src": "\"content\" !== a.options.heightStyle && (i.now = Math.round(s - e.outerHeight() - r) , r = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53681,
+ "nodeLength": 33,
+ "src": "\"content\" !== a.options.heightStyle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54038,
+ "nodeLength": 59,
+ "src": "e.length && (e.parent()[0].className = e.parent()[0].className)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54222,
+ "nodeLength": 13,
+ "src": "e || (e = t.body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54236,
+ "nodeLength": 22,
+ "src": "e.nodeName || (e = t.body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54842,
+ "nodeLength": 338,
+ "src": "!this.mouseHandled && i.not(\".ui-state-disabled\").length && (this.select(e) , e.isPropagationStopped() || (this.mouseHandled = !0) , i.has(\".ui-menu\").length ? this.expand(e) : !this.element.is(\":focus\") && s.closest(\".ui-menu\").length && (this.element.trigger(\"focus\", [!0]) , this.active && 1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54862,
+ "nodeLength": 318,
+ "src": "i.not(\".ui-state-disabled\").length && (this.select(e) , e.isPropagationStopped() || (this.mouseHandled = !0) , i.has(\".ui-menu\").length ? this.expand(e) : !this.element.is(\":focus\") && s.closest(\".ui-menu\").length && (this.element.trigger(\"focus\", [!0]) , this.active && 1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54914,
+ "nodeLength": 48,
+ "src": "e.isPropagationStopped() || (this.mouseHandled = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54963,
+ "nodeLength": 24,
+ "src": "i.has(\".ui-menu\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55003,
+ "nodeLength": 176,
+ "src": "!this.element.is(\":focus\") && s.closest(\".ui-menu\").length && (this.element.trigger(\"focus\", [!0]) , this.active && 1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55031,
+ "nodeLength": 148,
+ "src": "s.closest(\".ui-menu\").length && (this.element.trigger(\"focus\", [!0]) , this.active && 1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55097,
+ "nodeLength": 81,
+ "src": "this.active && 1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55110,
+ "nodeLength": 68,
+ "src": "1 === this.active.parents(\".ui-menu\").length && clearTimeout(this.timer)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55110,
+ "nodeLength": 42,
+ "src": "1 === this.active.parents(\".ui-menu\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55224,
+ "nodeLength": 20,
+ "src": "!this.previousFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55310,
+ "nodeLength": 114,
+ "src": "i[0] === s[0] && (this._removeClass(s.siblings().children(\".ui-state-active\"), null, \"ui-state-active\") , this.focus(e, s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55310,
+ "nodeLength": 11,
+ "src": "i[0] === s[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55514,
+ "nodeLength": 56,
+ "src": "this.active || this.element.find(this.options.items).eq(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55571,
+ "nodeLength": 18,
+ "src": "e || this.focus(t, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55707,
+ "nodeLength": 22,
+ "src": "i && this.collapseAll(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55811,
+ "nodeLength": 50,
+ "src": "this._closeOnDocumentClick(t) && this.collapseAll(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56305,
+ "nodeLength": 43,
+ "src": "e.data(\"ui-menu-submenu-caret\") && e.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56783,
+ "nodeLength": 66,
+ "src": "this.active && !this.active.is(\".ui-state-disabled\") && this.expand(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56796,
+ "nodeLength": 53,
+ "src": "!this.active.is(\".ui-state-disabled\") && this.expand(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56991,
+ "nodeLength": 23,
+ "src": "this.previousFilter || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57022,
+ "nodeLength": 29,
+ "src": "e.keyCode >= 96 && 105 >= e.keyCode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57022,
+ "nodeLength": 13,
+ "src": "e.keyCode >= 96",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57037,
+ "nodeLength": 14,
+ "src": "105 >= e.keyCode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57132,
+ "nodeLength": 5,
+ "src": "n === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57178,
+ "nodeLength": 35,
+ "src": "o && -1 !== i.index(this.active.next())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57181,
+ "nodeLength": 32,
+ "src": "-1 !== i.index(this.active.next())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57253,
+ "nodeLength": 71,
+ "src": "i.length || (n = String.fromCharCode(e.keyCode) , i = this._filterMenuItems(n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57325,
+ "nodeLength": 8,
+ "src": "i.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57474,
+ "nodeLength": 21,
+ "src": "a && e.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57519,
+ "nodeLength": 137,
+ "src": "this.active && !this.active.is(\".ui-state-disabled\") && (this.active.children(\"[aria-haspopup='true']\").length ? this.expand(t) : this.select(t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57532,
+ "nodeLength": 124,
+ "src": "!this.active.is(\".ui-state-disabled\") && (this.active.children(\"[aria-haspopup='true']\").length ? this.expand(t) : this.select(t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57572,
+ "nodeLength": 53,
+ "src": "this.active.children(\"[aria-haspopup='true']\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58343,
+ "nodeLength": 69,
+ "src": "a._isDivider(e) && a._addClass(e, \"ui-menu-divider\", \"ui-widget-content\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58671,
+ "nodeLength": 69,
+ "src": "this.active && !t.contains(this.element[0], this.active[0]) && this.blur()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58684,
+ "nodeLength": 56,
+ "src": "!t.contains(this.element[0], this.active[0]) && this.blur()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58852,
+ "nodeLength": 11,
+ "src": "\"icons\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59182,
+ "nodeLength": 19,
+ "src": "t && \"focus\" === t.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15,
+ "nodeLength": 16,
+ "src": "\"focus\" === t.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59338,
+ "nodeLength": 74,
+ "src": "this.options.role && this.element.attr(\"aria-activedescendant\", s.attr(\"id\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59536,
+ "nodeLength": 21,
+ "src": "t && \"keydown\" === t.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59539,
+ "nodeLength": 18,
+ "src": "\"keydown\" === t.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59658,
+ "nodeLength": 57,
+ "src": "i.length && t && /^mouse/.test(t.type) && this._startOpening(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59668,
+ "nodeLength": 47,
+ "src": "t && /^mouse/.test(t.type) && this._startOpening(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59671,
+ "nodeLength": 44,
+ "src": "/^mouse/.test(t.type) && this._startOpening(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59822,
+ "nodeLength": 338,
+ "src": "this._hasScroll() && (i = parseFloat(t.css(this.activeMenu[0], \"borderTopWidth\")) || 0 , s = parseFloat(t.css(this.activeMenu[0], \"paddingTop\")) || 0 , n = e.offset().top - this.activeMenu.offset().top - i - s , o = this.activeMenu.scrollTop() , a = this.activeMenu.height() , r = e.outerHeight() , 0 > n ? this.activeMenu.scrollTop(o + n) : n + r > a && this.activeMenu.scrollTop(o + n - a + r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59844,
+ "nodeLength": 57,
+ "src": "parseFloat(t.css(this.activeMenu[0], \"borderTopWidth\")) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59904,
+ "nodeLength": 53,
+ "src": "parseFloat(t.css(this.activeMenu[0], \"paddingTop\")) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60083,
+ "nodeLength": 3,
+ "src": "0 > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60118,
+ "nodeLength": 41,
+ "src": "n + r > a && this.activeMenu.scrollTop(o + n - a + r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60118,
+ "nodeLength": 5,
+ "src": "n + r > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60181,
+ "nodeLength": 27,
+ "src": "e || clearTimeout(this.timer)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60209,
+ "nodeLength": 162,
+ "src": "this.active && (this._removeClass(this.active.children(\".ui-menu-item-wrapper\"), null, \"ui-state-active\") , this._trigger(\"blur\", t, {\n item: this.active}) , this.active = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60424,
+ "nodeLength": 108,
+ "src": "\"true\" === t.attr(\"aria-hidden\") && (this.timer = this._delay(function() {\n this._close() , this._open(t);\n}, this.delay))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60424,
+ "nodeLength": 30,
+ "src": "\"true\" === t.attr(\"aria-hidden\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60891,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60908,
+ "nodeLength": 11,
+ "src": "e && e.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60960,
+ "nodeLength": 26,
+ "src": "s.length || (s = this.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61135,
+ "nodeLength": 52,
+ "src": "t || (t = this.active ? this.active.parent() : this.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61141,
+ "nodeLength": 11,
+ "src": "this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61448,
+ "nodeLength": 71,
+ "src": "this.active && this.active.parent().closest(\".ui-menu-item\", this.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61520,
+ "nodeLength": 44,
+ "src": "e && e.length && (this._close() , this.focus(t, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61523,
+ "nodeLength": 41,
+ "src": "e.length && (this._close() , this.focus(t, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61591,
+ "nodeLength": 79,
+ "src": "this.active && this.active.children(\".ui-menu \").find(this.options.items).first()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61671,
+ "nodeLength": 78,
+ "src": "e && e.length && (this._open(e.parent()) , this._delay(function() {\n this.focus(t, e);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61674,
+ "nodeLength": 75,
+ "src": "e.length && (this._open(e.parent()) , this._delay(function() {\n this.focus(t, e);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61878,
+ "nodeLength": 57,
+ "src": "this.active && !this.active.prevAll(\".ui-menu-item\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61966,
+ "nodeLength": 57,
+ "src": "this.active && !this.active.nextAll(\".ui-menu-item\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62053,
+ "nodeLength": 153,
+ "src": "this.active && (s = \"first\" === t || \"last\" === t ? this.active[\"first\" === t ? \"prevAll\" : \"nextAll\"](\".ui-menu-item\").eq(-1) : this.active[t + \"All\"](\".ui-menu-item\").eq(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62069,
+ "nodeLength": 23,
+ "src": "\"first\" === t || \"last\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62069,
+ "nodeLength": 11,
+ "src": "\"first\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62082,
+ "nodeLength": 10,
+ "src": "\"last\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62105,
+ "nodeLength": 11,
+ "src": "\"first\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62207,
+ "nodeLength": 75,
+ "src": "s && s.length && this.active || (s = this.activeMenu.find(this.options.items)[e]())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62207,
+ "nodeLength": 24,
+ "src": "s && s.length && this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62210,
+ "nodeLength": 21,
+ "src": "s.length && this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62338,
+ "nodeLength": 11,
+ "src": "this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62351,
+ "nodeLength": 285,
+ "src": "this.isLastItem() || (this._hasScroll() ? (s = this.active.offset().top , n = this.element.height() , this.active.nextAll(\".ui-menu-item\").each(function() {\n return i = t(this) , 0 > i.offset().top - s - n;\n}) , this.focus(e, i)) : this.focus(e, this.activeMenu.find(this.options.items)[this.active ? \"last\" : \"first\"]()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62371,
+ "nodeLength": 17,
+ "src": "this._hasScroll()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62511,
+ "nodeLength": 20,
+ "src": "0 > i.offset().top - s - n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62605,
+ "nodeLength": 11,
+ "src": "this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62710,
+ "nodeLength": 11,
+ "src": "this.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62723,
+ "nodeLength": 264,
+ "src": "this.isFirstItem() || (this._hasScroll() ? (s = this.active.offset().top , n = this.element.height() , this.active.prevAll(\".ui-menu-item\").each(function() {\n return i = t(this) , i.offset().top - s + n > 0;\n}) , this.focus(e, i)) : this.focus(e, this.activeMenu.find(this.options.items).first()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62744,
+ "nodeLength": 17,
+ "src": "this._hasScroll()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62884,
+ "nodeLength": 20,
+ "src": "i.offset().top - s + n > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63048,
+ "nodeLength": 60,
+ "src": "this.element.outerHeight() < this.element.prop(\"scrollHeight\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63141,
+ "nodeLength": 49,
+ "src": "this.active || t(e.target).closest(\".ui-menu-item\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63216,
+ "nodeLength": 58,
+ "src": "this.active.has(\".ui-menu\").length || this.collapseAll(e, !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63953,
+ "nodeLength": 14,
+ "src": "\"textarea\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63970,
+ "nodeLength": 11,
+ "src": "\"input\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "8": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 44,
+ "src": "o || !a && this._isContentEditable(this.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 41,
+ "src": "!a && this._isContentEditable(this.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175,
+ "nodeLength": 4,
+ "src": "o || a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 338,
+ "nodeLength": 29,
+ "src": "this.element.prop(\"readOnly\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 668,
+ "nodeLength": 63,
+ "src": "this.menu.active && (e = !0 , n.preventDefault() , this.menu.select(n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 749,
+ "nodeLength": 37,
+ "src": "this.menu.active && this.menu.select(n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 807,
+ "nodeLength": 109,
+ "src": "this.menu.element.is(\":visible\") && (this.isMultiLine || this._value(this.term) , this.close(n) , n.preventDefault())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 842,
+ "nodeLength": 40,
+ "src": "this.isMultiLine || this._value(this.term)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 985,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1000,
+ "nodeLength": 72,
+ "src": "(!this.isMultiLine || this.menu.element.is(\":visible\")) && s.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1000,
+ "nodeLength": 51,
+ "src": "!this.isMultiLine || this.menu.element.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1083,
+ "nodeLength": 2,
+ "src": "!i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1332,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1493,
+ "nodeLength": 15,
+ "src": "this.cancelBlur",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1917,
+ "nodeLength": 89,
+ "src": "this.element[0] !== t.ui.safeActiveElement(this.document[0]) && this.element.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1917,
+ "nodeLength": 58,
+ "src": "this.element[0] !== t.ui.safeActiveElement(this.document[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2049,
+ "nodeLength": 88,
+ "src": "this.isNewMenu && (this.isNewMenu = !1 , e.originalEvent && /^mouse/.test(e.originalEvent.type))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2084,
+ "nodeLength": 52,
+ "src": "e.originalEvent && /^mouse/.test(e.originalEvent.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2283,
+ "nodeLength": 112,
+ "src": "!1 !== this._trigger(\"focus\", e, {\n item: n}) && e.originalEvent && /^key/.test(e.originalEvent.type) && this._value(n.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2283,
+ "nodeLength": 38,
+ "src": "!1 !== this._trigger(\"focus\", e, {\n item: n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2323,
+ "nodeLength": 72,
+ "src": "e.originalEvent && /^key/.test(e.originalEvent.type) && this._value(n.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2340,
+ "nodeLength": 55,
+ "src": "/^key/.test(e.originalEvent.type) && this._value(n.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2398,
+ "nodeLength": 34,
+ "src": "i.item.attr(\"aria-label\") || n.value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2433,
+ "nodeLength": 101,
+ "src": "s && t.trim(s).length && (this.liveRegion.children().hide() , t(\"\").text(s).appendTo(this.liveRegion))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2436,
+ "nodeLength": 98,
+ "src": "t.trim(s).length && (this.liveRegion.children().hide() , t(\"
\").text(s).appendTo(this.liveRegion))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2627,
+ "nodeLength": 168,
+ "src": "this.element[0] !== t.ui.safeActiveElement(this.document[0]) && (this.element.trigger(\"focus\") , this.previous = n , this._delay(function() {\n this.previous = n , this.selectedItem = s;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2627,
+ "nodeLength": 58,
+ "src": "this.element[0] !== t.ui.safeActiveElement(this.document[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2796,
+ "nodeLength": 61,
+ "src": "!1 !== this._trigger(\"select\", e, {\n item: s}) && this._value(s.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2796,
+ "nodeLength": 39,
+ "src": "!1 !== this._trigger(\"select\", e, {\n item: s})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3685,
+ "nodeLength": 32,
+ "src": "\"source\" === t && this._initSource()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3685,
+ "nodeLength": 12,
+ "src": "\"source\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3718,
+ "nodeLength": 60,
+ "src": "\"appendTo\" === t && this.menu.element.appendTo(this._appendTo())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3718,
+ "nodeLength": 14,
+ "src": "\"appendTo\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3779,
+ "nodeLength": 45,
+ "src": "\"disabled\" === t && e && this.xhr && this.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3779,
+ "nodeLength": 14,
+ "src": "\"disabled\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3795,
+ "nodeLength": 29,
+ "src": "e && this.xhr && this.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3798,
+ "nodeLength": 26,
+ "src": "this.xhr && this.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3895,
+ "nodeLength": 64,
+ "src": "e.target === this.element[0] || e.target === i || t.contains(i, e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3895,
+ "nodeLength": 26,
+ "src": "e.target === this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3923,
+ "nodeLength": 36,
+ "src": "e.target === i || t.contains(i, e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3923,
+ "nodeLength": 12,
+ "src": "e.target === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3994,
+ "nodeLength": 44,
+ "src": "this._isEventTargetInWidget(t) || this.close()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4096,
+ "nodeLength": 60,
+ "src": "e && (e = e.jquery || e.nodeType ? t(e) : this.document.find(e).eq(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4102,
+ "nodeLength": 20,
+ "src": "e.jquery || e.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4157,
+ "nodeLength": 54,
+ "src": "e && e[0] || (e = this.element.closest(\".ui-front, dialog\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4157,
+ "nodeLength": 7,
+ "src": "e && e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4212,
+ "nodeLength": 35,
+ "src": "e.length || (e = this.document[0].body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4289,
+ "nodeLength": 30,
+ "src": "t.isArray(this.options.source)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4409,
+ "nodeLength": 36,
+ "src": "\"string\" == typeof this.options.source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4495,
+ "nodeLength": 20,
+ "src": "s.xhr && s.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4746,
+ "nodeLength": 25,
+ "src": "this.term === this._value()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4809,
+ "nodeLength": 42,
+ "src": "t.altKey || t.ctrlKey || t.metaKey || t.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4819,
+ "nodeLength": 32,
+ "src": "t.ctrlKey || t.metaKey || t.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4830,
+ "nodeLength": 21,
+ "src": "t.metaKey || t.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4853,
+ "nodeLength": 60,
+ "src": "(!e || e && !i && !s) && (this.selectedItem = null , this.search(null, t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4853,
+ "nodeLength": 13,
+ "src": "!e || e && !i && !s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4857,
+ "nodeLength": 9,
+ "src": "e && !i && !s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4860,
+ "nodeLength": 6,
+ "src": "!i && !s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4966,
+ "nodeLength": 7,
+ "src": "null != t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5014,
+ "nodeLength": 31,
+ "src": "t.length < this.options.minLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5060,
+ "nodeLength": 30,
+ "src": "this._trigger(\"search\", e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5327,
+ "nodeLength": 41,
+ "src": "e === this.requestIndex && this.__response(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5327,
+ "nodeLength": 21,
+ "src": "e === this.requestIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5384,
+ "nodeLength": 58,
+ "src": "this.pending || this._removeClass(\"ui-autocomplete-loading\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5474,
+ "nodeLength": 25,
+ "src": "t && (t = this._normalize(t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5543,
+ "nodeLength": 55,
+ "src": "!this.options.disabled && t && t.length && !this.cancelSearch",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5567,
+ "nodeLength": 31,
+ "src": "t && t.length && !this.cancelSearch",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5570,
+ "nodeLength": 28,
+ "src": "t.length && !this.cancelSearch",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5766,
+ "nodeLength": 120,
+ "src": "this.menu.element.is(\":visible\") && (this.menu.element.hide() , this.menu.blur() , this.isNewMenu = !0 , this._trigger(\"close\", t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5908,
+ "nodeLength": 81,
+ "src": "this.previous !== this._value() && this._trigger(\"change\", t, {\n item: this.selectedItem})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5908,
+ "nodeLength": 29,
+ "src": "this.previous !== this._value()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6021,
+ "nodeLength": 32,
+ "src": "e.length && e[0].label && e[0].value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6031,
+ "nodeLength": 22,
+ "src": "e[0].label && e[0].value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6082,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6140,
+ "nodeLength": 16,
+ "src": "e.label || e.value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6163,
+ "nodeLength": 16,
+ "src": "e.value || e.label",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6388,
+ "nodeLength": 40,
+ "src": "this.options.autoFocus && this.menu.next()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6907,
+ "nodeLength": 32,
+ "src": "this.menu.element.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6940,
+ "nodeLength": 85,
+ "src": "this.menu.isFirstItem() && /^previous/.test(t) || this.menu.isLastItem() && /^next/.test(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6940,
+ "nodeLength": 44,
+ "src": "this.menu.isFirstItem() && /^previous/.test(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6986,
+ "nodeLength": 39,
+ "src": "this.menu.isLastItem() && /^next/.test(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7027,
+ "nodeLength": 40,
+ "src": "this.isMultiLine || this._value(this.term)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7290,
+ "nodeLength": 90,
+ "src": "(!this.isMultiLine || this.menu.element.is(\":visible\")) && (this._move(t, e) , e.preventDefault())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7290,
+ "nodeLength": 51,
+ "src": "!this.isMultiLine || this.menu.element.is(\":visible\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7416,
+ "nodeLength": 9,
+ "src": "!t.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7473,
+ "nodeLength": 13,
+ "src": "\"inherit\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7523,
+ "nodeLength": 10,
+ "src": "\"true\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29171,
+ "nodeLength": 19,
+ "src": "t.label || t.value || t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23,
+ "nodeLength": 10,
+ "src": "t.value || t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29323,
+ "nodeLength": 3,
+ "src": "t > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29470,
+ "nodeLength": 208,
+ "src": "this.options.disabled || this.cancelSearch || (i = e && e.length ? this.options.messages.results(e.length) : this.options.messages.noResults , this.liveRegion.children().hide() , t(\"
\").text(i).appendTo(this.liveRegion))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29493,
+ "nodeLength": 185,
+ "src": "this.cancelSearch || (i = e && e.length ? this.options.messages.results(e.length) : this.options.messages.noResults , this.liveRegion.children().hide() , t(\"
\").text(i).appendTo(this.liveRegion))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29515,
+ "nodeLength": 11,
+ "src": "e && e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72535,
+ "nodeLength": 153,
+ "src": "this.options.items.controlgroupLabel && this.element.find(this.options.items.controlgroupLabel).find(\".ui-controlgroup-label-contents\").contents().unwrap()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72788,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72790,
+ "nodeLength": 23,
+ "src": "\"controlgroupLabel\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72867,
+ "nodeLength": 130,
+ "src": "e.children(\".ui-controlgroup-label-contents\").length || e.contents().wrapAll(\"
\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73096,
+ "nodeLength": 397,
+ "src": "t.fn[s] && (a = e[\"_\" + s + \"Options\"] ? e[\"_\" + s + \"Options\"](\"middle\") : {\n classes: {}} , e.element.find(n).each(function() {\n var n = t(this), o = n[s](\"instance\"), r = t.widget.extend({}, a);\n if (\"button\" !== s || !n.parent(\".ui-spinner\").length) {\n o || (o = n[s]()[s](\"instance\")) , o && (r.classes = e._resolveClassesValues(r.classes, o)) , n[s](r);\n var h = n[s](\"widget\");\n t.data(h[0], \"ui-controlgroup-data\", o ? o : n[s](\"instance\")) , i.push(h[0]);\n }\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73108,
+ "nodeLength": 18,
+ "src": "e[\"_\" + s + \"Options\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73263,
+ "nodeLength": 45,
+ "src": "\"button\" !== s || !n.parent(\".ui-spinner\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73263,
+ "nodeLength": 12,
+ "src": "\"button\" !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73310,
+ "nodeLength": 28,
+ "src": "o || (o = n[s]()[s](\"instance\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73339,
+ "nodeLength": 51,
+ "src": "o && (r.classes = e._resolveClassesValues(r.classes, o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73455,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73712,
+ "nodeLength": 15,
+ "src": "s && s[e] && s[e]()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73715,
+ "nodeLength": 12,
+ "src": "s[e] && s[e]()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73994,
+ "nodeLength": 35,
+ "src": "\"vertical\" === this.options.direction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74096,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74131,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74533,
+ "nodeLength": 35,
+ "src": "\"vertical\" === this.options.direction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74582,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74725,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74783,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74834,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74898,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75101,
+ "nodeLength": 24,
+ "src": "i.options.classes[n] || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75226,
+ "nodeLength": 77,
+ "src": "\"direction\" === t && this._removeClass(\"ui-controlgroup-\" + this.options.direction)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75226,
+ "nodeLength": 15,
+ "src": "\"direction\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75321,
+ "nodeLength": 14,
+ "src": "\"disabled\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75359,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75520,
+ "nodeLength": 80,
+ "src": "\"horizontal\" === this.options.direction && this._addClass(null, \"ui-helper-clearfix\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75520,
+ "nodeLength": 37,
+ "src": "\"horizontal\" === this.options.direction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75641,
+ "nodeLength": 50,
+ "src": "this.options.onlyVisible && (e = e.filter(\":visible\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75692,
+ "nodeLength": 332,
+ "src": "e.length && (t.each([\"first\", \"last\"], function(t, s) {\n var n = e[s]().data(\"ui-controlgroup-data\");\n if (n && i[\"_\" + n.widgetName + \"Options\"]) {\n var o = i[\"_\" + n.widgetName + \"Options\"](1 === e.length ? \"only\" : s);\n o.classes = i._resolveClassesValues(o.classes, n) , n.element[n.widgetName](o);\n } else \n i._updateCornerClass(e[s](), s);\n}) , this._callChildMethod(\"refresh\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75786,
+ "nodeLength": 32,
+ "src": "n && i[\"_\" + n.widgetName + \"Options\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75856,
+ "nodeLength": 12,
+ "src": "1 === e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76275,
+ "nodeLength": 17,
+ "src": "this._super() || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76369,
+ "nodeLength": 69,
+ "src": "this.label.length || t.error(\"No label found for checkboxradio widget\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76537,
+ "nodeLength": 17,
+ "src": "3 === this.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76587,
+ "nodeLength": 48,
+ "src": "this.originalLabel && (n.label = this.originalLabel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76663,
+ "nodeLength": 23,
+ "src": "null != e && (n.disabled = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76663,
+ "nodeLength": 7,
+ "src": "null != e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76768,
+ "nodeLength": 77,
+ "src": "null == this.options.disabled && (this.options.disabled = this.element[0].disabled)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76768,
+ "nodeLength": 27,
+ "src": "null == this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77035,
+ "nodeLength": 78,
+ "src": "\"radio\" === this.type && this._addClass(this.label, \"ui-checkboxradio-radio-label\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77035,
+ "nodeLength": 19,
+ "src": "\"radio\" === this.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77114,
+ "nodeLength": 59,
+ "src": "this.options.label && this.options.label !== this.originalLabel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77134,
+ "nodeLength": 39,
+ "src": "this.options.label !== this.originalLabel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77194,
+ "nodeLength": 59,
+ "src": "this.originalLabel && (this.options.label = this.originalLabel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77270,
+ "nodeLength": 135,
+ "src": "t && (this._addClass(this.label, \"ui-checkboxradio-checked\", \"ui-state-active\") , this.icon && this._addClass(this.icon, null, \"ui-state-hover\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77346,
+ "nodeLength": 58,
+ "src": "this.icon && this._addClass(this.icon, null, \"ui-state-hover\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77708,
+ "nodeLength": 138,
+ "src": "\"input\" === e && /radio|checkbox/.test(this.type) || t.error(\"Can't create checkboxradio on element.nodeName=\" + e + \" and element.type=\" + this.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77708,
+ "nodeLength": 45,
+ "src": "\"input\" === e && /radio|checkbox/.test(this.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77708,
+ "nodeLength": 11,
+ "src": "\"input\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78055,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78060,
+ "nodeLength": 16,
+ "src": "this.form.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78142,
+ "nodeLength": 25,
+ "src": "0 === t(this).form().length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78331,
+ "nodeLength": 159,
+ "src": "this.options.icon && \"checkbox\" === this.type && this._toggleClass(this.icon, null, \"ui-icon-check ui-state-checked\", e)._toggleClass(this.icon, null, \"ui-icon-blank\", !e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78350,
+ "nodeLength": 140,
+ "src": "\"checkbox\" === this.type && this._toggleClass(this.icon, null, \"ui-icon-check ui-state-checked\", e)._toggleClass(this.icon, null, \"ui-icon-blank\", !e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78350,
+ "nodeLength": 22,
+ "src": "\"checkbox\" === this.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78491,
+ "nodeLength": 172,
+ "src": "\"radio\" === this.type && this._getRadioGroup().each(function() {\n var e = t(this).checkboxradio(\"instance\");\n e && e._removeClass(e.label, \"ui-checkboxradio-checked\", \"ui-state-active\");\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78491,
+ "nodeLength": 19,
+ "src": "\"radio\" === this.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78590,
+ "nodeLength": 71,
+ "src": "e && e._removeClass(e.label, \"ui-checkboxradio-checked\", \"ui-state-active\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78716,
+ "nodeLength": 55,
+ "src": "this.icon && (this.icon.remove() , this.iconSpace.remove())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78804,
+ "nodeLength": 14,
+ "src": "\"label\" !== t || e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78804,
+ "nodeLength": 11,
+ "src": "\"label\" !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 78837,
+ "nodeLength": 14,
+ "src": "\"disabled\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79038,
+ "nodeLength": 17,
+ "src": "this.options.icon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79057,
+ "nodeLength": 130,
+ "src": "this.icon || (this.icon = t(\"
\") , this.iconSpace = t(\" \") , this._addClass(this.iconSpace, \"ui-checkboxradio-icon-space\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79188,
+ "nodeLength": 22,
+ "src": "\"checkbox\" === this.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79215,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79299,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79406,
+ "nodeLength": 69,
+ "src": "e || this._removeClass(this.icon, null, \"ui-icon-check ui-state-checked\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79531,
+ "nodeLength": 81,
+ "src": "void 0 !== this.icon && (this.icon.remove() , this.iconSpace.remove() , delete this.icon)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79531,
+ "nodeLength": 18,
+ "src": "void 0 !== this.icon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79687,
+ "nodeLength": 34,
+ "src": "this.icon && (t = t.not(this.icon[0]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79722,
+ "nodeLength": 44,
+ "src": "this.iconSpace && (t = t.not(this.iconSpace[0]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79990,
+ "nodeLength": 46,
+ "src": "null !== this.options.label && this._updateLabel()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79990,
+ "nodeLength": 25,
+ "src": "null !== this.options.label",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80037,
+ "nodeLength": 57,
+ "src": "e !== this.options.disabled && this._setOptions({\n disabled: e})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80037,
+ "nodeLength": 25,
+ "src": "e !== this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80341,
+ "nodeLength": 17,
+ "src": "this._super() || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80431,
+ "nodeLength": 23,
+ "src": "null != t && (e.disabled = t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80431,
+ "nodeLength": 7,
+ "src": "null != t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80474,
+ "nodeLength": 12,
+ "src": "this.isInput",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80526,
+ "nodeLength": 48,
+ "src": "this.originalLabel && (e.label = this.originalLabel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80597,
+ "nodeLength": 70,
+ "src": "!this.option.showLabel & !this.options.icon && (this.options.showLabel = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80668,
+ "nodeLength": 81,
+ "src": "null == this.options.disabled && (this.options.disabled = this.element[0].disabled || !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80668,
+ "nodeLength": 27,
+ "src": "null == this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80720,
+ "nodeLength": 28,
+ "src": "this.element[0].disabled || !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80793,
+ "nodeLength": 150,
+ "src": "this.options.label && this.options.label !== this.originalLabel && (this.isInput ? this.element.val(this.options.label) : this.element.html(this.options.label))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80813,
+ "nodeLength": 130,
+ "src": "this.options.label !== this.originalLabel && (this.isInput ? this.element.val(this.options.label) : this.element.html(this.options.label))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80813,
+ "nodeLength": 39,
+ "src": "this.options.label !== this.originalLabel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 80855,
+ "nodeLength": 12,
+ "src": "this.isInput",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81050,
+ "nodeLength": 181,
+ "src": "this.element.is(\"a\") && this._on({\n keyup: function(e) {\n e.keyCode === t.ui.keyCode.SPACE && (e.preventDefault() , this.element[0].click ? this.element[0].click() : this.element.trigger(\"click\"));\n}})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81100,
+ "nodeLength": 128,
+ "src": "e.keyCode === t.ui.keyCode.SPACE && (e.preventDefault() , this.element[0].click ? this.element[0].click() : this.element.trigger(\"click\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81100,
+ "nodeLength": 30,
+ "src": "e.keyCode === t.ui.keyCode.SPACE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81152,
+ "nodeLength": 21,
+ "src": "this.element[0].click",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81253,
+ "nodeLength": 61,
+ "src": "this.element.is(\"button\") || this.element.attr(\"role\", \"button\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81315,
+ "nodeLength": 85,
+ "src": "this.options.icon && (this._updateIcon(\"icon\", this.options.icon) , this._updateTooltip())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81466,
+ "nodeLength": 81,
+ "src": "this.options.showLabel || this.title || this.element.attr(\"title\", this.options.label)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81490,
+ "nodeLength": 57,
+ "src": "this.title || this.element.attr(\"title\", this.options.label)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81581,
+ "nodeLength": 18,
+ "src": "\"iconPosition\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81602,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81634,
+ "nodeLength": 23,
+ "src": "\"top\" === n || \"bottom\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81634,
+ "nodeLength": 9,
+ "src": "\"top\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81645,
+ "nodeLength": 12,
+ "src": "\"bottom\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81658,
+ "nodeLength": 9,
+ "src": "this.icon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81668,
+ "nodeLength": 54,
+ "src": "s && this._removeClass(this.icon, null, this.options.icon)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81799,
+ "nodeLength": 61,
+ "src": "this.options.showLabel || this._addClass(\"ui-button-icon-only\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81862,
+ "nodeLength": 35,
+ "src": "s && this._addClass(this.icon, null, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81918,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81975,
+ "nodeLength": 39,
+ "src": "this.iconSpace && this.iconSpace.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82017,
+ "nodeLength": 106,
+ "src": "this.iconSpace || (this.iconSpace = t(\" \") , this._addClass(this.iconSpace, \"ui-button-icon-space\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82259,
+ "nodeLength": 29,
+ "src": "this.icon && this.icon.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82289,
+ "nodeLength": 39,
+ "src": "this.iconSpace && this.iconSpace.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82329,
+ "nodeLength": 47,
+ "src": "this.hasTitle || this.element.removeAttr(\"title\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82417,
+ "nodeLength": 25,
+ "src": "/^(?:end|bottom)/.test(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82515,
+ "nodeLength": 25,
+ "src": "/^(?:end|bottom)/.test(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82603,
+ "nodeLength": 20,
+ "src": "void 0 === t.showLabel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82661,
+ "nodeLength": 15,
+ "src": "void 0 === t.icon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82702,
+ "nodeLength": 22,
+ "src": "e || i || (t.showLabel = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82705,
+ "nodeLength": 19,
+ "src": "i || (t.showLabel = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82766,
+ "nodeLength": 109,
+ "src": "\"icon\" === t && (e ? this._updateIcon(t, e) : this.icon && (this.icon.remove() , this.iconSpace && this.iconSpace.remove()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82766,
+ "nodeLength": 10,
+ "src": "\"icon\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82779,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82803,
+ "nodeLength": 71,
+ "src": "this.icon && (this.icon.remove() , this.iconSpace && this.iconSpace.remove())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82834,
+ "nodeLength": 39,
+ "src": "this.iconSpace && this.iconSpace.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82876,
+ "nodeLength": 41,
+ "src": "\"iconPosition\" === t && this._updateIcon(t, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82876,
+ "nodeLength": 18,
+ "src": "\"iconPosition\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82918,
+ "nodeLength": 89,
+ "src": "\"showLabel\" === t && (this._toggleClass(\"ui-button-icon-only\", null, !e) , this._updateTooltip())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82918,
+ "nodeLength": 15,
+ "src": "\"showLabel\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83008,
+ "nodeLength": 176,
+ "src": "\"label\" === t && (this.isInput ? this.element.val(e) : (this.element.html(e) , this.icon && (this._attachIcon(this.options.iconPosition) , this._attachIconSpace(this.options.iconPosition))))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83008,
+ "nodeLength": 11,
+ "src": "\"label\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83022,
+ "nodeLength": 12,
+ "src": "this.isInput",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83077,
+ "nodeLength": 105,
+ "src": "this.icon && (this._attachIcon(this.options.iconPosition) , this._attachIconSpace(this.options.iconPosition))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83202,
+ "nodeLength": 113,
+ "src": "\"disabled\" === t && (this._toggleClass(null, \"ui-state-disabled\", e) , this.element[0].disabled = e , e && this.element.blur())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83202,
+ "nodeLength": 14,
+ "src": "\"disabled\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83292,
+ "nodeLength": 22,
+ "src": "e && this.element.blur()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83342,
+ "nodeLength": 32,
+ "src": "this.element.is(\"input, button\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83444,
+ "nodeLength": 57,
+ "src": "t !== this.options.disabled && this._setOptions({\n disabled: t})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83444,
+ "nodeLength": 25,
+ "src": "t !== this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83527,
+ "nodeLength": 1865,
+ "src": "t.uiBackCompat !== !1 && (t.widget(\"ui.button\", t.ui.button, {\n options: {\n text: !0, \n icons: {\n primary: null, \n secondary: null}}, \n _create: function() {\n this.options.showLabel && !this.options.text && (this.options.showLabel = this.options.text) , !this.options.showLabel && this.options.text && (this.options.text = this.options.showLabel) , this.options.icon || !this.options.icons.primary && !this.options.icons.secondary ? this.options.icon && (this.options.icons.primary = this.options.icon) : this.options.icons.primary ? this.options.icon = this.options.icons.primary : (this.options.icon = this.options.icons.secondary , this.options.iconPosition = \"end\") , this._super();\n}, \n _setOption: function(t, e) {\n return \"text\" === t ? (this._super(\"showLabel\", e) , void 0) : (\"showLabel\" === t && (this.options.text = e) , \"icon\" === t && (this.options.icons.primary = e) , \"icons\" === t && (e.primary ? (this._super(\"icon\", e.primary) , this._super(\"iconPosition\", \"beginning\")) : e.secondary && (this._super(\"icon\", e.secondary) , this._super(\"iconPosition\", \"end\"))) , this._superApply(arguments) , void 0);\n}}) , t.fn.button = function(e) {\n return function() {\n return !this.length || this.length && \"INPUT\" !== this[0].tagName || this.length && \"INPUT\" === this[0].tagName && \"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\") ? e.apply(this, arguments) : (t.ui.checkboxradio || t.error(\"Checkboxradio widget missing\") , 0 === arguments.length ? this.checkboxradio({\n icon: !1}) : this.checkboxradio.apply(this, arguments));\n};\n}(t.fn.button) , t.fn.buttonset = function() {\n return t.ui.controlgroup || t.error(\"Controlgroup widget missing\") , \"option\" === arguments[0] && \"items\" === arguments[1] && arguments[2] ? this.controlgroup.apply(this, [arguments[0], \"items.button\", arguments[2]]) : \"option\" === arguments[0] && \"items\" === arguments[1] ? this.controlgroup.apply(this, [arguments[0], \"items.button\"]) : (\"object\" == typeof arguments[0] && arguments[0].items && (arguments[0].items = {\n button: arguments[0].items}) , this.controlgroup.apply(this, arguments));\n})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 83527,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 83656,
+ "nodeLength": 86,
+ "src": "this.options.showLabel && !this.options.text && (this.options.showLabel = this.options.text)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83680,
+ "nodeLength": 62,
+ "src": "!this.options.text && (this.options.showLabel = this.options.text)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83743,
+ "nodeLength": 86,
+ "src": "!this.options.showLabel && this.options.text && (this.options.text = this.options.showLabel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83768,
+ "nodeLength": 61,
+ "src": "this.options.text && (this.options.text = this.options.showLabel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83830,
+ "nodeLength": 77,
+ "src": "this.options.icon || !this.options.icons.primary && !this.options.icons.secondary",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83849,
+ "nodeLength": 58,
+ "src": "!this.options.icons.primary && !this.options.icons.secondary",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83908,
+ "nodeLength": 65,
+ "src": "this.options.icon && (this.options.icons.primary = this.options.icon)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 83974,
+ "nodeLength": 26,
+ "src": "this.options.icons.primary",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84173,
+ "nodeLength": 10,
+ "src": "\"text\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84221,
+ "nodeLength": 38,
+ "src": "\"showLabel\" === t && (this.options.text = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84221,
+ "nodeLength": 15,
+ "src": "\"showLabel\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84260,
+ "nodeLength": 42,
+ "src": "\"icon\" === t && (this.options.icons.primary = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84260,
+ "nodeLength": 10,
+ "src": "\"icon\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84303,
+ "nodeLength": 177,
+ "src": "\"icons\" === t && (e.primary ? (this._super(\"icon\", e.primary) , this._super(\"iconPosition\", \"beginning\")) : e.secondary && (this._super(\"icon\", e.secondary) , this._super(\"iconPosition\", \"end\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84303,
+ "nodeLength": 11,
+ "src": "\"icons\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84317,
+ "nodeLength": 9,
+ "src": "e.primary",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84399,
+ "nodeLength": 80,
+ "src": "e.secondary && (this._super(\"icon\", e.secondary) , this._super(\"iconPosition\", \"end\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84568,
+ "nodeLength": 153,
+ "src": "!this.length || this.length && \"INPUT\" !== this[0].tagName || this.length && \"INPUT\" === this[0].tagName && \"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84582,
+ "nodeLength": 139,
+ "src": "this.length && \"INPUT\" !== this[0].tagName || this.length && \"INPUT\" === this[0].tagName && \"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84582,
+ "nodeLength": 38,
+ "src": "this.length && \"INPUT\" !== this[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84595,
+ "nodeLength": 25,
+ "src": "\"INPUT\" !== this[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84622,
+ "nodeLength": 99,
+ "src": "this.length && \"INPUT\" === this[0].tagName && \"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84635,
+ "nodeLength": 86,
+ "src": "\"INPUT\" === this[0].tagName && \"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84635,
+ "nodeLength": 25,
+ "src": "\"INPUT\" === this[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84662,
+ "nodeLength": 59,
+ "src": "\"checkbox\" !== this.attr(\"type\") && \"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84662,
+ "nodeLength": 30,
+ "src": "\"checkbox\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84694,
+ "nodeLength": 27,
+ "src": "\"radio\" !== this.attr(\"type\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84747,
+ "nodeLength": 59,
+ "src": "t.ui.checkboxradio || t.error(\"Checkboxradio widget missing\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84807,
+ "nodeLength": 20,
+ "src": "0 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84948,
+ "nodeLength": 57,
+ "src": "t.ui.controlgroup || t.error(\"Controlgroup widget missing\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85006,
+ "nodeLength": 61,
+ "src": "\"option\" === arguments[0] && \"items\" === arguments[1] && arguments[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85006,
+ "nodeLength": 23,
+ "src": "\"option\" === arguments[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85031,
+ "nodeLength": 36,
+ "src": "\"items\" === arguments[1] && arguments[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85031,
+ "nodeLength": 22,
+ "src": "\"items\" === arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85141,
+ "nodeLength": 47,
+ "src": "\"option\" === arguments[0] && \"items\" === arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85141,
+ "nodeLength": 23,
+ "src": "\"option\" === arguments[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85166,
+ "nodeLength": 22,
+ "src": "\"items\" === arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85250,
+ "nodeLength": 99,
+ "src": "\"object\" == typeof arguments[0] && arguments[0].items && (arguments[0].items = {\n button: arguments[0].items})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85250,
+ "nodeLength": 29,
+ "src": "\"object\" == typeof arguments[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85281,
+ "nodeLength": 68,
+ "src": "arguments[0].items && (arguments[0].items = {\n button: arguments[0].items})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85618,
+ "nodeLength": 5,
+ "src": "t || {}",
+ "evalFalse": 0,
+ "evalTrue": 10
+ },
+ {
+ "position": 85702,
+ "nodeLength": 21,
+ "src": "\"div\" === s || \"span\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85702,
+ "nodeLength": 9,
+ "src": "\"div\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85713,
+ "nodeLength": 10,
+ "src": "\"span\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85724,
+ "nodeLength": 40,
+ "src": "e.id || (this.uuid += 1 , e.id = \"dp\" + this.uuid)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85812,
+ "nodeLength": 5,
+ "src": "i || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85819,
+ "nodeLength": 11,
+ "src": "\"input\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 85860,
+ "nodeLength": 30,
+ "src": "n && this._inlineDatepicker(e, o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86071,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86286,
+ "nodeLength": 270,
+ "src": "s.hasClass(this.markerClassName) || (this._attachments(s, i) , s.addClass(this.markerClassName).on(\"keydown\", this._doKeyDown).on(\"keypress\", this._doKeyPress).on(\"keyup\", this._doKeyUp) , this._autoSize(i) , t.data(e, \"datepicker\", i) , i.settings.disabled && this._disableDatepicker(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86508,
+ "nodeLength": 47,
+ "src": "i.settings.disabled && this._disableDatepicker(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86646,
+ "nodeLength": 27,
+ "src": "i.append && i.append.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86674,
+ "nodeLength": 99,
+ "src": "a && (i.append = t(\"\" + a + \"\") , e[r ? \"before\" : \"after\"](i.append))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86743,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86810,
+ "nodeLength": 29,
+ "src": "i.trigger && i.trigger.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86865,
+ "nodeLength": 60,
+ "src": "(\"focus\" === s || \"both\" === s) && e.on(\"focus\", this._showDatepicker)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86865,
+ "nodeLength": 23,
+ "src": "\"focus\" === s || \"both\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86865,
+ "nodeLength": 11,
+ "src": "\"focus\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86878,
+ "nodeLength": 10,
+ "src": "\"both\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86927,
+ "nodeLength": 653,
+ "src": "(\"button\" === s || \"both\" === s) && (n = this._get(i, \"buttonText\") , o = this._get(i, \"buttonImage\") , i.trigger = t(this._get(i, \"buttonImageOnly\") ? t(\"
\").addClass(this._triggerClass).attr({\n src: o, \n alt: n, \n title: n}) : t(\"\").addClass(this._triggerClass).html(o ? t(\"
\").attr({\n src: o, \n alt: n, \n title: n}) : n)) , e[r ? \"before\" : \"after\"](i.trigger) , i.trigger.on(\"click\", function() {\n return t.datepicker._datepickerShowing && t.datepicker._lastInput === e[0] ? t.datepicker._hideDatepicker() : t.datepicker._datepickerShowing && t.datepicker._lastInput !== e[0] ? (t.datepicker._hideDatepicker() , t.datepicker._showDatepicker(e[0])) : t.datepicker._showDatepicker(e[0]) , !1;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86927,
+ "nodeLength": 24,
+ "src": "\"button\" === s || \"both\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86927,
+ "nodeLength": 12,
+ "src": "\"button\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86941,
+ "nodeLength": 10,
+ "src": "\"both\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87024,
+ "nodeLength": 30,
+ "src": "this._get(i, \"buttonImageOnly\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87195,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87243,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87313,
+ "nodeLength": 63,
+ "src": "t.datepicker._datepickerShowing && t.datepicker._lastInput === e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87346,
+ "nodeLength": 30,
+ "src": "t.datepicker._lastInput === e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87408,
+ "nodeLength": 63,
+ "src": "t.datepicker._datepickerShowing && t.datepicker._lastInput !== e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87441,
+ "nodeLength": 30,
+ "src": "t.datepicker._lastInput !== e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87607,
+ "nodeLength": 34,
+ "src": "this._get(t, \"autoSize\") && !t.inline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87706,
+ "nodeLength": 263,
+ "src": "a.match(/[DM]/) && (e = function(t) {\n for (i = 0 , s = 0 , n = 0; t.length > n; n++) \n t[n].length > i && (i = t[n].length , s = n);\n return s;\n} , o.setMonth(e(this._get(t, a.match(/MM/) ? \"monthNames\" : \"monthNamesShort\"))) , o.setDate(e(this._get(t, a.match(/DD/) ? \"dayNames\" : \"dayNamesShort\")) + 20 - o.getDay()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87754,
+ "nodeLength": 10,
+ "src": "t.length > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87769,
+ "nodeLength": 34,
+ "src": "t[n].length > i && (i = t[n].length , s = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87769,
+ "nodeLength": 13,
+ "src": "t[n].length > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87839,
+ "nodeLength": 13,
+ "src": "a.match(/MM/)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87911,
+ "nodeLength": 13,
+ "src": "a.match(/DD/)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88065,
+ "nodeLength": 283,
+ "src": "s.hasClass(this.markerClassName) || (s.addClass(this.markerClassName).append(i.dpDiv) , t.data(e, \"datepicker\", i) , this._setDate(i, this._getDefaultDate(i), !0) , this._updateDatepicker(i) , this._updateAlternate(i) , i.settings.disabled && this._disableDatepicker(e) , i.dpDiv.css(\"display\", \"block\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88269,
+ "nodeLength": 47,
+ "src": "i.settings.disabled && this._disableDatepicker(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88428,
+ "nodeLength": 339,
+ "src": "d || (this.uuid += 1 , r = \"dp\" + this.uuid , this._dialogInput = t(\"\") , this._dialogInput.on(\"keydown\", this._doKeyDown) , t(\"body\").append(this._dialogInput) , d = this._dialogInst = this._newInst(this._dialogInput, !1) , d.settings = {} , t.data(this._dialogInput[0], \"datepicker\", d))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88781,
+ "nodeLength": 5,
+ "src": "n || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88790,
+ "nodeLength": 23,
+ "src": "i && i.constructor === Date",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88793,
+ "nodeLength": 20,
+ "src": "i.constructor === Date",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88873,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88875,
+ "nodeLength": 8,
+ "src": "o.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88909,
+ "nodeLength": 249,
+ "src": "this._pos || (h = document.documentElement.clientWidth , l = document.documentElement.clientHeight , c = document.documentElement.scrollLeft || document.body.scrollLeft , u = document.documentElement.scrollTop || document.body.scrollTop , this._pos = [h / 2 - 100 + c, l / 2 - 150 + u])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89002,
+ "nodeLength": 61,
+ "src": "document.documentElement.scrollLeft || document.body.scrollLeft",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89066,
+ "nodeLength": 59,
+ "src": "document.documentElement.scrollTop || document.body.scrollTop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89361,
+ "nodeLength": 32,
+ "src": "t.blockUI && t.blockUI(this.dpDiv)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89513,
+ "nodeLength": 388,
+ "src": "s.hasClass(this.markerClassName) && (i = e.nodeName.toLowerCase() , t.removeData(e, \"datepicker\") , \"input\" === i ? (n.append.remove() , n.trigger.remove() , s.removeClass(this.markerClassName).off(\"focus\", this._showDatepicker).off(\"keydown\", this._doKeyDown).off(\"keypress\", this._doKeyPress).off(\"keyup\", this._doKeyUp)) : (\"div\" === i || \"span\" === i) && s.removeClass(this.markerClassName).empty() , m === n && (m = null))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89604,
+ "nodeLength": 11,
+ "src": "\"input\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89817,
+ "nodeLength": 67,
+ "src": "(\"div\" === i || \"span\" === i) && s.removeClass(this.markerClassName).empty()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89817,
+ "nodeLength": 21,
+ "src": "\"div\" === i || \"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89817,
+ "nodeLength": 9,
+ "src": "\"div\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89828,
+ "nodeLength": 10,
+ "src": "\"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89885,
+ "nodeLength": 15,
+ "src": "m === n && (m = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89885,
+ "nodeLength": 5,
+ "src": "m === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89973,
+ "nodeLength": 478,
+ "src": "n.hasClass(this.markerClassName) && (i = e.nodeName.toLowerCase() , \"input\" === i ? (e.disabled = !1 , o.trigger.filter(\"button\").each(function() {\n this.disabled = !1;\n}).end().filter(\"img\").css({\n opacity: \"1.0\", \n cursor: \"\"})) : (\"div\" === i || \"span\" === i) && (s = n.children(\".\" + this._inlineClass) , s.children().removeClass(\"ui-state-disabled\") , s.find(\"select.ui-datepicker-month, select.ui-datepicker-year\").prop(\"disabled\", !1)) , this._disabledInputs = t.map(this._disabledInputs, function(t) {\n return t === e ? null : t;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90035,
+ "nodeLength": 11,
+ "src": "\"input\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90177,
+ "nodeLength": 191,
+ "src": "(\"div\" === i || \"span\" === i) && (s = n.children(\".\" + this._inlineClass) , s.children().removeClass(\"ui-state-disabled\") , s.find(\"select.ui-datepicker-month, select.ui-datepicker-year\").prop(\"disabled\", !1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90177,
+ "nodeLength": 21,
+ "src": "\"div\" === i || \"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90177,
+ "nodeLength": 9,
+ "src": "\"div\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90188,
+ "nodeLength": 10,
+ "src": "\"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90436,
+ "nodeLength": 5,
+ "src": "t === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90524,
+ "nodeLength": 534,
+ "src": "n.hasClass(this.markerClassName) && (i = e.nodeName.toLowerCase() , \"input\" === i ? (e.disabled = !0 , o.trigger.filter(\"button\").each(function() {\n this.disabled = !0;\n}).end().filter(\"img\").css({\n opacity: \"0.5\", \n cursor: \"default\"})) : (\"div\" === i || \"span\" === i) && (s = n.children(\".\" + this._inlineClass) , s.children().addClass(\"ui-state-disabled\") , s.find(\"select.ui-datepicker-month, select.ui-datepicker-year\").prop(\"disabled\", !0)) , this._disabledInputs = t.map(this._disabledInputs, function(t) {\n return t === e ? null : t;\n}) , this._disabledInputs[this._disabledInputs.length] = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90586,
+ "nodeLength": 11,
+ "src": "\"input\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90735,
+ "nodeLength": 188,
+ "src": "(\"div\" === i || \"span\" === i) && (s = n.children(\".\" + this._inlineClass) , s.children().addClass(\"ui-state-disabled\") , s.find(\"select.ui-datepicker-month, select.ui-datepicker-year\").prop(\"disabled\", !0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90735,
+ "nodeLength": 21,
+ "src": "\"div\" === i || \"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90735,
+ "nodeLength": 9,
+ "src": "\"div\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90746,
+ "nodeLength": 10,
+ "src": "\"span\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90991,
+ "nodeLength": 5,
+ "src": "t === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91097,
+ "nodeLength": 2,
+ "src": "!t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91121,
+ "nodeLength": 29,
+ "src": "this._disabledInputs.length > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91158,
+ "nodeLength": 27,
+ "src": "this._disabledInputs[e] === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91392,
+ "nodeLength": 40,
+ "src": "2 === arguments.length && \"string\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91392,
+ "nodeLength": 20,
+ "src": "2 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91414,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91433,
+ "nodeLength": 14,
+ "src": "\"defaults\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91484,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91486,
+ "nodeLength": 9,
+ "src": "\"all\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91543,
+ "nodeLength": 5,
+ "src": "i || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91549,
+ "nodeLength": 33,
+ "src": "\"string\" == typeof i && (n = {} , n[i] = s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91549,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91583,
+ "nodeLength": 544,
+ "src": "l && (this._curInst === l && this._hideDatepicker() , o = this._getDateDatepicker(e, !0) , r = this._getMinMaxDate(l, \"min\") , h = this._getMinMaxDate(l, \"max\") , a(l.settings, n) , null !== r && void 0 !== n.dateFormat && void 0 === n.minDate && (l.settings.minDate = this._formatDate(l, r)) , null !== h && void 0 !== n.dateFormat && void 0 === n.maxDate && (l.settings.maxDate = this._formatDate(l, h)) , \"disabled\" in n && (n.disabled ? this._disableDatepicker(e) : this._enableDatepicker(e)) , this._attachments(t(e), l) , this._autoSize(l) , this._setDate(l, o) , this._updateAlternate(l) , this._updateDatepicker(l))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91587,
+ "nodeLength": 41,
+ "src": "this._curInst === l && this._hideDatepicker()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91587,
+ "nodeLength": 17,
+ "src": "this._curInst === l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91739,
+ "nodeLength": 95,
+ "src": "null !== r && void 0 !== n.dateFormat && void 0 === n.minDate && (l.settings.minDate = this._formatDate(l, r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91739,
+ "nodeLength": 8,
+ "src": "null !== r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91749,
+ "nodeLength": 85,
+ "src": "void 0 !== n.dateFormat && void 0 === n.minDate && (l.settings.minDate = this._formatDate(l, r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91749,
+ "nodeLength": 21,
+ "src": "void 0 !== n.dateFormat",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91772,
+ "nodeLength": 62,
+ "src": "void 0 === n.minDate && (l.settings.minDate = this._formatDate(l, r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91772,
+ "nodeLength": 18,
+ "src": "void 0 === n.minDate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91835,
+ "nodeLength": 95,
+ "src": "null !== h && void 0 !== n.dateFormat && void 0 === n.maxDate && (l.settings.maxDate = this._formatDate(l, h))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91835,
+ "nodeLength": 8,
+ "src": "null !== h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91845,
+ "nodeLength": 85,
+ "src": "void 0 !== n.dateFormat && void 0 === n.maxDate && (l.settings.maxDate = this._formatDate(l, h))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91845,
+ "nodeLength": 21,
+ "src": "void 0 !== n.dateFormat",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91868,
+ "nodeLength": 62,
+ "src": "void 0 === n.maxDate && (l.settings.maxDate = this._formatDate(l, h))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91868,
+ "nodeLength": 18,
+ "src": "void 0 === n.maxDate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91931,
+ "nodeLength": 81,
+ "src": "\"disabled\" in n && (n.disabled ? this._disableDatepicker(e) : this._enableDatepicker(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91948,
+ "nodeLength": 10,
+ "src": "n.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92256,
+ "nodeLength": 28,
+ "src": "e && this._updateDatepicker(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92342,
+ "nodeLength": 74,
+ "src": "i && (this._setDate(i, e) , this._updateDatepicker(i) , this._updateAlternate(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92481,
+ "nodeLength": 41,
+ "src": "i && !i.inline && this._setDateFromField(i, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92484,
+ "nodeLength": 38,
+ "src": "!i.inline && this._setDateFromField(i, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92523,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92658,
+ "nodeLength": 46,
+ "src": "o._keyEvent = !0 , t.datepicker._datepickerShowing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92873,
+ "nodeLength": 75,
+ "src": "n[0] && t.datepicker._selectDay(e.target, o.selectedMonth, o.selectedYear, n[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92983,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93024,
+ "nodeLength": 7,
+ "src": "o.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93177,
+ "nodeLength": 9,
+ "src": "e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93313,
+ "nodeLength": 9,
+ "src": "e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93416,
+ "nodeLength": 56,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._clearDate(e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93416,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93475,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93511,
+ "nodeLength": 56,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._gotoToday(e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93511,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93570,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93606,
+ "nodeLength": 68,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._adjustDate(e.target, r ? 1 : -1, \"D\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93606,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93663,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93677,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93698,
+ "nodeLength": 145,
+ "src": "e.originalEvent.altKey && t.datepicker._adjustDate(e.target, e.ctrlKey ? -t.datepicker._get(o, \"stepBigMonths\") : -t.datepicker._get(o, \"stepMonths\"), \"M\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93756,
+ "nodeLength": 9,
+ "src": "e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93859,
+ "nodeLength": 64,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._adjustDate(e.target, -7, \"D\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93859,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93926,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93962,
+ "nodeLength": 68,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._adjustDate(e.target, r ? -1 : 1, \"D\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93962,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94019,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94033,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94054,
+ "nodeLength": 145,
+ "src": "e.originalEvent.altKey && t.datepicker._adjustDate(e.target, e.ctrlKey ? +t.datepicker._get(o, \"stepBigMonths\") : +t.datepicker._get(o, \"stepMonths\"), \"M\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94112,
+ "nodeLength": 9,
+ "src": "e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94215,
+ "nodeLength": 63,
+ "src": "(e.ctrlKey || e.metaKey) && t.datepicker._adjustDate(e.target, 7, \"D\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94215,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94281,
+ "nodeLength": 20,
+ "src": "e.ctrlKey || e.metaKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94326,
+ "nodeLength": 25,
+ "src": "36 === e.keyCode && e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94326,
+ "nodeLength": 14,
+ "src": "36 === e.keyCode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94392,
+ "nodeLength": 43,
+ "src": "a && (e.preventDefault() , e.stopPropagation())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94510,
+ "nodeLength": 37,
+ "src": "t.datepicker._get(n, \"constrainInput\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94636,
+ "nodeLength": 16,
+ "src": "null == e.charCode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94675,
+ "nodeLength": 48,
+ "src": "e.ctrlKey || e.metaKey || \" \" > s || !i || i.indexOf(s) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94686,
+ "nodeLength": 37,
+ "src": "e.metaKey || \" \" > s || !i || i.indexOf(s) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94697,
+ "nodeLength": 26,
+ "src": "\" \" > s || !i || i.indexOf(s) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94697,
+ "nodeLength": 5,
+ "src": "\" \" > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94704,
+ "nodeLength": 19,
+ "src": "!i || i.indexOf(s) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94708,
+ "nodeLength": 15,
+ "src": "i.indexOf(s) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94797,
+ "nodeLength": 25,
+ "src": "s.input.val() !== s.lastVal",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94886,
+ "nodeLength": 7,
+ "src": "s.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94947,
+ "nodeLength": 105,
+ "src": "i && (t.datepicker._setDateFromField(s) , t.datepicker._updateAlternate(s) , t.datepicker._updateDatepicker(s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95104,
+ "nodeLength": 148,
+ "src": "e = e.target || e , \"input\" !== e.nodeName.toLowerCase() && (e = t(\"input\", e.parentNode)[0]) , !t.datepicker._isDisabledDatepicker(e) && t.datepicker._lastInput !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95106,
+ "nodeLength": 11,
+ "src": "e.target || e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95118,
+ "nodeLength": 66,
+ "src": "\"input\" !== e.nodeName.toLowerCase() && (e = t(\"input\", e.parentNode)[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95118,
+ "nodeLength": 34,
+ "src": "\"input\" !== e.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95185,
+ "nodeLength": 67,
+ "src": "!t.datepicker._isDisabledDatepicker(e) && t.datepicker._lastInput !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95225,
+ "nodeLength": 27,
+ "src": "t.datepicker._lastInput !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95299,
+ "nodeLength": 188,
+ "src": "t.datepicker._curInst && t.datepicker._curInst !== s && (t.datepicker._curInst.dpDiv.stop(!0, !0) , s && t.datepicker._datepickerShowing && t.datepicker._hideDatepicker(t.datepicker._curInst.input[0]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95322,
+ "nodeLength": 165,
+ "src": "t.datepicker._curInst !== s && (t.datepicker._curInst.dpDiv.stop(!0, !0) , s && t.datepicker._datepickerShowing && t.datepicker._hideDatepicker(t.datepicker._curInst.input[0]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95322,
+ "nodeLength": 25,
+ "src": "t.datepicker._curInst !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95390,
+ "nodeLength": 96,
+ "src": "s && t.datepicker._datepickerShowing && t.datepicker._hideDatepicker(t.datepicker._curInst.input[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95393,
+ "nodeLength": 93,
+ "src": "t.datepicker._datepickerShowing && t.datepicker._hideDatepicker(t.datepicker._curInst.input[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95526,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95548,
+ "nodeLength": 1031,
+ "src": "o !== !1 && (a(s.settings, o) , s.lastVal = null , t.datepicker._lastInput = e , t.datepicker._setDateFromField(s) , t.datepicker._inDialog && (e.value = \"\") , t.datepicker._pos || (t.datepicker._pos = t.datepicker._findPos(e) , t.datepicker._pos[1] += e.offsetHeight) , r = !1 , t(e).parents().each(function() {\n return r |= \"fixed\" === t(this).css(\"position\") , !r;\n}) , h = {\n left: t.datepicker._pos[0], \n top: t.datepicker._pos[1]} , t.datepicker._pos = null , s.dpDiv.empty() , s.dpDiv.css({\n position: \"absolute\", \n display: \"block\", \n top: \"-1000px\"}) , t.datepicker._updateDatepicker(s) , h = t.datepicker._checkOffset(s, h, r) , s.dpDiv.css({\n position: t.datepicker._inDialog && t.blockUI ? \"static\" : r ? \"fixed\" : \"absolute\", \n display: \"none\", \n left: h.left + \"px\", \n top: h.top + \"px\"}) , s.inline || (l = t.datepicker._get(s, \"showAnim\") , c = t.datepicker._get(s, \"duration\") , s.dpDiv.css(\"z-index\", i(t(e)) + 1) , t.datepicker._datepickerShowing = !0 , t.effects && t.effects.effect[l] ? s.dpDiv.show(l, t.datepicker._get(s, \"showOptions\"), c) : s.dpDiv[l || \"show\"](l ? c : null) , t.datepicker._shouldFocusInput(s) && s.input.trigger(\"focus\") , t.datepicker._curInst = s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95548,
+ "nodeLength": 6,
+ "src": "o !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95648,
+ "nodeLength": 36,
+ "src": "t.datepicker._inDialog && (e.value = \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95685,
+ "nodeLength": 100,
+ "src": "t.datepicker._pos || (t.datepicker._pos = t.datepicker._findPos(e) , t.datepicker._pos[1] += e.offsetHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95832,
+ "nodeLength": 33,
+ "src": "\"fixed\" === t(this).css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96121,
+ "nodeLength": 33,
+ "src": "t.datepicker._inDialog && t.blockUI",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96164,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96234,
+ "nodeLength": 344,
+ "src": "s.inline || (l = t.datepicker._get(s, \"showAnim\") , c = t.datepicker._get(s, \"duration\") , s.dpDiv.css(\"z-index\", i(t(e)) + 1) , t.datepicker._datepickerShowing = !0 , t.effects && t.effects.effect[l] ? s.dpDiv.show(l, t.datepicker._get(s, \"showOptions\"), c) : s.dpDiv[l || \"show\"](l ? c : null) , t.datepicker._shouldFocusInput(s) && s.input.trigger(\"focus\") , t.datepicker._curInst = s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96381,
+ "nodeLength": 30,
+ "src": "t.effects && t.effects.effect[l]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96473,
+ "nodeLength": 9,
+ "src": "l || \"show\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96484,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96494,
+ "nodeLength": 59,
+ "src": "t.datepicker._shouldFocusInput(s) && s.input.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "9": [
+ null,
+ {
+ "position": 11313,
+ "nodeLength": 29,
+ "src": "r.length > 0 && o.apply(r.get(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11313,
+ "nodeLength": 10,
+ "src": "r.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11442,
+ "nodeLength": 69,
+ "src": "n > 1 && e.dpDiv.addClass(\"ui-datepicker-multi-\" + n).css(\"width\", a * n + \"em\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11442,
+ "nodeLength": 3,
+ "src": "n > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11521,
+ "nodeLength": 18,
+ "src": "1 !== s[0] || 1 !== s[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11521,
+ "nodeLength": 8,
+ "src": "1 !== s[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11531,
+ "nodeLength": 8,
+ "src": "1 !== s[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11597,
+ "nodeLength": 20,
+ "src": "this._get(e, \"isRTL\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11664,
+ "nodeLength": 119,
+ "src": "e === t.datepicker._curInst && t.datepicker._datepickerShowing && t.datepicker._shouldFocusInput(e) && e.input.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11664,
+ "nodeLength": 25,
+ "src": "e === t.datepicker._curInst",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11691,
+ "nodeLength": 92,
+ "src": "t.datepicker._datepickerShowing && t.datepicker._shouldFocusInput(e) && e.input.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11724,
+ "nodeLength": 59,
+ "src": "t.datepicker._shouldFocusInput(e) && e.input.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11784,
+ "nodeLength": 176,
+ "src": "e.yearshtml && (i = e.yearshtml , setTimeout(function() {\n i === e.yearshtml && e.yearshtml && e.dpDiv.find(\"select.ui-datepicker-year:first\").replaceWith(e.yearshtml) , i = e.yearshtml = null;\n}, 0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11834,
+ "nodeLength": 102,
+ "src": "i === e.yearshtml && e.yearshtml && e.dpDiv.find(\"select.ui-datepicker-year:first\").replaceWith(e.yearshtml)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11834,
+ "nodeLength": 15,
+ "src": "i === e.yearshtml",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11851,
+ "nodeLength": 85,
+ "src": "e.yearshtml && e.dpDiv.find(\"select.ui-datepicker-year:first\").replaceWith(e.yearshtml)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11999,
+ "nodeLength": 80,
+ "src": "t.input && t.input.is(\":visible\") && !t.input.is(\":disabled\") && !t.input.is(\":focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12008,
+ "nodeLength": 71,
+ "src": "t.input.is(\":visible\") && !t.input.is(\":disabled\") && !t.input.is(\":focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12032,
+ "nodeLength": 47,
+ "src": "!t.input.is(\":disabled\") && !t.input.is(\":focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12163,
+ "nodeLength": 7,
+ "src": "e.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12196,
+ "nodeLength": 7,
+ "src": "e.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12268,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12339,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12383,
+ "nodeLength": 20,
+ "src": "this._get(e, \"isRTL\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12418,
+ "nodeLength": 33,
+ "src": "s && i.left === e.input.offset().left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12421,
+ "nodeLength": 30,
+ "src": "i.left === e.input.offset().left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12486,
+ "nodeLength": 33,
+ "src": "s && i.top === e.input.offset().top + r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12489,
+ "nodeLength": 30,
+ "src": "i.top === e.input.offset().top + r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12570,
+ "nodeLength": 15,
+ "src": "i.left + n > h && h > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12570,
+ "nodeLength": 10,
+ "src": "i.left + n > h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12582,
+ "nodeLength": 3,
+ "src": "h > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12632,
+ "nodeLength": 14,
+ "src": "i.top + o > l && l > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12632,
+ "nodeLength": 9,
+ "src": "i.top + o > l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12643,
+ "nodeLength": 3,
+ "src": "l > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12740,
+ "nodeLength": 64,
+ "src": "e && (\"hidden\" === e.type || 1 !== e.nodeType || t.expr.filters.hidden(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12744,
+ "nodeLength": 59,
+ "src": "\"hidden\" === e.type || 1 !== e.nodeType || t.expr.filters.hidden(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12744,
+ "nodeLength": 17,
+ "src": "\"hidden\" === e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12763,
+ "nodeLength": 40,
+ "src": "1 !== e.nodeType || t.expr.filters.hidden(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12763,
+ "nodeLength": 14,
+ "src": "1 !== e.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12810,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12940,
+ "nodeLength": 638,
+ "src": "!a || e && a !== t.data(e, \"datepicker\") || this._datepickerShowing && (i = this._get(a, \"showAnim\") , s = this._get(a, \"duration\") , n = function() {\n t.datepicker._tidyDialog(a);\n} , t.effects && (t.effects.effect[i] || t.effects[i]) ? a.dpDiv.hide(i, t.datepicker._get(a, \"showOptions\"), s, n) : a.dpDiv[\"slideDown\" === i ? \"slideUp\" : \"fadeIn\" === i ? \"fadeOut\" : \"hide\"](i ? s : null, n) , i || n() , this._datepickerShowing = !1 , o = this._get(a, \"onClose\") , o && o.apply(a.input ? a.input[0] : null, [a.input ? a.input.val() : \"\", a]) , this._lastInput = null , this._inDialog && (this._dialogInput.css({\n position: \"absolute\", \n left: \"0\", \n top: \"-100px\"}) , t.blockUI && (t.unblockUI() , t(\"body\").append(this.dpDiv))) , this._inDialog = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12944,
+ "nodeLength": 634,
+ "src": "e && a !== t.data(e, \"datepicker\") || this._datepickerShowing && (i = this._get(a, \"showAnim\") , s = this._get(a, \"duration\") , n = function() {\n t.datepicker._tidyDialog(a);\n} , t.effects && (t.effects.effect[i] || t.effects[i]) ? a.dpDiv.hide(i, t.datepicker._get(a, \"showOptions\"), s, n) : a.dpDiv[\"slideDown\" === i ? \"slideUp\" : \"fadeIn\" === i ? \"fadeOut\" : \"hide\"](i ? s : null, n) , i || n() , this._datepickerShowing = !1 , o = this._get(a, \"onClose\") , o && o.apply(a.input ? a.input[0] : null, [a.input ? a.input.val() : \"\", a]) , this._lastInput = null , this._inDialog && (this._dialogInput.css({\n position: \"absolute\", \n left: \"0\", \n top: \"-100px\"}) , t.blockUI && (t.unblockUI() , t(\"body\").append(this.dpDiv))) , this._inDialog = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12944,
+ "nodeLength": 29,
+ "src": "e && a !== t.data(e, \"datepicker\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12947,
+ "nodeLength": 26,
+ "src": "a !== t.data(e, \"datepicker\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12975,
+ "nodeLength": 603,
+ "src": "this._datepickerShowing && (i = this._get(a, \"showAnim\") , s = this._get(a, \"duration\") , n = function() {\n t.datepicker._tidyDialog(a);\n} , t.effects && (t.effects.effect[i] || t.effects[i]) ? a.dpDiv.hide(i, t.datepicker._get(a, \"showOptions\"), s, n) : a.dpDiv[\"slideDown\" === i ? \"slideUp\" : \"fadeIn\" === i ? \"fadeOut\" : \"hide\"](i ? s : null, n) , i || n() , this._datepickerShowing = !1 , o = this._get(a, \"onClose\") , o && o.apply(a.input ? a.input[0] : null, [a.input ? a.input.val() : \"\", a]) , this._lastInput = null , this._inDialog && (this._dialogInput.css({\n position: \"absolute\", \n left: \"0\", \n top: \"-100px\"}) , t.blockUI && (t.unblockUI() , t(\"body\").append(this.dpDiv))) , this._inDialog = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13095,
+ "nodeLength": 46,
+ "src": "t.effects && (t.effects.effect[i] || t.effects[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13107,
+ "nodeLength": 33,
+ "src": "t.effects.effect[i] || t.effects[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13205,
+ "nodeLength": 15,
+ "src": "\"slideDown\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13231,
+ "nodeLength": 12,
+ "src": "\"fadeIn\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13262,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13274,
+ "nodeLength": 6,
+ "src": "i || n()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13333,
+ "nodeLength": 64,
+ "src": "o && o.apply(a.input ? a.input[0] : null, [a.input ? a.input.val() : \"\", a])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13344,
+ "nodeLength": 7,
+ "src": "a.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13369,
+ "nodeLength": 7,
+ "src": "a.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13419,
+ "nodeLength": 140,
+ "src": "this._inDialog && (this._dialogInput.css({\n position: \"absolute\", \n left: \"0\", \n top: \"-100px\"}) , t.blockUI && (t.unblockUI() , t(\"body\").append(this.dpDiv)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13503,
+ "nodeLength": 55,
+ "src": "t.blockUI && (t.unblockUI() , t(\"body\").append(this.dpDiv))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13710,
+ "nodeLength": 21,
+ "src": "t.datepicker._curInst",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13782,
+ "nodeLength": 352,
+ "src": "(i[0].id !== t.datepicker._mainDivId && 0 === i.parents(\"#\" + t.datepicker._mainDivId).length && !i.hasClass(t.datepicker.markerClassName) && !i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI) || i.hasClass(t.datepicker.markerClassName) && t.datepicker._curInst !== s) && t.datepicker._hideDatepicker()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13782,
+ "nodeLength": 319,
+ "src": "i[0].id !== t.datepicker._mainDivId && 0 === i.parents(\"#\" + t.datepicker._mainDivId).length && !i.hasClass(t.datepicker.markerClassName) && !i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI) || i.hasClass(t.datepicker.markerClassName) && t.datepicker._curInst !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13782,
+ "nodeLength": 250,
+ "src": "i[0].id !== t.datepicker._mainDivId && 0 === i.parents(\"#\" + t.datepicker._mainDivId).length && !i.hasClass(t.datepicker.markerClassName) && !i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13782,
+ "nodeLength": 33,
+ "src": "i[0].id !== t.datepicker._mainDivId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13817,
+ "nodeLength": 215,
+ "src": "0 === i.parents(\"#\" + t.datepicker._mainDivId).length && !i.hasClass(t.datepicker.markerClassName) && !i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13817,
+ "nodeLength": 49,
+ "src": "0 === i.parents(\"#\" + t.datepicker._mainDivId).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13868,
+ "nodeLength": 164,
+ "src": "!i.hasClass(t.datepicker.markerClassName) && !i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13911,
+ "nodeLength": 121,
+ "src": "!i.closest(\".\" + t.datepicker._triggerClass).length && t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13962,
+ "nodeLength": 70,
+ "src": "t.datepicker._datepickerShowing && (!t.datepicker._inDialog || !t.blockUI)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13996,
+ "nodeLength": 35,
+ "src": "!t.datepicker._inDialog || !t.blockUI",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14034,
+ "nodeLength": 67,
+ "src": "i.hasClass(t.datepicker.markerClassName) && t.datepicker._curInst !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14076,
+ "nodeLength": 25,
+ "src": "t.datepicker._curInst !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14198,
+ "nodeLength": 133,
+ "src": "this._isDisabledDatepicker(n[0]) || (this._adjustInstDate(o, i + (\"M\" === s ? this._get(o, \"showCurrentAtPos\") : 0), s) , this._updateDatepicker(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14259,
+ "nodeLength": 7,
+ "src": "\"M\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14391,
+ "nodeLength": 40,
+ "src": "this._get(n, \"gotoCurrent\") && n.currentDay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14789,
+ "nodeLength": 7,
+ "src": "\"M\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14824,
+ "nodeLength": 7,
+ "src": "\"M\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14980,
+ "nodeLength": 286,
+ "src": "t(n).hasClass(this._unselectableClass) || this._isDisabledDatepicker(a[0]) || (o = this._getInst(a[0]) , o.selectedDay = o.currentDay = t(\"a\", n).html() , o.selectedMonth = o.currentMonth = i , o.selectedYear = o.currentYear = s , this._selectDate(e, this._formatDate(o, o.currentDay, o.currentMonth, o.currentYear)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15020,
+ "nodeLength": 246,
+ "src": "this._isDisabledDatepicker(a[0]) || (o = this._getInst(a[0]) , o.selectedDay = o.currentDay = t(\"a\", n).html() , o.selectedMonth = o.currentMonth = i , o.selectedYear = o.currentYear = s , this._selectDate(e, this._formatDate(o, o.currentDay, o.currentMonth, o.currentYear)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15389,
+ "nodeLength": 7,
+ "src": "null != i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15419,
+ "nodeLength": 23,
+ "src": "o.input && o.input.val(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15494,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15504,
+ "nodeLength": 7,
+ "src": "o.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15535,
+ "nodeLength": 34,
+ "src": "o.input && o.input.trigger(\"change\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15570,
+ "nodeLength": 8,
+ "src": "o.inline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15656,
+ "nodeLength": 53,
+ "src": "\"object\" != typeof o.input[0] && o.input.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15656,
+ "nodeLength": 27,
+ "src": "\"object\" != typeof o.input[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15798,
+ "nodeLength": 137,
+ "src": "o && (i = this._get(e, \"altFormat\") || this._get(e, \"dateFormat\") , s = this._getDate(e) , n = this.formatDate(i, s, this._getFormatConfig(e)) , t(o).val(n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15804,
+ "nodeLength": 51,
+ "src": "this._get(e, \"altFormat\") || this._get(e, \"dateFormat\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15984,
+ "nodeLength": 8,
+ "src": "e > 0 && 6 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 3,
+ "src": "e > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4,
+ "nodeLength": 3,
+ "src": "6 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16084,
+ "nodeLength": 13,
+ "src": "i.getDay() || 7",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16211,
+ "nodeLength": 16,
+ "src": "null == e || null == i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16211,
+ "nodeLength": 7,
+ "src": "null == e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16220,
+ "nodeLength": 7,
+ "src": "null == i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16256,
+ "nodeLength": 37,
+ "src": "i = \"object\" == typeof i ? \"\" + i : i + \"\" , \"\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16258,
+ "nodeLength": 18,
+ "src": "\"object\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16287,
+ "nodeLength": 6,
+ "src": "\"\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16325,
+ "nodeLength": 57,
+ "src": "(s ? s.shortYearCutoff : null) || this._defaults.shortYearCutoff",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16325,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16385,
+ "nodeLength": 18,
+ "src": "\"string\" != typeof l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16453,
+ "nodeLength": 53,
+ "src": "(s ? s.dayNamesShort : null) || this._defaults.dayNamesShort",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16453,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16510,
+ "nodeLength": 43,
+ "src": "(s ? s.dayNames : null) || this._defaults.dayNames",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16510,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16557,
+ "nodeLength": 57,
+ "src": "(s ? s.monthNamesShort : null) || this._defaults.monthNamesShort",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16557,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16618,
+ "nodeLength": 47,
+ "src": "(s ? s.monthNames : null) || this._defaults.monthNames",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16618,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16711,
+ "nodeLength": 31,
+ "src": "e.length > n + 1 && e.charAt(n + 1) === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16711,
+ "nodeLength": 12,
+ "src": "e.length > n + 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16725,
+ "nodeLength": 17,
+ "src": "e.charAt(n + 1) === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16750,
+ "nodeLength": 6,
+ "src": "i && n++",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16787,
+ "nodeLength": 7,
+ "src": "\"@\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16798,
+ "nodeLength": 7,
+ "src": "\"!\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16809,
+ "nodeLength": 10,
+ "src": "\"y\" === t && e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16809,
+ "nodeLength": 7,
+ "src": "\"y\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16822,
+ "nodeLength": 7,
+ "src": "\"o\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16836,
+ "nodeLength": 7,
+ "src": "\"y\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16907,
+ "nodeLength": 2,
+ "src": "!a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17023,
+ "nodeLength": 4,
+ "src": "y(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17119,
+ "nodeLength": 133,
+ "src": "t.each(a, function(t, e) {\n var s = e[1];\n return i.substr(h, s.length).toLowerCase() === s.toLowerCase() ? (o = e[0] , h += s.length , !1) : void 0;\n}) , -1 !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17160,
+ "nodeLength": 52,
+ "src": "i.substr(h, s.length).toLowerCase() === s.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17246,
+ "nodeLength": 6,
+ "src": "-1 !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17316,
+ "nodeLength": 25,
+ "src": "i.charAt(h) !== e.charAt(n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17396,
+ "nodeLength": 10,
+ "src": "e.length > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17414,
+ "nodeLength": 1,
+ "src": "b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17416,
+ "nodeLength": 25,
+ "src": "\"'\" !== e.charAt(n) || y(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17416,
+ "nodeLength": 17,
+ "src": "\"'\" !== e.charAt(n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17816,
+ "nodeLength": 6,
+ "src": "y(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17853,
+ "nodeLength": 43,
+ "src": "i.length > h && (a = i.substr(h) , !/^\\s+/.test(a))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17853,
+ "nodeLength": 10,
+ "src": "i.length > h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17951,
+ "nodeLength": 118,
+ "src": "-1 === g ? g = (new Date()).getFullYear() : 100 > g && (g += (new Date()).getFullYear() - (new Date()).getFullYear() % 100 + (c >= g ? 0 : -100)) , v > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17951,
+ "nodeLength": 6,
+ "src": "-1 === g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17985,
+ "nodeLength": 79,
+ "src": "100 > g && (g += (new Date()).getFullYear() - (new Date()).getFullYear() % 100 + (c >= g ? 0 : -100))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17985,
+ "nodeLength": 5,
+ "src": "100 > g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18051,
+ "nodeLength": 4,
+ "src": "c >= g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18065,
+ "nodeLength": 4,
+ "src": "v > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18088,
+ "nodeLength": 34,
+ "src": "o = this._getDaysInMonth(g, m - 1) , o >= _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18118,
+ "nodeLength": 4,
+ "src": "o >= _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18141,
+ "nodeLength": 104,
+ "src": "r = this._daylightSavingAdjust(new Date(g, m - 1, _)) , r.getFullYear() !== g || r.getMonth() + 1 !== m || r.getDate() !== _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18189,
+ "nodeLength": 56,
+ "src": "r.getFullYear() !== g || r.getMonth() + 1 !== m || r.getDate() !== _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18189,
+ "nodeLength": 19,
+ "src": "r.getFullYear() !== g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18210,
+ "nodeLength": 35,
+ "src": "r.getMonth() + 1 !== m || r.getDate() !== _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18210,
+ "nodeLength": 18,
+ "src": "r.getMonth() + 1 !== m",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18230,
+ "nodeLength": 15,
+ "src": "r.getDate() !== _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18606,
+ "nodeLength": 2,
+ "src": "!e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18627,
+ "nodeLength": 53,
+ "src": "(i ? i.dayNamesShort : null) || this._defaults.dayNamesShort",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18627,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18684,
+ "nodeLength": 43,
+ "src": "(i ? i.dayNames : null) || this._defaults.dayNames",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18684,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18731,
+ "nodeLength": 57,
+ "src": "(i ? i.monthNamesShort : null) || this._defaults.monthNamesShort",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18731,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18792,
+ "nodeLength": 47,
+ "src": "(i ? i.monthNames : null) || this._defaults.monthNames",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18792,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18860,
+ "nodeLength": 31,
+ "src": "t.length > s + 1 && t.charAt(s + 1) === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18860,
+ "nodeLength": 12,
+ "src": "t.length > s + 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18874,
+ "nodeLength": 17,
+ "src": "t.charAt(s + 1) === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18899,
+ "nodeLength": 6,
+ "src": "i && s++",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18941,
+ "nodeLength": 4,
+ "src": "h(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18951,
+ "nodeLength": 10,
+ "src": "i > s.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19008,
+ "nodeLength": 4,
+ "src": "h(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19037,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19047,
+ "nodeLength": 10,
+ "src": "t.length > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19065,
+ "nodeLength": 1,
+ "src": "d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19067,
+ "nodeLength": 25,
+ "src": "\"'\" !== t.charAt(s) || h(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19067,
+ "nodeLength": 17,
+ "src": "\"'\" !== t.charAt(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19455,
+ "nodeLength": 6,
+ "src": "h(\"y\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19479,
+ "nodeLength": 22,
+ "src": "10 > e.getFullYear() % 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19624,
+ "nodeLength": 6,
+ "src": "h(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19745,
+ "nodeLength": 31,
+ "src": "t.length > e + 1 && t.charAt(e + 1) === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19745,
+ "nodeLength": 12,
+ "src": "t.length > e + 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19759,
+ "nodeLength": 17,
+ "src": "t.charAt(e + 1) === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19784,
+ "nodeLength": 6,
+ "src": "s && e++",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19802,
+ "nodeLength": 10,
+ "src": "t.length > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19820,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19822,
+ "nodeLength": 25,
+ "src": "\"'\" !== t.charAt(e) || n(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19822,
+ "nodeLength": 17,
+ "src": "\"'\" !== t.charAt(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19983,
+ "nodeLength": 6,
+ "src": "n(\"'\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20067,
+ "nodeLength": 22,
+ "src": "void 0 !== t.settings[e]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20158,
+ "nodeLength": 25,
+ "src": "t.input.val() !== t.lastVal",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20229,
+ "nodeLength": 7,
+ "src": "t.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20319,
+ "nodeLength": 24,
+ "src": "this.parseDate(i, s, a) || n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20355,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20484,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20515,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20546,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20949,
+ "nodeLength": 68,
+ "src": "(i.toLowerCase().match(/^c/) ? t.datepicker._getDate(e) : null) || new Date()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20949,
+ "nodeLength": 27,
+ "src": "i.toLowerCase().match(/^c/)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21118,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21129,
+ "nodeLength": 9,
+ "src": "l[2] || \"d\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21442,
+ "nodeLength": 15,
+ "src": "null == i || \"\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21442,
+ "nodeLength": 7,
+ "src": "null == i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21451,
+ "nodeLength": 6,
+ "src": "\"\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21460,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21484,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21503,
+ "nodeLength": 8,
+ "src": "isNaN(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21550,
+ "nodeLength": 23,
+ "src": "a && \"Invalid Date\" == \"\" + a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21553,
+ "nodeLength": 20,
+ "src": "\"Invalid Date\" == \"\" + a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21578,
+ "nodeLength": 71,
+ "src": "a && (a.setHours(0) , a.setMinutes(0) , a.setSeconds(0) , a.setMilliseconds(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21722,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21736,
+ "nodeLength": 15,
+ "src": "t.getHours() > 12",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22059,
+ "nodeLength": 65,
+ "src": "n === t.selectedMonth && o === t.selectedYear || i || this._notifyChange(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22059,
+ "nodeLength": 39,
+ "src": "n === t.selectedMonth && o === t.selectedYear",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22059,
+ "nodeLength": 19,
+ "src": "n === t.selectedMonth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22080,
+ "nodeLength": 18,
+ "src": "o === t.selectedYear",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22100,
+ "nodeLength": 24,
+ "src": "i || this._notifyChange(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22149,
+ "nodeLength": 46,
+ "src": "t.input && t.input.val(s ? \"\" : this._formatDate(t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22170,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22224,
+ "nodeLength": 43,
+ "src": "!t.currentYear || t.input && \"\" === t.input.val()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22240,
+ "nodeLength": 27,
+ "src": "t.input && \"\" === t.input.val()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22249,
+ "nodeLength": 18,
+ "src": "\"\" === t.input.val()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23494,
+ "nodeLength": 18,
+ "src": "1 !== U[0] || 1 !== U[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23494,
+ "nodeLength": 8,
+ "src": "1 !== U[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23504,
+ "nodeLength": 8,
+ "src": "1 !== U[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23542,
+ "nodeLength": 12,
+ "src": "t.currentDay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23722,
+ "nodeLength": 19,
+ "src": "0 > Z && (Z += 12 , te--) , J",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23722,
+ "nodeLength": 17,
+ "src": "0 > Z && (Z += 12 , te--)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23722,
+ "nodeLength": 3,
+ "src": "0 > Z",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23841,
+ "nodeLength": 6,
+ "src": "Q && Q > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23844,
+ "nodeLength": 3,
+ "src": "Q > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23852,
+ "nodeLength": 46,
+ "src": "this._daylightSavingAdjust(new Date(te, Z, 1)) > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23904,
+ "nodeLength": 16,
+ "src": "0 > Z && (Z = 11 , te--)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23904,
+ "nodeLength": 3,
+ "src": "0 > Z",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23981,
+ "nodeLength": 1,
+ "src": "K",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24078,
+ "nodeLength": 31,
+ "src": "this._canAdjustMonth(t, -1, te, Z)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24256,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24288,
+ "nodeLength": 1,
+ "src": "q",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24418,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24478,
+ "nodeLength": 1,
+ "src": "K",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24575,
+ "nodeLength": 30,
+ "src": "this._canAdjustMonth(t, 1, te, Z)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24752,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24784,
+ "nodeLength": 1,
+ "src": "q",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24914,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24977,
+ "nodeLength": 40,
+ "src": "this._get(t, \"gotoCurrent\") && t.currentDay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25024,
+ "nodeLength": 1,
+ "src": "K",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25076,
+ "nodeLength": 8,
+ "src": "t.inline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25270,
+ "nodeLength": 1,
+ "src": "j",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25332,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25341,
+ "nodeLength": 20,
+ "src": "this._isInRange(t, r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25529,
+ "nodeLength": 1,
+ "src": "Y",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25590,
+ "nodeLength": 8,
+ "src": "isNaN(c)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25879,
+ "nodeLength": 6,
+ "src": "U[0] > k",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25919,
+ "nodeLength": 6,
+ "src": "U[1] > C",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25934,
+ "nodeLength": 84,
+ "src": "D = this._daylightSavingAdjust(new Date(te, Z, t.selectedDay)) , I = \" ui-corner-all\" , T = \"\" , X",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26023,
+ "nodeLength": 43,
+ "src": "T += \"\" + r[c] + \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28884,
+ "nodeLength": 19,
+ "src": "!h || c >= s.getMonth()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28888,
+ "nodeLength": 15,
+ "src": "c >= s.getMonth()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28907,
+ "nodeLength": 105,
+ "src": "(!l || n.getMonth() >= c) && (y += \"
\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28907,
+ "nodeLength": 19,
+ "src": "!l || n.getMonth() >= c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28911,
+ "nodeLength": 15,
+ "src": "n.getMonth() >= c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28958,
+ "nodeLength": 5,
+ "src": "c === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29031,
+ "nodeLength": 45,
+ "src": "v || (b += y + (!o && m && _ ? \"\" : \" \")) , !t.yearshtml",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29031,
+ "nodeLength": 32,
+ "src": "v || (b += y + (!o && m && _ ? \"\" : \" \"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29041,
+ "nodeLength": 8,
+ "src": "!o && m && _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29045,
+ "nodeLength": 4,
+ "src": "m && _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29080,
+ "nodeLength": 20,
+ "src": "t.yearshtml = \"\" , o || !_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29095,
+ "nodeLength": 5,
+ "src": "o || !_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29246,
+ "nodeLength": 19,
+ "src": "t.match(/c[+\\-].*/)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29296,
+ "nodeLength": 18,
+ "src": "t.match(/[+\\-].*/)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29354,
+ "nodeLength": 8,
+ "src": "isNaN(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29393,
+ "nodeLength": 8,
+ "src": "u[1] || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29406,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29440,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29569,
+ "nodeLength": 4,
+ "src": "g >= f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29616,
+ "nodeLength": 5,
+ "src": "f === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29760,
+ "nodeLength": 32,
+ "src": "v && (b += (!o && m && _ ? \"\" : \" \") + y)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29768,
+ "nodeLength": 8,
+ "src": "!o && m && _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29772,
+ "nodeLength": 4,
+ "src": "m && _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29860,
+ "nodeLength": 7,
+ "src": "\"Y\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29892,
+ "nodeLength": 7,
+ "src": "\"M\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29958,
+ "nodeLength": 7,
+ "src": "\"D\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30151,
+ "nodeLength": 40,
+ "src": "(\"M\" === i || \"Y\" === i) && this._notifyChange(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30151,
+ "nodeLength": 16,
+ "src": "\"M\" === i || \"Y\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30151,
+ "nodeLength": 7,
+ "src": "\"M\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30160,
+ "nodeLength": 7,
+ "src": "\"Y\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30291,
+ "nodeLength": 6,
+ "src": "i && i > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30294,
+ "nodeLength": 3,
+ "src": "i > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30309,
+ "nodeLength": 6,
+ "src": "s && n > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30312,
+ "nodeLength": 3,
+ "src": "n > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30386,
+ "nodeLength": 72,
+ "src": "e && e.apply(t.input ? t.input[0] : null, [t.selectedYear, t.selectedMonth + 1, t])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30397,
+ "nodeLength": 7,
+ "src": "t.input",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30534,
+ "nodeLength": 7,
+ "src": "null == e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30548,
+ "nodeLength": 18,
+ "src": "\"number\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30936,
+ "nodeLength": 3,
+ "src": "0 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30964,
+ "nodeLength": 66,
+ "src": "0 > e && o.setDate(this._getDaysInMonth(o.getFullYear(), o.getMonth()))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30964,
+ "nodeLength": 3,
+ "src": "0 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31196,
+ "nodeLength": 146,
+ "src": "h && (i = h.split(\":\") , s = (new Date()).getFullYear() , a = parseInt(i[0], 10) , r = parseInt(i[1], 10) , i[0].match(/[+\\-].*/) && (a += s) , i[1].match(/[+\\-].*/) && (r += s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31282,
+ "nodeLength": 29,
+ "src": "i[0].match(/[+\\-].*/) && (a += s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31312,
+ "nodeLength": 29,
+ "src": "i[1].match(/[+\\-].*/) && (r += s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31344,
+ "nodeLength": 113,
+ "src": "(!n || e.getTime() >= n.getTime()) && (!o || e.getTime() <= o.getTime()) && (!a || e.getFullYear() >= a) && (!r || r >= e.getFullYear())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31344,
+ "nodeLength": 28,
+ "src": "!n || e.getTime() >= n.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31348,
+ "nodeLength": 24,
+ "src": "e.getTime() >= n.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31376,
+ "nodeLength": 81,
+ "src": "(!o || e.getTime() <= o.getTime()) && (!a || e.getFullYear() >= a) && (!r || r >= e.getFullYear())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31376,
+ "nodeLength": 28,
+ "src": "!o || e.getTime() <= o.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31380,
+ "nodeLength": 24,
+ "src": "e.getTime() <= o.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31408,
+ "nodeLength": 49,
+ "src": "(!a || e.getFullYear() >= a) && (!r || r >= e.getFullYear())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31408,
+ "nodeLength": 22,
+ "src": "!a || e.getFullYear() >= a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31412,
+ "nodeLength": 18,
+ "src": "e.getFullYear() >= a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31434,
+ "nodeLength": 22,
+ "src": "!r || r >= e.getFullYear()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31438,
+ "nodeLength": 18,
+ "src": "r >= e.getFullYear()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31534,
+ "nodeLength": 18,
+ "src": "\"string\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31810,
+ "nodeLength": 91,
+ "src": "e || (t.currentDay = t.selectedDay , t.currentMonth = t.selectedMonth , t.currentYear = t.selectedYear)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31908,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31910,
+ "nodeLength": 18,
+ "src": "\"object\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32187,
+ "nodeLength": 12,
+ "src": "!this.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32212,
+ "nodeLength": 116,
+ "src": "t.datepicker.initialized || (t(document).on(\"mousedown\", t.datepicker._checkExternalClick) , t.datepicker.initialized = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32329,
+ "nodeLength": 79,
+ "src": "0 === t(\"#\" + t.datepicker._mainDivId).length && t(\"body\").append(t.datepicker.dpDiv)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32329,
+ "nodeLength": 41,
+ "src": "0 === t(\"#\" + t.datepicker._mainDivId).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32461,
+ "nodeLength": 65,
+ "src": "\"string\" != typeof e || \"isDisabled\" !== e && \"getDate\" !== e && \"widget\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32461,
+ "nodeLength": 18,
+ "src": "\"string\" != typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32481,
+ "nodeLength": 45,
+ "src": "\"isDisabled\" !== e && \"getDate\" !== e && \"widget\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32481,
+ "nodeLength": 16,
+ "src": "\"isDisabled\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32499,
+ "nodeLength": 27,
+ "src": "\"getDate\" !== e && \"widget\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32499,
+ "nodeLength": 13,
+ "src": "\"getDate\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32514,
+ "nodeLength": 12,
+ "src": "\"widget\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32527,
+ "nodeLength": 65,
+ "src": "\"option\" === e && 2 === arguments.length && \"string\" == typeof arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32527,
+ "nodeLength": 12,
+ "src": "\"option\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32541,
+ "nodeLength": 51,
+ "src": "2 === arguments.length && \"string\" == typeof arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32541,
+ "nodeLength": 20,
+ "src": "2 === arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32563,
+ "nodeLength": 29,
+ "src": "\"string\" == typeof arguments[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32687,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 118872,
+ "nodeLength": 55,
+ "src": "!0 === t.data(i.target, e.widgetName + \".preventClickEvent\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119108,
+ "nodeLength": 150,
+ "src": "this._mouseMoveDelegate && this.document.off(\"mousemove.\" + this.widgetName, this._mouseMoveDelegate).off(\"mouseup.\" + this.widgetName, this._mouseUpDelegate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119286,
+ "nodeLength": 2,
+ "src": "!_",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119310,
+ "nodeLength": 36,
+ "src": "this._mouseStarted && this._mouseUp(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119383,
+ "nodeLength": 11,
+ "src": "1 === e.which",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119397,
+ "nodeLength": 55,
+ "src": "\"string\" == typeof this.options.cancel && e.target.nodeName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119397,
+ "nodeLength": 36,
+ "src": "\"string\" == typeof this.options.cancel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119511,
+ "nodeLength": 28,
+ "src": "s && !n && this._mouseCapture(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119514,
+ "nodeLength": 25,
+ "src": "!n && this._mouseCapture(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119580,
+ "nodeLength": 105,
+ "src": "this.mouseDelayMet || (this._mouseDelayTimer = setTimeout(function() {\n i.mouseDelayMet = !0;\n}, this.options.delay))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119686,
+ "nodeLength": 116,
+ "src": "this._mouseDistanceMet(e) && this._mouseDelayMet(e) && (this._mouseStarted = this._mouseStart(e) !== !1 , !this._mouseStarted)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119713,
+ "nodeLength": 89,
+ "src": "this._mouseDelayMet(e) && (this._mouseStarted = this._mouseStart(e) !== !1 , !this._mouseStarted)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119757,
+ "nodeLength": 24,
+ "src": "this._mouseStart(e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119828,
+ "nodeLength": 119,
+ "src": "!0 === t.data(e.target, this.widgetName + \".preventClickEvent\") && t.removeData(e.target, this.widgetName + \".preventClickEvent\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 119828,
+ "nodeLength": 58,
+ "src": "!0 === t.data(e.target, this.widgetName + \".preventClickEvent\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120248,
+ "nodeLength": 16,
+ "src": "this._mouseMoved",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120269,
+ "nodeLength": 69,
+ "src": "t.ui.ie && (!document.documentMode || 9 > document.documentMode) && !e.button",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120279,
+ "nodeLength": 59,
+ "src": "(!document.documentMode || 9 > document.documentMode) && !e.button",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120279,
+ "nodeLength": 47,
+ "src": "!document.documentMode || 9 > document.documentMode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120303,
+ "nodeLength": 23,
+ "src": "9 > document.documentMode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120366,
+ "nodeLength": 8,
+ "src": "!e.which",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120378,
+ "nodeLength": 98,
+ "src": "e.originalEvent.altKey || e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120402,
+ "nodeLength": 74,
+ "src": "e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120427,
+ "nodeLength": 49,
+ "src": "e.originalEvent.metaKey || e.originalEvent.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120512,
+ "nodeLength": 24,
+ "src": "!this.ignoreMissingWhich",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120568,
+ "nodeLength": 41,
+ "src": "(e.which || e.button) && (this._mouseMoved = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120568,
+ "nodeLength": 17,
+ "src": "e.which || e.button",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120610,
+ "nodeLength": 18,
+ "src": "this._mouseStarted",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120670,
+ "nodeLength": 172,
+ "src": "this._mouseDistanceMet(e) && this._mouseDelayMet(e) && (this._mouseStarted = this._mouseStart(this._mouseDownEvent, e) !== !1 , this._mouseStarted ? this._mouseDrag(e) : this._mouseUp(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120697,
+ "nodeLength": 145,
+ "src": "this._mouseDelayMet(e) && (this._mouseStarted = this._mouseStart(this._mouseDownEvent, e) !== !1 , this._mouseStarted ? this._mouseDrag(e) : this._mouseUp(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120741,
+ "nodeLength": 45,
+ "src": "this._mouseStart(this._mouseDownEvent, e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120787,
+ "nodeLength": 18,
+ "src": "this._mouseStarted",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121012,
+ "nodeLength": 159,
+ "src": "this._mouseStarted && (this._mouseStarted = !1 , e.target === this._mouseDownEvent.target && t.data(e.target, this.widgetName + \".preventClickEvent\", !0) , this._mouseStop(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121055,
+ "nodeLength": 96,
+ "src": "e.target === this._mouseDownEvent.target && t.data(e.target, this.widgetName + \".preventClickEvent\", !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121055,
+ "nodeLength": 38,
+ "src": "e.target === this._mouseDownEvent.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121172,
+ "nodeLength": 89,
+ "src": "this._mouseDelayTimer && (clearTimeout(this._mouseDelayTimer) , delete this._mouseDelayTimer)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121351,
+ "nodeLength": 122,
+ "src": "Math.max(Math.abs(this._mouseDownEvent.pageX - t.pageX), Math.abs(this._mouseDownEvent.pageY - t.pageY)) >= this.options.distance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121721,
+ "nodeLength": 16,
+ "src": "o.plugins[n] || []",
+ "evalFalse": 0,
+ "evalTrue": 25
+ },
+ {
+ "position": 121814,
+ "nodeLength": 70,
+ "src": "o && (s || t.element[0].parentNode && 11 !== t.element[0].parentNode.nodeType)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121818,
+ "nodeLength": 65,
+ "src": "s || t.element[0].parentNode && 11 !== t.element[0].parentNode.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121821,
+ "nodeLength": 62,
+ "src": "t.element[0].parentNode && 11 !== t.element[0].parentNode.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121846,
+ "nodeLength": 37,
+ "src": "11 !== t.element[0].parentNode.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121893,
+ "nodeLength": 10,
+ "src": "o.length > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121908,
+ "nodeLength": 46,
+ "src": "t.options[o[n][0]] && o[n][1].apply(t.element, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121983,
+ "nodeLength": 58,
+ "src": "e && \"body\" !== e.nodeName.toLowerCase() && t(e).trigger(\"blur\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121986,
+ "nodeLength": 55,
+ "src": "\"body\" !== e.nodeName.toLowerCase() && t(e).trigger(\"blur\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 121986,
+ "nodeLength": 33,
+ "src": "\"body\" !== e.nodeName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122514,
+ "nodeLength": 61,
+ "src": "\"original\" === this.options.helper && this._setPositionRelative()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122514,
+ "nodeLength": 32,
+ "src": "\"original\" === this.options.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122576,
+ "nodeLength": 55,
+ "src": "this.options.addClasses && this._addClass(\"ui-draggable\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122720,
+ "nodeLength": 72,
+ "src": "\"handle\" === t && (this._removeHandleClassName() , this._setHandleClassName())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122720,
+ "nodeLength": 12,
+ "src": "\"handle\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122821,
+ "nodeLength": 55,
+ "src": "(this.helper || this.element).is(\".ui-draggable-dragging\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122821,
+ "nodeLength": 25,
+ "src": "this.helper || this.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123022,
+ "nodeLength": 77,
+ "src": "this.helper || i.disabled || t(e.target).closest(\".ui-resizable-handle\").length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123035,
+ "nodeLength": 64,
+ "src": "i.disabled || t(e.target).closest(\".ui-resizable-handle\").length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123047,
+ "nodeLength": 52,
+ "src": "t(e.target).closest(\".ui-resizable-handle\").length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123135,
+ "nodeLength": 11,
+ "src": "this.handle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123193,
+ "nodeLength": 16,
+ "src": "i.iframeFix === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123508,
+ "nodeLength": 72,
+ "src": "this.iframeBlocks && (this.iframeBlocks.remove() , delete this.iframeBlocks)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123674,
+ "nodeLength": 37,
+ "src": "s.closest(i).length || t.ui.safeBlur(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 123880,
+ "nodeLength": 45,
+ "src": "t.ui.ddmanager && (t.ui.ddmanager.current = this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124106,
+ "nodeLength": 90,
+ "src": "this.helper.parents().filter(function() {\n return \"fixed\" === t(this).css(\"position\");\n}).length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124152,
+ "nodeLength": 33,
+ "src": "\"fixed\" === t(this).css(\"position\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124379,
+ "nodeLength": 52,
+ "src": "i.cursorAt && this._adjustOffsetFromHelper(i.cursorAt)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124455,
+ "nodeLength": 29,
+ "src": "this._trigger(\"start\", e) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124536,
+ "nodeLength": 71,
+ "src": "t.ui.ddmanager && !i.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124552,
+ "nodeLength": 55,
+ "src": "!i.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124630,
+ "nodeLength": 48,
+ "src": "t.ui.ddmanager && t.ui.ddmanager.dragStart(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124996,
+ "nodeLength": 166,
+ "src": "this.hasFixedAncestor && (this.offset.parent = this._getParentOffset()) , this.position = this._generatePosition(e, !0) , this.positionAbs = this._convertPositionTo(\"absolute\") , !i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 124996,
+ "nodeLength": 67,
+ "src": "this.hasFixedAncestor && (this.offset.parent = this._getParentOffset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125188,
+ "nodeLength": 30,
+ "src": "this._trigger(\"drag\", e, s) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125399,
+ "nodeLength": 43,
+ "src": "t.ui.ddmanager && t.ui.ddmanager.drag(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125493,
+ "nodeLength": 76,
+ "src": "t.ui.ddmanager && !this.options.dropBehaviour && (s = t.ui.ddmanager.drop(this, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125509,
+ "nodeLength": 60,
+ "src": "!this.options.dropBehaviour && (s = t.ui.ddmanager.drop(this, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125570,
+ "nodeLength": 46,
+ "src": "this.dropped && (s = this.dropped , this.dropped = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125617,
+ "nodeLength": 172,
+ "src": "\"invalid\" === this.options.revert && !s || \"valid\" === this.options.revert && s || this.options.revert === !0 || t.isFunction(this.options.revert) && this.options.revert.call(this.element, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125617,
+ "nodeLength": 35,
+ "src": "\"invalid\" === this.options.revert && !s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125617,
+ "nodeLength": 31,
+ "src": "\"invalid\" === this.options.revert",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125654,
+ "nodeLength": 135,
+ "src": "\"valid\" === this.options.revert && s || this.options.revert === !0 || t.isFunction(this.options.revert) && this.options.revert.call(this.element, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125654,
+ "nodeLength": 32,
+ "src": "\"valid\" === this.options.revert && s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125654,
+ "nodeLength": 29,
+ "src": "\"valid\" === this.options.revert",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125688,
+ "nodeLength": 101,
+ "src": "this.options.revert === !0 || t.isFunction(this.options.revert) && this.options.revert.call(this.element, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125688,
+ "nodeLength": 24,
+ "src": "this.options.revert === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125714,
+ "nodeLength": 75,
+ "src": "t.isFunction(this.options.revert) && this.options.revert.call(this.element, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125887,
+ "nodeLength": 37,
+ "src": "i._trigger(\"stop\", e) !== !1 && i._clear()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125887,
+ "nodeLength": 25,
+ "src": "i._trigger(\"stop\", e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125927,
+ "nodeLength": 43,
+ "src": "this._trigger(\"stop\", e) !== !1 && this._clear()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125927,
+ "nodeLength": 28,
+ "src": "this._trigger(\"stop\", e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126025,
+ "nodeLength": 47,
+ "src": "t.ui.ddmanager && t.ui.ddmanager.dragStop(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126073,
+ "nodeLength": 62,
+ "src": "this.handleElement.is(e.target) && this.element.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126205,
+ "nodeLength": 40,
+ "src": "this.helper.is(\".ui-draggable-dragging\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126359,
+ "nodeLength": 19,
+ "src": "this.options.handle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126502,
+ "nodeLength": 19,
+ "src": "this.options.handle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126799,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126840,
+ "nodeLength": 18,
+ "src": "\"clone\" === i.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126917,
+ "nodeLength": 97,
+ "src": "n.parents(\"body\").length || n.appendTo(\"parent\" === i.appendTo ? this.element[0].parentNode : i.appendTo)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 126954,
+ "nodeLength": 21,
+ "src": "\"parent\" === i.appendTo",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127015,
+ "nodeLength": 54,
+ "src": "s && n[0] === this.element[0] && this._setPositionRelative()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127018,
+ "nodeLength": 51,
+ "src": "n[0] === this.element[0] && this._setPositionRelative()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127018,
+ "nodeLength": 22,
+ "src": "n[0] === this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127070,
+ "nodeLength": 96,
+ "src": "n[0] === this.element[0] || /(fixed|absolute)/.test(n.css(\"position\")) || n.css(\"position\", \"absolute\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127070,
+ "nodeLength": 22,
+ "src": "n[0] === this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127094,
+ "nodeLength": 72,
+ "src": "/(fixed|absolute)/.test(n.css(\"position\")) || n.css(\"position\", \"absolute\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127202,
+ "nodeLength": 92,
+ "src": "/^(?:r|a|f)/.test(this.element.css(\"position\")) || (this.element[0].style.position = \"relative\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127332,
+ "nodeLength": 36,
+ "src": "\"string\" == typeof e && (e = e.split(\" \"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127332,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127369,
+ "nodeLength": 43,
+ "src": "t.isArray(e) && (e = {\n left: +e[0], \n top: +e[1] || 0})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127402,
+ "nodeLength": 8,
+ "src": "+e[1] || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127413,
+ "nodeLength": 61,
+ "src": "\"left\" in e && (this.offset.click.left = e.left + this.margins.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127475,
+ "nodeLength": 92,
+ "src": "\"right\" in e && (this.offset.click.left = this.helperProportions.width - e.right + this.margins.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127568,
+ "nodeLength": 57,
+ "src": "\"top\" in e && (this.offset.click.top = e.top + this.margins.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127626,
+ "nodeLength": 93,
+ "src": "\"bottom\" in e && (this.offset.click.top = this.helperProportions.height - e.bottom + this.margins.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127751,
+ "nodeLength": 52,
+ "src": "/(html|body)/i.test(t.tagName) || t === this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127783,
+ "nodeLength": 20,
+ "src": "t === this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127891,
+ "nodeLength": 189,
+ "src": "\"absolute\" === this.cssPosition && this.scrollParent[0] !== i && t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127891,
+ "nodeLength": 29,
+ "src": "\"absolute\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127922,
+ "nodeLength": 158,
+ "src": "this.scrollParent[0] !== i && t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127922,
+ "nodeLength": 24,
+ "src": "this.scrollParent[0] !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 127948,
+ "nodeLength": 132,
+ "src": "t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128081,
+ "nodeLength": 58,
+ "src": "this._isRootNode(this.offsetParent[0]) && (e = {\n top: 0, \n left: 0})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128152,
+ "nodeLength": 55,
+ "src": "parseInt(this.offsetParent.css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128222,
+ "nodeLength": 56,
+ "src": "parseInt(this.offsetParent.css(\"borderLeftWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128315,
+ "nodeLength": 29,
+ "src": "\"relative\" !== this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128455,
+ "nodeLength": 38,
+ "src": "parseInt(this.helper.css(\"top\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128496,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128544,
+ "nodeLength": 39,
+ "src": "parseInt(this.helper.css(\"left\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128586,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "10": [
+ null,
+ {
+ "position": 6590,
+ "nodeLength": 46,
+ "src": "parseInt(this.element.css(\"marginLeft\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6641,
+ "nodeLength": 45,
+ "src": "parseInt(this.element.css(\"marginTop\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6693,
+ "nodeLength": 47,
+ "src": "parseInt(this.element.css(\"marginRight\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6748,
+ "nodeLength": 48,
+ "src": "parseInt(this.element.css(\"marginBottom\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7030,
+ "nodeLength": 13,
+ "src": "n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7044,
+ "nodeLength": 24,
+ "src": "\"window\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7342,
+ "nodeLength": 50,
+ "src": "t(window).height() || o.body.parentNode.scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7450,
+ "nodeLength": 26,
+ "src": "\"document\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7561,
+ "nodeLength": 45,
+ "src": "t(o).height() || o.body.parentNode.scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7664,
+ "nodeLength": 33,
+ "src": "n.containment.constructor === Array",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7739,
+ "nodeLength": 67,
+ "src": "\"parent\" === n.containment && (n.containment = this.helper[0].parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7739,
+ "nodeLength": 24,
+ "src": "\"parent\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7833,
+ "nodeLength": 670,
+ "src": "s && (e = /(scroll|auto)/.test(i.css(\"overflow\")) , this.containment = [(parseInt(i.css(\"borderLeftWidth\"), 10) || 0) + (parseInt(i.css(\"paddingLeft\"), 10) || 0), (parseInt(i.css(\"borderTopWidth\"), 10) || 0) + (parseInt(i.css(\"paddingTop\"), 10) || 0), (e ? Math.max(s.scrollWidth, s.offsetWidth) : s.offsetWidth) - (parseInt(i.css(\"borderRightWidth\"), 10) || 0) - (parseInt(i.css(\"paddingRight\"), 10) || 0) - this.helperProportions.width - this.margins.left - this.margins.right, (e ? Math.max(s.scrollHeight, s.offsetHeight) : s.offsetHeight) - (parseInt(i.css(\"borderBottomWidth\"), 10) || 0) - (parseInt(i.css(\"paddingBottom\"), 10) || 0) - this.helperProportions.height - this.margins.top - this.margins.bottom] , this.relativeContainer = i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7898,
+ "nodeLength": 40,
+ "src": "parseInt(i.css(\"borderLeftWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7941,
+ "nodeLength": 36,
+ "src": "parseInt(i.css(\"paddingLeft\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7980,
+ "nodeLength": 39,
+ "src": "parseInt(i.css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8022,
+ "nodeLength": 35,
+ "src": "parseInt(i.css(\"paddingTop\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8060,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8116,
+ "nodeLength": 41,
+ "src": "parseInt(i.css(\"borderRightWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8160,
+ "nodeLength": 37,
+ "src": "parseInt(i.css(\"paddingRight\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8266,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8325,
+ "nodeLength": 42,
+ "src": "parseInt(i.css(\"borderBottomWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8370,
+ "nodeLength": 38,
+ "src": "parseInt(i.css(\"paddingBottom\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8577,
+ "nodeLength": 20,
+ "src": "e || (e = this.position)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8604,
+ "nodeLength": 14,
+ "src": "\"absolute\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8735,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8786,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8883,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8935,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9095,
+ "nodeLength": 115,
+ "src": "r && this.offset.scroll || (this.offset.scroll = {\n top: this.scrollParent.scrollTop(), \n left: this.scrollParent.scrollLeft()})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9095,
+ "nodeLength": 21,
+ "src": "r && this.offset.scroll",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9211,
+ "nodeLength": 1025,
+ "src": "e && (this.containment && (this.relativeContainer ? (s = this.relativeContainer.offset() , i = [this.containment[0] + s.left, this.containment[1] + s.top, this.containment[2] + s.left, this.containment[3] + s.top]) : i = this.containment , t.pageX - this.offset.click.left < i[0] && (h = i[0] + this.offset.click.left) , t.pageY - this.offset.click.top < i[1] && (l = i[1] + this.offset.click.top) , t.pageX - this.offset.click.left > i[2] && (h = i[2] + this.offset.click.left) , t.pageY - this.offset.click.top > i[3] && (l = i[3] + this.offset.click.top)) , a.grid && (n = a.grid[1] ? this.originalPageY + Math.round((l - this.originalPageY) / a.grid[1]) * a.grid[1] : this.originalPageY , l = i ? n - this.offset.click.top >= i[1] || n - this.offset.click.top > i[3] ? n : n - this.offset.click.top >= i[1] ? n - a.grid[1] : n + a.grid[1] : n , o = a.grid[0] ? this.originalPageX + Math.round((h - this.originalPageX) / a.grid[0]) * a.grid[0] : this.originalPageX , h = i ? o - this.offset.click.left >= i[0] || o - this.offset.click.left > i[2] ? o : o - this.offset.click.left >= i[0] ? o - a.grid[0] : o + a.grid[0] : o) , \"y\" === a.axis && (h = this.originalPageX) , \"x\" === a.axis && (l = this.originalPageY))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9215,
+ "nodeLength": 479,
+ "src": "this.containment && (this.relativeContainer ? (s = this.relativeContainer.offset() , i = [this.containment[0] + s.left, this.containment[1] + s.top, this.containment[2] + s.left, this.containment[3] + s.top]) : i = this.containment , t.pageX - this.offset.click.left < i[0] && (h = i[0] + this.offset.click.left) , t.pageY - this.offset.click.top < i[1] && (l = i[1] + this.offset.click.top) , t.pageX - this.offset.click.left > i[2] && (h = i[2] + this.offset.click.left) , t.pageY - this.offset.click.top > i[3] && (l = i[3] + this.offset.click.top))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9234,
+ "nodeLength": 22,
+ "src": "this.relativeContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9422,
+ "nodeLength": 68,
+ "src": "t.pageX - this.offset.click.left < i[0] && (h = i[0] + this.offset.click.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9422,
+ "nodeLength": 35,
+ "src": "t.pageX - this.offset.click.left < i[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9491,
+ "nodeLength": 66,
+ "src": "t.pageY - this.offset.click.top < i[1] && (l = i[1] + this.offset.click.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9491,
+ "nodeLength": 34,
+ "src": "t.pageY - this.offset.click.top < i[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9558,
+ "nodeLength": 68,
+ "src": "t.pageX - this.offset.click.left > i[2] && (h = i[2] + this.offset.click.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9558,
+ "nodeLength": 35,
+ "src": "t.pageX - this.offset.click.left > i[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9627,
+ "nodeLength": 66,
+ "src": "t.pageY - this.offset.click.top > i[3] && (l = i[3] + this.offset.click.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9627,
+ "nodeLength": 34,
+ "src": "t.pageY - this.offset.click.top > i[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9695,
+ "nodeLength": 466,
+ "src": "a.grid && (n = a.grid[1] ? this.originalPageY + Math.round((l - this.originalPageY) / a.grid[1]) * a.grid[1] : this.originalPageY , l = i ? n - this.offset.click.top >= i[1] || n - this.offset.click.top > i[3] ? n : n - this.offset.click.top >= i[1] ? n - a.grid[1] : n + a.grid[1] : n , o = a.grid[0] ? this.originalPageX + Math.round((h - this.originalPageX) / a.grid[0]) * a.grid[0] : this.originalPageX , h = i ? o - this.offset.click.left >= i[0] || o - this.offset.click.left > i[2] ? o : o - this.offset.click.left >= i[0] ? o - a.grid[0] : o + a.grid[0] : o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9706,
+ "nodeLength": 9,
+ "src": "a.grid[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9811,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9813,
+ "nodeLength": 59,
+ "src": "n - this.offset.click.top >= i[1] || n - this.offset.click.top > i[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9813,
+ "nodeLength": 29,
+ "src": "n - this.offset.click.top >= i[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9844,
+ "nodeLength": 28,
+ "src": "n - this.offset.click.top > i[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9875,
+ "nodeLength": 29,
+ "src": "n - this.offset.click.top >= i[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9933,
+ "nodeLength": 9,
+ "src": "a.grid[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10038,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10040,
+ "nodeLength": 61,
+ "src": "o - this.offset.click.left >= i[0] || o - this.offset.click.left > i[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10040,
+ "nodeLength": 30,
+ "src": "o - this.offset.click.left >= i[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10072,
+ "nodeLength": 29,
+ "src": "o - this.offset.click.left > i[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10104,
+ "nodeLength": 30,
+ "src": "o - this.offset.click.left >= i[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10162,
+ "nodeLength": 36,
+ "src": "\"y\" === a.axis && (h = this.originalPageX)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10162,
+ "nodeLength": 12,
+ "src": "\"y\" === a.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10199,
+ "nodeLength": 36,
+ "src": "\"x\" === a.axis && (l = this.originalPageY)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10199,
+ "nodeLength": 12,
+ "src": "\"x\" === a.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10315,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10366,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10475,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10527,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10631,
+ "nodeLength": 80,
+ "src": "this.helper[0] === this.element[0] || this.cancelHelperRemoval || this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10631,
+ "nodeLength": 32,
+ "src": "this.helper[0] === this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10665,
+ "nodeLength": 46,
+ "src": "this.cancelHelperRemoval || this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10757,
+ "nodeLength": 35,
+ "src": "this.destroyOnClear && this.destroy()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10828,
+ "nodeLength": 17,
+ "src": "s || this._uiHash()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10885,
+ "nodeLength": 110,
+ "src": "/^(drag|start|stop)/.test(e) && (this.positionAbs = this._convertPositionTo(\"absolute\") , s.offset = this.positionAbs)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14920,
+ "nodeLength": 93,
+ "src": "i && !i.options.disabled && (s.sortables.push(i) , i.refreshPositions() , i._trigger(\"activate\", e, n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14923,
+ "nodeLength": 90,
+ "src": "!i.options.disabled && (s.sortables.push(i) , i.refreshPositions() , i._trigger(\"activate\", e, n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15142,
+ "nodeLength": 8,
+ "src": "t.isOver",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15603,
+ "nodeLength": 293,
+ "src": "o._intersectsWith(o.containerCache) && (n = !0 , t.each(s.sortables, function() {\n return this.positionAbs = s.positionAbs , this.helperProportions = s.helperProportions , this.offset.click = s.offset.click , this !== o && this._intersectsWith(this.containerCache) && t.contains(o.element[0], this.element[0]) && (n = !1) , n;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15790,
+ "nodeLength": 101,
+ "src": "this !== o && this._intersectsWith(this.containerCache) && t.contains(o.element[0], this.element[0]) && (n = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15790,
+ "nodeLength": 8,
+ "src": "this !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15800,
+ "nodeLength": 91,
+ "src": "this._intersectsWith(this.containerCache) && t.contains(o.element[0], this.element[0]) && (n = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15843,
+ "nodeLength": 48,
+ "src": "t.contains(o.element[0], this.element[0]) && (n = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15897,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15900,
+ "nodeLength": 621,
+ "src": "o.isOver || (o.isOver = 1 , s._parent = i.helper.parent() , o.currentItem = i.helper.appendTo(o.element).data(\"ui-sortable-item\", !0) , o.options._helper = o.options.helper , o.options.helper = function() {\n return i.helper[0];\n} , e.target = o.currentItem[0] , o._mouseCapture(e, !0) , o._mouseStart(e, !0, !0) , o.offset.click.top = s.offset.click.top , o.offset.click.left = s.offset.click.left , o.offset.parent.left -= s.offset.parent.left - o.offset.parent.left , o.offset.parent.top -= s.offset.parent.top - o.offset.parent.top , s._trigger(\"toSortable\", e) , s.dropped = o.element , t.each(s.sortables, function() {\n this.refreshPositions();\n}) , s.currentItem = s.element , o.fromOutside = s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16522,
+ "nodeLength": 54,
+ "src": "o.currentItem && (o._mouseDrag(e) , i.position = o.position)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16578,
+ "nodeLength": 447,
+ "src": "o.isOver && (o.isOver = 0 , o.cancelHelperRemoval = !0 , o.options._revert = o.options.revert , o.options.revert = !1 , o._trigger(\"out\", e, o._uiHash(o)) , o._mouseStop(e, !0) , o.options.revert = o.options._revert , o.options.helper = o.options._helper , o.placeholder && o.placeholder.remove() , i.helper.appendTo(s._parent) , s._refreshOffsets(e) , i.position = s._generatePosition(e, !0) , s._trigger(\"fromSortable\", e) , s.dropped = !1 , t.each(s.sortables, function() {\n this.refreshPositions();\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16802,
+ "nodeLength": 37,
+ "src": "o.placeholder && o.placeholder.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17119,
+ "nodeLength": 44,
+ "src": "n.css(\"cursor\") && (o._cursor = n.css(\"cursor\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17227,
+ "nodeLength": 44,
+ "src": "n._cursor && t(\"body\").css(\"cursor\", n._cursor)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17366,
+ "nodeLength": 47,
+ "src": "n.css(\"opacity\") && (o._opacity = n.css(\"opacity\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17479,
+ "nodeLength": 49,
+ "src": "n._opacity && t(i.helper).css(\"opacity\", n._opacity)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17592,
+ "nodeLength": 76,
+ "src": "i.scrollParentNotHidden || (i.scrollParentNotHidden = i.helper.scrollParent(!1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17669,
+ "nodeLength": 140,
+ "src": "i.scrollParentNotHidden[0] !== i.document[0] && \"HTML\" !== i.scrollParentNotHidden[0].tagName && (i.overflowOffset = i.scrollParentNotHidden.offset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17669,
+ "nodeLength": 42,
+ "src": "i.scrollParentNotHidden[0] !== i.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17713,
+ "nodeLength": 96,
+ "src": "\"HTML\" !== i.scrollParentNotHidden[0].tagName && (i.overflowOffset = i.scrollParentNotHidden.offset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17713,
+ "nodeLength": 43,
+ "src": "\"HTML\" !== i.scrollParentNotHidden[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17898,
+ "nodeLength": 25,
+ "src": "a !== r && \"HTML\" !== a.tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17898,
+ "nodeLength": 5,
+ "src": "a !== r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17905,
+ "nodeLength": 18,
+ "src": "\"HTML\" !== a.tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17925,
+ "nodeLength": 219,
+ "src": "n.axis && \"x\" === n.axis || (s.overflowOffset.top + a.offsetHeight - e.pageY < n.scrollSensitivity ? a.scrollTop = o = a.scrollTop + n.scrollSpeed : e.pageY - s.overflowOffset.top < n.scrollSensitivity && (a.scrollTop = o = a.scrollTop - n.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17925,
+ "nodeLength": 20,
+ "src": "n.axis && \"x\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17933,
+ "nodeLength": 12,
+ "src": "\"x\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17948,
+ "nodeLength": 63,
+ "src": "s.overflowOffset.top + a.offsetHeight - e.pageY < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18052,
+ "nodeLength": 91,
+ "src": "e.pageY - s.overflowOffset.top < n.scrollSensitivity && (a.scrollTop = o = a.scrollTop - n.scrollSpeed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18052,
+ "nodeLength": 48,
+ "src": "e.pageY - s.overflowOffset.top < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18145,
+ "nodeLength": 224,
+ "src": "n.axis && \"y\" === n.axis || (s.overflowOffset.left + a.offsetWidth - e.pageX < n.scrollSensitivity ? a.scrollLeft = o = a.scrollLeft + n.scrollSpeed : e.pageX - s.overflowOffset.left < n.scrollSensitivity && (a.scrollLeft = o = a.scrollLeft - n.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18145,
+ "nodeLength": 20,
+ "src": "n.axis && \"y\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18153,
+ "nodeLength": 12,
+ "src": "\"y\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18168,
+ "nodeLength": 63,
+ "src": "s.overflowOffset.left + a.offsetWidth - e.pageX < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18274,
+ "nodeLength": 94,
+ "src": "e.pageX - s.overflowOffset.left < n.scrollSensitivity && (a.scrollLeft = o = a.scrollLeft - n.scrollSpeed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18274,
+ "nodeLength": 49,
+ "src": "e.pageX - s.overflowOffset.left < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18372,
+ "nodeLength": 235,
+ "src": "n.axis && \"x\" === n.axis || (e.pageY - t(r).scrollTop() < n.scrollSensitivity ? o = t(r).scrollTop(t(r).scrollTop() - n.scrollSpeed) : t(window).height() - (e.pageY - t(r).scrollTop()) < n.scrollSensitivity && (o = t(r).scrollTop(t(r).scrollTop() + n.scrollSpeed)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18372,
+ "nodeLength": 20,
+ "src": "n.axis && \"x\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18380,
+ "nodeLength": 12,
+ "src": "\"x\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18395,
+ "nodeLength": 44,
+ "src": "e.pageY - t(r).scrollTop() < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18489,
+ "nodeLength": 117,
+ "src": "t(window).height() - (e.pageY - t(r).scrollTop()) < n.scrollSensitivity && (o = t(r).scrollTop(t(r).scrollTop() + n.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18489,
+ "nodeLength": 65,
+ "src": "t(window).height() - (e.pageY - t(r).scrollTop()) < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18608,
+ "nodeLength": 240,
+ "src": "n.axis && \"y\" === n.axis || (e.pageX - t(r).scrollLeft() < n.scrollSensitivity ? o = t(r).scrollLeft(t(r).scrollLeft() - n.scrollSpeed) : t(window).width() - (e.pageX - t(r).scrollLeft()) < n.scrollSensitivity && (o = t(r).scrollLeft(t(r).scrollLeft() + n.scrollSpeed)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18608,
+ "nodeLength": 20,
+ "src": "n.axis && \"y\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18616,
+ "nodeLength": 12,
+ "src": "\"y\" === n.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18631,
+ "nodeLength": 45,
+ "src": "e.pageX - t(r).scrollLeft() < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18728,
+ "nodeLength": 119,
+ "src": "t(window).width() - (e.pageX - t(r).scrollLeft()) < n.scrollSensitivity && (o = t(r).scrollLeft(t(r).scrollLeft() + n.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18728,
+ "nodeLength": 65,
+ "src": "t(window).width() - (e.pageX - t(r).scrollLeft()) < n.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18850,
+ "nodeLength": 76,
+ "src": "o !== !1 && t.ui.ddmanager && !n.dropBehaviour && t.ui.ddmanager.prepareOffsets(s, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18850,
+ "nodeLength": 6,
+ "src": "o !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18858,
+ "nodeLength": 68,
+ "src": "t.ui.ddmanager && !n.dropBehaviour && t.ui.ddmanager.prepareOffsets(s, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18874,
+ "nodeLength": 52,
+ "src": "!n.dropBehaviour && t.ui.ddmanager.prepareOffsets(s, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19024,
+ "nodeLength": 27,
+ "src": "n.snap.constructor !== String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19052,
+ "nodeLength": 35,
+ "src": "n.snap.items || \":data(ui-draggable)\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19139,
+ "nodeLength": 119,
+ "src": "this !== s.element[0] && s.snapElements.push({\n item: this, \n width: e.outerWidth(), \n height: e.outerHeight(), \n top: i.top, \n left: i.left})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19139,
+ "nodeLength": 19,
+ "src": "this !== s.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19459,
+ "nodeLength": 4,
+ "src": "d >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19603,
+ "nodeLength": 100,
+ "src": "h - g > _ || m > l + g || c - g > b || v > u + g || !t.contains(s.snapElements[d].item.ownerDocument, s.snapElements[d].item)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19603,
+ "nodeLength": 5,
+ "src": "h - g > _",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19610,
+ "nodeLength": 93,
+ "src": "m > l + g || c - g > b || v > u + g || !t.contains(s.snapElements[d].item.ownerDocument, s.snapElements[d].item)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19610,
+ "nodeLength": 5,
+ "src": "m > l + g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19617,
+ "nodeLength": 86,
+ "src": "c - g > b || v > u + g || !t.contains(s.snapElements[d].item.ownerDocument, s.snapElements[d].item)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19617,
+ "nodeLength": 5,
+ "src": "c - g > b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19624,
+ "nodeLength": 79,
+ "src": "v > u + g || !t.contains(s.snapElements[d].item.ownerDocument, s.snapElements[d].item)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19624,
+ "nodeLength": 5,
+ "src": "v > u + g",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19705,
+ "nodeLength": 148,
+ "src": "s.snapElements[d].snapping && s.options.snap.release && s.options.snap.release.call(s.element, e, t.extend(s._uiHash(), {\n snapItem: s.snapElements[d].item}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19733,
+ "nodeLength": 120,
+ "src": "s.options.snap.release && s.options.snap.release.call(s.element, e, t.extend(s._uiHash(), {\n snapItem: s.snapElements[d].item}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19886,
+ "nodeLength": 444,
+ "src": "\"inner\" !== f.snapMode && (n = g >= Math.abs(c - b) , o = g >= Math.abs(u - v) , a = g >= Math.abs(h - _) , r = g >= Math.abs(l - m) , n && (i.position.top = s._convertPositionTo(\"relative\", {\n top: c - s.helperProportions.height, \n left: 0}).top) , o && (i.position.top = s._convertPositionTo(\"relative\", {\n top: u, \n left: 0}).top) , a && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: h - s.helperProportions.width}).left) , r && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: l}).left))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19886,
+ "nodeLength": 20,
+ "src": "\"inner\" !== f.snapMode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19911,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(c - b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19930,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(u - v)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19949,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(h - _)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19968,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(l - m)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19985,
+ "nodeLength": 98,
+ "src": "n && (i.position.top = s._convertPositionTo(\"relative\", {\n top: c - s.helperProportions.height, \n left: 0}).top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20084,
+ "nodeLength": 71,
+ "src": "o && (i.position.top = s._convertPositionTo(\"relative\", {\n top: u, \n left: 0}).top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20156,
+ "nodeLength": 99,
+ "src": "a && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: h - s.helperProportions.width}).left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20256,
+ "nodeLength": 73,
+ "src": "r && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: l}).left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20333,
+ "nodeLength": 10,
+ "src": "n || o || a || r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20336,
+ "nodeLength": 7,
+ "src": "o || a || r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20339,
+ "nodeLength": 4,
+ "src": "a || r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20344,
+ "nodeLength": 444,
+ "src": "\"outer\" !== f.snapMode && (n = g >= Math.abs(c - v) , o = g >= Math.abs(u - b) , a = g >= Math.abs(h - m) , r = g >= Math.abs(l - _) , n && (i.position.top = s._convertPositionTo(\"relative\", {\n top: c, \n left: 0}).top) , o && (i.position.top = s._convertPositionTo(\"relative\", {\n top: u - s.helperProportions.height, \n left: 0}).top) , a && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: h}).left) , r && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: l - s.helperProportions.width}).left))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20344,
+ "nodeLength": 20,
+ "src": "\"outer\" !== f.snapMode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20369,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(c - v)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20388,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(u - b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20407,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(h - m)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20426,
+ "nodeLength": 16,
+ "src": "g >= Math.abs(l - _)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20443,
+ "nodeLength": 71,
+ "src": "n && (i.position.top = s._convertPositionTo(\"relative\", {\n top: c, \n left: 0}).top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20515,
+ "nodeLength": 98,
+ "src": "o && (i.position.top = s._convertPositionTo(\"relative\", {\n top: u - s.helperProportions.height, \n left: 0}).top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20614,
+ "nodeLength": 73,
+ "src": "a && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: h}).left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20688,
+ "nodeLength": 99,
+ "src": "r && (i.position.left = s._convertPositionTo(\"relative\", {\n top: 0, \n left: l - s.helperProportions.width}).left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20789,
+ "nodeLength": 160,
+ "src": "!s.snapElements[d].snapping && (n || o || a || r || p) && s.options.snap.snap && s.options.snap.snap.call(s.element, e, t.extend(s._uiHash(), {\n snapItem: s.snapElements[d].item}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20819,
+ "nodeLength": 130,
+ "src": "(n || o || a || r || p) && s.options.snap.snap && s.options.snap.snap.call(s.element, e, t.extend(s._uiHash(), {\n snapItem: s.snapElements[d].item}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20819,
+ "nodeLength": 13,
+ "src": "n || o || a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20822,
+ "nodeLength": 10,
+ "src": "o || a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20825,
+ "nodeLength": 7,
+ "src": "a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20828,
+ "nodeLength": 4,
+ "src": "r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20835,
+ "nodeLength": 114,
+ "src": "s.options.snap.snap && s.options.snap.snap.call(s.element, e, t.extend(s._uiHash(), {\n snapItem: s.snapElements[d].item}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20977,
+ "nodeLength": 13,
+ "src": "n || o || a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20980,
+ "nodeLength": 10,
+ "src": "o || a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20983,
+ "nodeLength": 7,
+ "src": "a || r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20986,
+ "nodeLength": 4,
+ "src": "r || p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21124,
+ "nodeLength": 34,
+ "src": "parseInt(t(e).css(\"zIndex\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21161,
+ "nodeLength": 34,
+ "src": "parseInt(t(i).css(\"zIndex\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21199,
+ "nodeLength": 131,
+ "src": "a.length && (n = parseInt(t(a[0]).css(\"zIndex\"), 10) || 0 , t(a).each(function(e) {\n t(this).css(\"zIndex\", n + e);\n}) , this.css(\"zIndex\", n + a.length))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21212,
+ "nodeLength": 37,
+ "src": "parseInt(t(a[0]).css(\"zIndex\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21424,
+ "nodeLength": 44,
+ "src": "n.css(\"zIndex\") && (o._zIndex = n.css(\"zIndex\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21532,
+ "nodeLength": 46,
+ "src": "n._zIndex && t(i.helper).css(\"zIndex\", n._zIndex)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22032,
+ "nodeLength": 16,
+ "src": "parseFloat(t) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22129,
+ "nodeLength": 31,
+ "src": "\"hidden\" === t(e).css(\"overflow\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22176,
+ "nodeLength": 13,
+ "src": "i && \"left\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22179,
+ "nodeLength": 10,
+ "src": "\"left\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22227,
+ "nodeLength": 6,
+ "src": "e[s] > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22247,
+ "nodeLength": 6,
+ "src": "e[s] > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22483,
+ "nodeLength": 28,
+ "src": "i.helper || i.ghost || i.animate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22493,
+ "nodeLength": 18,
+ "src": "i.ghost || i.animate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22512,
+ "nodeLength": 31,
+ "src": "i.helper || \"ui-resizable-helper\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22551,
+ "nodeLength": 981,
+ "src": "this.element[0].nodeName.match(/^(canvas|textarea|input|select|button|img)$/i) && (this.element.wrap(t(\"
\").css({\n position: this.element.css(\"position\"), \n width: this.element.outerWidth(), \n height: this.element.outerHeight(), \n top: this.element.css(\"top\"), \n left: this.element.css(\"left\")})) , this.element = this.element.parent().data(\"ui-resizable\", this.element.resizable(\"instance\")) , this.elementIsWrapper = !0 , e = {\n marginTop: this.originalElement.css(\"marginTop\"), \n marginRight: this.originalElement.css(\"marginRight\"), \n marginBottom: this.originalElement.css(\"marginBottom\"), \n marginLeft: this.originalElement.css(\"marginLeft\")} , this.element.css(e) , this.originalElement.css(\"margin\", 0) , this.originalResizeStyle = this.originalElement.css(\"resize\") , this.originalElement.css(\"resize\", \"none\") , this._proportionallyResizeElements.push(this.originalElement.css({\n position: \"static\", \n zoom: 1, \n display: \"block\"})) , this.originalElement.css(e) , this._proportionallyResize())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23554,
+ "nodeLength": 238,
+ "src": "i.autoHide && t(this.element).on(\"mouseenter\", function() {\n i.disabled || (s._removeClass(\"ui-resizable-autohide\") , s._handles.show());\n}).on(\"mouseleave\", function() {\n i.disabled || s.resizing || (s._addClass(\"ui-resizable-autohide\") , s._handles.hide());\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23609,
+ "nodeLength": 71,
+ "src": "i.disabled || (s._removeClass(\"ui-resizable-autohide\") , s._handles.show())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23710,
+ "nodeLength": 80,
+ "src": "i.disabled || s.resizing || (s._addClass(\"ui-resizable-autohide\") , s._handles.hide())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23722,
+ "nodeLength": 68,
+ "src": "s.resizing || (s._addClass(\"ui-resizable-autohide\") , s._handles.hide())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23993,
+ "nodeLength": 216,
+ "src": "this.elementIsWrapper && (i(this.element) , e = this.element , this.originalElement.css({\n position: e.css(\"position\"), \n width: e.outerWidth(), \n height: e.outerHeight(), \n top: e.css(\"top\"), \n left: e.css(\"left\")}).insertAfter(e) , e.remove())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24490,
+ "nodeLength": 302,
+ "src": "this.handles = a.handles || (t(\".ui-resizable-handle\", this.element).length ? {\n n: \".ui-resizable-n\", \n e: \".ui-resizable-e\", \n s: \".ui-resizable-s\", \n w: \".ui-resizable-w\", \n se: \".ui-resizable-se\", \n sw: \".ui-resizable-sw\", \n ne: \".ui-resizable-ne\", \n nw: \".ui-resizable-nw\"} : \"e,s,se\") , this._handles = t() , this.handles.constructor === String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24503,
+ "nodeLength": 237,
+ "src": "a.handles || (t(\".ui-resizable-handle\", this.element).length ? {\n n: \".ui-resizable-n\", \n e: \".ui-resizable-e\", \n s: \".ui-resizable-s\", \n w: \".ui-resizable-w\", \n se: \".ui-resizable-se\", \n sw: \".ui-resizable-sw\", \n ne: \".ui-resizable-ne\", \n nw: \".ui-resizable-nw\"} : \"e,s,se\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24515,
+ "nodeLength": 45,
+ "src": "t(\".ui-resizable-handle\", this.element).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24759,
+ "nodeLength": 33,
+ "src": "this.handles.constructor === String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24797,
+ "nodeLength": 58,
+ "src": "\"all\" === this.handles && (this.handles = \"n,e,s,w,se,sw,ne,nw\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24797,
+ "nodeLength": 20,
+ "src": "\"all\" === this.handles",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24902,
+ "nodeLength": 10,
+ "src": "s.length > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25134,
+ "nodeLength": 15,
+ "src": "e || this.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25172,
+ "nodeLength": 36,
+ "src": "this.handles[i].constructor === String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25280,
+ "nodeLength": 138,
+ "src": "(this.handles[i].jquery || this.handles[i].nodeType) && (this.handles[i] = t(this.handles[i]) , this._on(this.handles[i], {\n mousedown: r._mouseDown}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25280,
+ "nodeLength": 48,
+ "src": "this.handles[i].jquery || this.handles[i].nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25419,
+ "nodeLength": 337,
+ "src": "this.elementIsWrapper && this.originalElement[0].nodeName.match(/^(textarea|input|select|button)$/i) && (s = t(this.handles[i], this.element) , o = /sw|ne|nw|se|n|s/.test(i) ? s.outerHeight() : s.outerWidth() , n = [\"padding\", /ne|nw|n/.test(i) ? \"Top\" : /se|sw|s/.test(i) ? \"Bottom\" : /^e$/.test(i) ? \"Right\" : \"Left\"].join(\"\") , e.css(n, o) , this._proportionallyResize())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25442,
+ "nodeLength": 314,
+ "src": "this.originalElement[0].nodeName.match(/^(textarea|input|select|button)$/i) && (s = t(this.handles[i], this.element) , o = /sw|ne|nw|se|n|s/.test(i) ? s.outerHeight() : s.outerWidth() , n = [\"padding\", /ne|nw|n/.test(i) ? \"Top\" : /se|sw|s/.test(i) ? \"Bottom\" : /^e$/.test(i) ? \"Right\" : \"Left\"].join(\"\") , e.css(n, o) , this._proportionallyResize())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25556,
+ "nodeLength": 25,
+ "src": "/sw|ne|nw|se|n|s/.test(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25626,
+ "nodeLength": 17,
+ "src": "/ne|nw|n/.test(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25650,
+ "nodeLength": 17,
+ "src": "/se|sw|s/.test(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25677,
+ "nodeLength": 13,
+ "src": "/^e$/.test(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25986,
+ "nodeLength": 118,
+ "src": "r.resizing || (this.className && (o = this.className.match(/ui-resizable-(se|sw|ne|nw|n|e|s|w)/i)) , r.axis = o && o[1] ? o[1] : \"se\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25999,
+ "nodeLength": 79,
+ "src": "this.className && (o = this.className.match(/ui-resizable-(se|sw|ne|nw|n|e|s|w)/i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26086,
+ "nodeLength": 7,
+ "src": "o && o[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26107,
+ "nodeLength": 74,
+ "src": "a.autoHide && (this._handles.hide() , this._addClass(\"ui-resizable-autohide\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26319,
+ "nodeLength": 45,
+ "src": "(s === e.target || t.contains(s, e.target)) && (n = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26319,
+ "nodeLength": 36,
+ "src": "s === e.target || t.contains(s, e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26319,
+ "nodeLength": 12,
+ "src": "s === e.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26371,
+ "nodeLength": 25,
+ "src": "!this.options.disabled && n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26579,
+ "nodeLength": 87,
+ "src": "o.containment && (i += t(o.containment).scrollLeft() || 0 , s += t(o.containment).scrollTop() || 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26598,
+ "nodeLength": 32,
+ "src": "t(o.containment).scrollLeft() || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26634,
+ "nodeLength": 31,
+ "src": "t(o.containment).scrollTop() || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26739,
+ "nodeLength": 12,
+ "src": "this._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26862,
+ "nodeLength": 12,
+ "src": "this._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27146,
+ "nodeLength": 30,
+ "src": "\"number\" == typeof o.aspectRatio",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27191,
+ "nodeLength": 51,
+ "src": "this.originalSize.width / this.originalSize.height || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27312,
+ "nodeLength": 10,
+ "src": "\"auto\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27491,
+ "nodeLength": 17,
+ "src": "e.pageX - n.left || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27511,
+ "nodeLength": 16,
+ "src": "e.pageY - n.top || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27582,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27652,
+ "nodeLength": 58,
+ "src": "(this._aspectRatio || e.shiftKey) && (i = this._updateRatio(i, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27652,
+ "nodeLength": 29,
+ "src": "this._aspectRatio || e.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27808,
+ "nodeLength": 86,
+ "src": "!this._helper && this._proportionallyResizeElements.length && this._proportionallyResize()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27823,
+ "nodeLength": 71,
+ "src": "this._proportionallyResizeElements.length && this._proportionallyResize()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27895,
+ "nodeLength": 107,
+ "src": "t.isEmptyObject(s) || (this._updatePrevProperties() , this._trigger(\"resize\", e, this.ui()) , this._applyChanges())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28098,
+ "nodeLength": 565,
+ "src": "this._helper && (i = this._proportionallyResizeElements , s = i.length && /textarea/i.test(i[0].nodeName) , n = s && this._hasScroll(i[0], \"left\") ? 0 : c.sizeDiff.height , o = s ? 0 : c.sizeDiff.width , a = {\n width: c.helper.width() - o, \n height: c.helper.height() - n} , r = parseFloat(c.element.css(\"left\")) + (c.position.left - c.originalPosition.left) || null , h = parseFloat(c.element.css(\"top\")) + (c.position.top - c.originalPosition.top) || null , l.animate || this.element.css(t.extend(a, {\n top: h, \n left: r})) , c.helper.height(c.size.height) , c.helper.width(c.size.width) , this._helper && !l.animate && this._proportionallyResize())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28152,
+ "nodeLength": 41,
+ "src": "i.length && /textarea/i.test(i[0].nodeName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28196,
+ "nodeLength": 31,
+ "src": "s && this._hasScroll(i[0], \"left\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28250,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28329,
+ "nodeLength": 81,
+ "src": "parseFloat(c.element.css(\"left\")) + (c.position.left - c.originalPosition.left) || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28413,
+ "nodeLength": 78,
+ "src": "parseFloat(c.element.css(\"top\")) + (c.position.top - c.originalPosition.top) || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28492,
+ "nodeLength": 55,
+ "src": "l.animate || this.element.css(t.extend(a, {\n top: h, \n left: r}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28608,
+ "nodeLength": 54,
+ "src": "this._helper && !l.animate && this._proportionallyResize()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28622,
+ "nodeLength": 40,
+ "src": "!l.animate && this._proportionallyResize()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28764,
+ "nodeLength": 34,
+ "src": "this._helper && this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29006,
+ "nodeLength": 73,
+ "src": "this.position.top !== this.prevPosition.top && (t.top = this.position.top + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29006,
+ "nodeLength": 41,
+ "src": "this.position.top !== this.prevPosition.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29080,
+ "nodeLength": 77,
+ "src": "this.position.left !== this.prevPosition.left && (t.left = this.position.left + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29080,
+ "nodeLength": 43,
+ "src": "this.position.left !== this.prevPosition.left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29158,
+ "nodeLength": 69,
+ "src": "this.size.width !== this.prevSize.width && (t.width = this.size.width + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29158,
+ "nodeLength": 37,
+ "src": "this.size.width !== this.prevSize.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29228,
+ "nodeLength": 73,
+ "src": "this.size.height !== this.prevSize.height && (t.height = this.size.height + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29228,
+ "nodeLength": 39,
+ "src": "this.size.height !== this.prevSize.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29402,
+ "nodeLength": 26,
+ "src": "this._isNumber(a.minWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29451,
+ "nodeLength": 26,
+ "src": "this._isNumber(a.maxWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29503,
+ "nodeLength": 27,
+ "src": "this._isNumber(a.minHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29555,
+ "nodeLength": 27,
+ "src": "this._isNumber(a.maxHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29601,
+ "nodeLength": 266,
+ "src": "(this._aspectRatio || t) && (e = o.minHeight * this.aspectRatio , s = o.minWidth / this.aspectRatio , i = o.maxHeight * this.aspectRatio , n = o.maxWidth / this.aspectRatio , e > o.minWidth && (o.minWidth = e) , s > o.minHeight && (o.minHeight = s) , o.maxWidth > i && (o.maxWidth = i) , o.maxHeight > n && (o.maxHeight = n))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29601,
+ "nodeLength": 20,
+ "src": "this._aspectRatio || t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29747,
+ "nodeLength": 28,
+ "src": "e > o.minWidth && (o.minWidth = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29747,
+ "nodeLength": 12,
+ "src": "e > o.minWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29776,
+ "nodeLength": 30,
+ "src": "s > o.minHeight && (o.minHeight = s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29776,
+ "nodeLength": 13,
+ "src": "s > o.minHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29807,
+ "nodeLength": 28,
+ "src": "o.maxWidth > i && (o.maxWidth = i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29807,
+ "nodeLength": 12,
+ "src": "o.maxWidth > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29836,
+ "nodeLength": 30,
+ "src": "o.maxHeight > n && (o.maxHeight = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29836,
+ "nodeLength": 13,
+ "src": "o.maxHeight > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29947,
+ "nodeLength": 51,
+ "src": "this._isNumber(t.left) && (this.position.left = t.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29999,
+ "nodeLength": 48,
+ "src": "this._isNumber(t.top) && (this.position.top = t.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30048,
+ "nodeLength": 53,
+ "src": "this._isNumber(t.height) && (this.size.height = t.height)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30102,
+ "nodeLength": 50,
+ "src": "this._isNumber(t.width) && (this.size.width = t.width)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30230,
+ "nodeLength": 24,
+ "src": "this._isNumber(t.height)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30289,
+ "nodeLength": 60,
+ "src": "this._isNumber(t.width) && (t.height = t.width / this.aspectRatio)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30350,
+ "nodeLength": 54,
+ "src": "\"sw\" === s && (t.left = e.left + (i.width - t.width) , t.top = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30350,
+ "nodeLength": 8,
+ "src": "\"sw\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30405,
+ "nodeLength": 75,
+ "src": "\"nw\" === s && (t.top = e.top + (i.height - t.height) , t.left = e.left + (i.width - t.width))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30405,
+ "nodeLength": 8,
+ "src": "\"nw\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30547,
+ "nodeLength": 55,
+ "src": "this._isNumber(t.width) && e.maxWidth && e.maxWidth < t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30572,
+ "nodeLength": 30,
+ "src": "e.maxWidth && e.maxWidth < t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30584,
+ "nodeLength": 18,
+ "src": "e.maxWidth < t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30605,
+ "nodeLength": 59,
+ "src": "this._isNumber(t.height) && e.maxHeight && e.maxHeight < t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30631,
+ "nodeLength": 33,
+ "src": "e.maxHeight && e.maxHeight < t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30644,
+ "nodeLength": 20,
+ "src": "e.maxHeight < t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30667,
+ "nodeLength": 55,
+ "src": "this._isNumber(t.width) && e.minWidth && e.minWidth > t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30692,
+ "nodeLength": 30,
+ "src": "e.minWidth && e.minWidth > t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30704,
+ "nodeLength": 18,
+ "src": "e.minWidth > t.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30725,
+ "nodeLength": 59,
+ "src": "this._isNumber(t.height) && e.minHeight && e.minHeight > t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30751,
+ "nodeLength": 33,
+ "src": "e.minHeight && e.minHeight > t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30764,
+ "nodeLength": 20,
+ "src": "e.minHeight > t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30938,
+ "nodeLength": 23,
+ "src": "o && (t.width = e.minWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30962,
+ "nodeLength": 25,
+ "src": "a && (t.height = e.minHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30988,
+ "nodeLength": 23,
+ "src": "s && (t.width = e.maxWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31012,
+ "nodeLength": 25,
+ "src": "n && (t.height = e.maxHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31038,
+ "nodeLength": 27,
+ "src": "o && l && (t.left = r - e.minWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31041,
+ "nodeLength": 24,
+ "src": "l && (t.left = r - e.minWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31066,
+ "nodeLength": 27,
+ "src": "s && l && (t.left = r - e.maxWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31069,
+ "nodeLength": 24,
+ "src": "l && (t.left = r - e.maxWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31094,
+ "nodeLength": 27,
+ "src": "a && c && (t.top = h - e.minHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31097,
+ "nodeLength": 24,
+ "src": "c && (t.top = h - e.minHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31122,
+ "nodeLength": 27,
+ "src": "n && c && (t.top = h - e.maxHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31125,
+ "nodeLength": 24,
+ "src": "c && (t.top = h - e.maxHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31150,
+ "nodeLength": 33,
+ "src": "t.width || t.height || t.left || !t.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31159,
+ "nodeLength": 24,
+ "src": "t.height || t.left || !t.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31169,
+ "nodeLength": 14,
+ "src": "t.left || !t.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31184,
+ "nodeLength": 48,
+ "src": "t.width || t.height || t.top || !t.left || (t.left = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31193,
+ "nodeLength": 39,
+ "src": "t.height || t.top || !t.left || (t.left = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31203,
+ "nodeLength": 29,
+ "src": "t.top || !t.left || (t.left = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31210,
+ "nodeLength": 22,
+ "src": "!t.left || (t.left = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31504,
+ "nodeLength": 3,
+ "src": "4 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31517,
+ "nodeLength": 19,
+ "src": "parseFloat(s[e]) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31543,
+ "nodeLength": 19,
+ "src": "parseFloat(n[e]) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31641,
+ "nodeLength": 41,
+ "src": "this._proportionallyResizeElements.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31699,
+ "nodeLength": 25,
+ "src": "this.helper || this.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31725,
+ "nodeLength": 43,
+ "src": "this._proportionallyResizeElements.length > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31813,
+ "nodeLength": 84,
+ "src": "this.outerDimensions || (this.outerDimensions = this._getPaddingPlusBorderDimensions(t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31912,
+ "nodeLength": 41,
+ "src": "i.height() - this.outerDimensions.height || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31960,
+ "nodeLength": 39,
+ "src": "i.width() - this.outerDimensions.width || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32091,
+ "nodeLength": 12,
+ "src": "this._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32117,
+ "nodeLength": 54,
+ "src": "this.helper || t(\"
\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33313,
+ "nodeLength": 42,
+ "src": "\"resize\" !== e && this._trigger(e, i, this.ui())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33313,
+ "nodeLength": 12,
+ "src": "\"resize\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33718,
+ "nodeLength": 41,
+ "src": "n.length && /textarea/i.test(n[0].nodeName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33762,
+ "nodeLength": 28,
+ "src": "o && i._hasScroll(n[0], \"left\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33813,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33884,
+ "nodeLength": 81,
+ "src": "parseFloat(i.element.css(\"left\")) + (i.position.left - i.originalPosition.left) || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33968,
+ "nodeLength": 78,
+ "src": "parseFloat(i.element.css(\"top\")) + (i.position.top - i.originalPosition.top) || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34076,
+ "nodeLength": 4,
+ "src": "c && l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34335,
+ "nodeLength": 57,
+ "src": "n && n.length && t(n[0]).css({\n width: s.width, \n height: s.height})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34338,
+ "nodeLength": 54,
+ "src": "n.length && t(n[0]).css({\n width: s.width, \n height: s.height})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34594,
+ "nodeLength": 14,
+ "src": "u instanceof t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34618,
+ "nodeLength": 16,
+ "src": "/parent/.test(u)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34655,
+ "nodeLength": 717,
+ "src": "d && (h.containerElement = t(d) , /document/.test(u) || u === document ? (h.containerOffset = {\n left: 0, \n top: 0} , h.containerPosition = {\n left: 0, \n top: 0} , h.parentData = {\n element: t(document), \n left: 0, \n top: 0, \n width: t(document).width(), \n height: t(document).height() || document.body.parentNode.scrollHeight}) : (e = t(d) , i = [] , t([\"Top\", \"Right\", \"Left\", \"Bottom\"]).each(function(t, s) {\n i[t] = h._num(e.css(\"padding\" + s));\n}) , h.containerOffset = e.offset() , h.containerPosition = e.position() , h.containerSize = {\n height: e.innerHeight() - i[3], \n width: e.innerWidth() - i[1]} , s = h.containerOffset , n = h.containerSize.height , o = h.containerSize.width , a = h._hasScroll(d, \"left\") ? d.scrollWidth : o , r = h._hasScroll(d) ? d.scrollHeight : n , h.parentData = {\n element: d, \n left: s.left, \n top: s.top, \n width: a, \n height: r}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34683,
+ "nodeLength": 32,
+ "src": "/document/.test(u) || u === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34703,
+ "nodeLength": 12,
+ "src": "u === document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34865,
+ "nodeLength": 59,
+ "src": "t(document).height() || document.body.parentNode.scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35233,
+ "nodeLength": 22,
+ "src": "h._hasScroll(d, \"left\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35274,
+ "nodeLength": 15,
+ "src": "h._hasScroll(d)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35484,
+ "nodeLength": 26,
+ "src": "a._aspectRatio || e.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35554,
+ "nodeLength": 56,
+ "src": "d[0] !== document && /static/.test(d.css(\"position\")) && (u = h)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35554,
+ "nodeLength": 15,
+ "src": "d[0] !== document",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35571,
+ "nodeLength": 39,
+ "src": "/static/.test(d.css(\"position\")) && (u = h)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35611,
+ "nodeLength": 199,
+ "src": "l.left < (a._helper ? h.left : 0) && (a.size.width = a.size.width + (a._helper ? a.position.left - h.left : a.position.left - u.left) , c && (a.size.height = a.size.width / a.aspectRatio , p = !1) , a.position.left = r.helper ? h.left : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35611,
+ "nodeLength": 27,
+ "src": "l.left < (a._helper ? h.left : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35619,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35668,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35725,
+ "nodeLength": 50,
+ "src": "c && (a.size.height = a.size.width / a.aspectRatio , p = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35792,
+ "nodeLength": 8,
+ "src": "r.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35811,
+ "nodeLength": 188,
+ "src": "l.top < (a._helper ? h.top : 0) && (a.size.height = a.size.height + (a._helper ? a.position.top - h.top : a.position.top) , c && (a.size.width = a.size.height * a.aspectRatio , p = !1) , a.position.top = a._helper ? h.top : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35811,
+ "nodeLength": 25,
+ "src": "l.top < (a._helper ? h.top : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35818,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35868,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35915,
+ "nodeLength": 50,
+ "src": "c && (a.size.width = a.size.height * a.aspectRatio , p = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35981,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36002,
+ "nodeLength": 53,
+ "src": "a.containerElement.get(0) === a.element.parent().get(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36119,
+ "nodeLength": 4,
+ "src": "n && o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36324,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36408,
+ "nodeLength": 9,
+ "src": "a._helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36458,
+ "nodeLength": 122,
+ "src": "i + a.size.width >= a.parentData.width && (a.size.width = a.parentData.width - i , c && (a.size.height = a.size.width / a.aspectRatio , p = !1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36458,
+ "nodeLength": 34,
+ "src": "i + a.size.width >= a.parentData.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36529,
+ "nodeLength": 50,
+ "src": "c && (a.size.height = a.size.width / a.aspectRatio , p = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36581,
+ "nodeLength": 126,
+ "src": "s + a.size.height >= a.parentData.height && (a.size.height = a.parentData.height - s , c && (a.size.width = a.size.height * a.aspectRatio , p = !1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36581,
+ "nodeLength": 36,
+ "src": "s + a.size.height >= a.parentData.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36656,
+ "nodeLength": 50,
+ "src": "c && (a.size.width = a.size.height * a.aspectRatio , p = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36708,
+ "nodeLength": 136,
+ "src": "p || (a.position.left = a.prevPosition.left , a.position.top = a.prevPosition.top , a.size.width = a.prevSize.width , a.size.height = a.prevSize.height)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37070,
+ "nodeLength": 116,
+ "src": "e._helper && !i.animate && /relative/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37081,
+ "nodeLength": 105,
+ "src": "!i.animate && /relative/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37093,
+ "nodeLength": 93,
+ "src": "/relative/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37187,
+ "nodeLength": 114,
+ "src": "e._helper && !i.animate && /static/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37198,
+ "nodeLength": 103,
+ "src": "!i.animate && /static/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37210,
+ "nodeLength": 91,
+ "src": "/static/.test(o.css(\"position\")) && t(this).css({\n left: r.left - n.left - s.left, \n width: h, \n height: l})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37732,
+ "nodeLength": 25,
+ "src": "s.size.height - o.height || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37764,
+ "nodeLength": 23,
+ "src": "s.size.width - o.width || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37792,
+ "nodeLength": 23,
+ "src": "s.position.top - a.top || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37821,
+ "nodeLength": 25,
+ "src": "s.position.left - a.left || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37943,
+ "nodeLength": 38,
+ "src": "e.parents(i.originalElement[0]).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38063,
+ "nodeLength": 7,
+ "src": "s[e] || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38073,
+ "nodeLength": 7,
+ "src": "r[e] || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38082,
+ "nodeLength": 23,
+ "src": "i && i >= 0 && (n[e] = i || null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38085,
+ "nodeLength": 20,
+ "src": "i >= 0 && (n[e] = i || null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38085,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38097,
+ "nodeLength": 7,
+ "src": "i || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38475,
+ "nodeLength": 91,
+ "src": "t.uiBackCompat !== !1 && \"string\" == typeof e.options.ghost && e.ghost.addClass(this.options.ghost)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38475,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38496,
+ "nodeLength": 70,
+ "src": "\"string\" == typeof e.options.ghost && e.ghost.addClass(this.options.ghost)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38496,
+ "nodeLength": 32,
+ "src": "\"string\" == typeof e.options.ghost",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38649,
+ "nodeLength": 83,
+ "src": "e.ghost && e.ghost.css({\n position: \"relative\", \n height: e.size.height, \n width: e.size.width})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38786,
+ "nodeLength": 62,
+ "src": "e.ghost && e.helper && e.helper.get(0).removeChild(e.ghost.get(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38795,
+ "nodeLength": 53,
+ "src": "e.helper && e.helper.get(0).removeChild(e.ghost.get(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39014,
+ "nodeLength": 23,
+ "src": "\"number\" == typeof s.grid",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39063,
+ "nodeLength": 7,
+ "src": "h[0] || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39073,
+ "nodeLength": 7,
+ "src": "h[1] || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39182,
+ "nodeLength": 24,
+ "src": "s.maxWidth && p > s.maxWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39194,
+ "nodeLength": 12,
+ "src": "p > s.maxWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39209,
+ "nodeLength": 26,
+ "src": "s.maxHeight && f > s.maxHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39222,
+ "nodeLength": 13,
+ "src": "f > s.maxHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39238,
+ "nodeLength": 24,
+ "src": "s.minWidth && s.minWidth > p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39250,
+ "nodeLength": 12,
+ "src": "s.minWidth > p",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39265,
+ "nodeLength": 26,
+ "src": "s.minHeight && s.minHeight > f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39278,
+ "nodeLength": 13,
+ "src": "s.minHeight > f",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39301,
+ "nodeLength": 9,
+ "src": "_ && (p += l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39311,
+ "nodeLength": 9,
+ "src": "v && (f += c)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39321,
+ "nodeLength": 9,
+ "src": "g && (p -= l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39331,
+ "nodeLength": 9,
+ "src": "m && (f -= c)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39341,
+ "nodeLength": 20,
+ "src": "/^(se|s|e)$/.test(r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39395,
+ "nodeLength": 16,
+ "src": "/^(ne)$/.test(r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39468,
+ "nodeLength": 16,
+ "src": "/^(sw)$/.test(r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39545,
+ "nodeLength": 60,
+ "src": "(0 >= f - c || 0 >= p - l) && (e = i._getPaddingPlusBorderDimensions(this))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39545,
+ "nodeLength": 14,
+ "src": "0 >= f - c || 0 >= p - l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39545,
+ "nodeLength": 6,
+ "src": "0 >= f - c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39553,
+ "nodeLength": 6,
+ "src": "0 >= p - l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39606,
+ "nodeLength": 5,
+ "src": "f - c > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39716,
+ "nodeLength": 5,
+ "src": "p - l > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40256,
+ "nodeLength": 31,
+ "src": "0 > i && t(this).css(\"top\", e.top - i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40256,
+ "nodeLength": 3,
+ "src": "0 > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41033,
+ "nodeLength": 91,
+ "src": "null == this.options.title && null != this.originalTitle && (this.options.title = this.originalTitle)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41033,
+ "nodeLength": 24,
+ "src": "null == this.options.title",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41059,
+ "nodeLength": 65,
+ "src": "null != this.originalTitle && (this.options.title = this.originalTitle)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41059,
+ "nodeLength": 24,
+ "src": "null != this.originalTitle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41125,
+ "nodeLength": 49,
+ "src": "this.options.disabled && (this.options.disabled = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41365,
+ "nodeLength": 61,
+ "src": "this.options.draggable && t.fn.draggable && this._makeDraggable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41389,
+ "nodeLength": 37,
+ "src": "t.fn.draggable && this._makeDraggable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41427,
+ "nodeLength": 61,
+ "src": "this.options.resizable && t.fn.resizable && this._makeResizable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41451,
+ "nodeLength": 37,
+ "src": "t.fn.resizable && this._makeResizable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41542,
+ "nodeLength": 34,
+ "src": "this.options.autoOpen && this.open()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41634,
+ "nodeLength": 25,
+ "src": "e && (e.jquery || e.nodeType)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41638,
+ "nodeLength": 20,
+ "src": "e.jquery || e.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41684,
+ "nodeLength": 9,
+ "src": "e || \"body\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41883,
+ "nodeLength": 65,
+ "src": "this.originalTitle && this.element.attr(\"title\", this.originalTitle)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41983,
+ "nodeLength": 32,
+ "src": "t.length && t[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41993,
+ "nodeLength": 22,
+ "src": "t[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "11": [
+ null,
+ {
+ "position": 2302,
+ "nodeLength": 333,
+ "src": "this._isOpen && this._trigger(\"beforeClose\", e) !== !1 && (this._isOpen = !1 , this._focusedElement = null , this._destroyOverlay() , this._untrackInstance() , this.opener.filter(\":focusable\").trigger(\"focus\").length || t.ui.safeBlur(t.ui.safeActiveElement(this.document[0])) , this._hide(this.uiDialog, this.options.hide, function() {\n i._trigger(\"close\", e);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2316,
+ "nodeLength": 319,
+ "src": "this._trigger(\"beforeClose\", e) !== !1 && (this._isOpen = !1 , this._focusedElement = null , this._destroyOverlay() , this._untrackInstance() , this.opener.filter(\":focusable\").trigger(\"focus\").length || t.ui.safeBlur(t.ui.safeActiveElement(this.document[0])) , this._hide(this.uiDialog, this.options.hide, function() {\n i._trigger(\"close\", e);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2316,
+ "nodeLength": 35,
+ "src": "this._trigger(\"beforeClose\", e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2443,
+ "nodeLength": 113,
+ "src": "this.opener.filter(\":focusable\").trigger(\"focus\").length || t.ui.safeBlur(t.ui.safeActiveElement(this.document[0]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2881,
+ "nodeLength": 73,
+ "src": "o >= +this.uiDialog.css(\"z-index\") && (this.uiDialog.css(\"z-index\", o + 1) , s = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2881,
+ "nodeLength": 32,
+ "src": "o >= +this.uiDialog.css(\"z-index\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2955,
+ "nodeLength": 31,
+ "src": "s && !i && this._trigger(\"focus\", e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2958,
+ "nodeLength": 28,
+ "src": "!i && this._trigger(\"focus\", e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3024,
+ "nodeLength": 12,
+ "src": "this._isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3038,
+ "nodeLength": 40,
+ "src": "this._moveToTop() && this._focusTabbable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3237,
+ "nodeLength": 72,
+ "src": "this.overlay && this.overlay.css(\"z-index\", this.uiDialog.css(\"z-index\") - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3513,
+ "nodeLength": 39,
+ "src": "t || (t = this.element.find(\"[autofocus]\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3553,
+ "nodeLength": 44,
+ "src": "t.length || (t = this.element.find(\":tabbable\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3598,
+ "nodeLength": 55,
+ "src": "t.length || (t = this.uiDialogButtonPane.find(\":tabbable\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3654,
+ "nodeLength": 60,
+ "src": "t.length || (t = this.uiDialogTitlebarClose.filter(\":tabbable\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3715,
+ "nodeLength": 27,
+ "src": "t.length || (t = this.uiDialog)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3854,
+ "nodeLength": 52,
+ "src": "this.uiDialog[0] === e || t.contains(this.uiDialog[0], e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3854,
+ "nodeLength": 20,
+ "src": "this.uiDialog[0] === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3907,
+ "nodeLength": 24,
+ "src": "i || this._focusTabbable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4227,
+ "nodeLength": 95,
+ "src": "this.options.closeOnEscape && !e.isDefaultPrevented() && e.keyCode && e.keyCode === t.ui.keyCode.ESCAPE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4255,
+ "nodeLength": 67,
+ "src": "!e.isDefaultPrevented() && e.keyCode && e.keyCode === t.ui.keyCode.ESCAPE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4280,
+ "nodeLength": 42,
+ "src": "e.keyCode && e.keyCode === t.ui.keyCode.ESCAPE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4291,
+ "nodeLength": 31,
+ "src": "e.keyCode === t.ui.keyCode.ESCAPE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4373,
+ "nodeLength": 53,
+ "src": "e.keyCode === t.ui.keyCode.TAB && !e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4373,
+ "nodeLength": 28,
+ "src": "e.keyCode === t.ui.keyCode.TAB",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4507,
+ "nodeLength": 56,
+ "src": "e.target !== n[0] && e.target !== this.uiDialog[0] || e.shiftKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4507,
+ "nodeLength": 44,
+ "src": "e.target !== n[0] && e.target !== this.uiDialog[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4507,
+ "nodeLength": 15,
+ "src": "e.target !== n[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4524,
+ "nodeLength": 27,
+ "src": "e.target !== this.uiDialog[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4564,
+ "nodeLength": 123,
+ "src": "e.target !== s[0] && e.target !== this.uiDialog[0] || !e.shiftKey || (this._delay(function() {\n n.trigger(\"focus\");\n}) , e.preventDefault())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4564,
+ "nodeLength": 44,
+ "src": "e.target !== s[0] && e.target !== this.uiDialog[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4564,
+ "nodeLength": 15,
+ "src": "e.target !== s[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4581,
+ "nodeLength": 27,
+ "src": "e.target !== this.uiDialog[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4610,
+ "nodeLength": 77,
+ "src": "!e.shiftKey || (this._delay(function() {\n n.trigger(\"focus\");\n}) , e.preventDefault())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4777,
+ "nodeLength": 41,
+ "src": "this._moveToTop(t) && this._focusTabbable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4822,
+ "nodeLength": 123,
+ "src": "this.element.find(\"[aria-describedby]\").length || this.uiDialog.attr({\n \"aria-describedby\": this.element.uniqueId().attr(\"id\")})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5164,
+ "nodeLength": 80,
+ "src": "t(e.target).closest(\".ui-dialog-titlebar-close\") || this.uiDialog.trigger(\"focus\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5834,
+ "nodeLength": 18,
+ "src": "this.options.title",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6329,
+ "nodeLength": 43,
+ "src": "t.isEmptyObject(i) || t.isArray(i) && !i.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6349,
+ "nodeLength": 23,
+ "src": "t.isArray(i) && !i.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6469,
+ "nodeLength": 15,
+ "src": "t.isFunction(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6721,
+ "nodeLength": 39,
+ "src": "\"boolean\" == typeof s.text && delete s.text",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6721,
+ "nodeLength": 24,
+ "src": "\"boolean\" == typeof s.text",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7522,
+ "nodeLength": 4,
+ "src": "a >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7548,
+ "nodeLength": 4,
+ "src": "r >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7892,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8500,
+ "nodeLength": 4,
+ "src": "r >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8526,
+ "nodeLength": 4,
+ "src": "h >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8973,
+ "nodeLength": 21,
+ "src": "-1 !== i && e.splice(i, 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8973,
+ "nodeLength": 6,
+ "src": "-1 !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9081,
+ "nodeLength": 53,
+ "src": "t || (t = [] , this.document.data(\"ui-dialog-instances\", t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9185,
+ "nodeLength": 17,
+ "src": "\"auto\" === t.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9303,
+ "nodeLength": 23,
+ "src": "t || this.uiDialog.show()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9373,
+ "nodeLength": 23,
+ "src": "t || this.uiDialog.hide()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9484,
+ "nodeLength": 33,
+ "src": "t in i.sizeRelatedOptions && (s = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9518,
+ "nodeLength": 40,
+ "src": "t in i.resizableRelatedOptions && (n[t] = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9561,
+ "nodeLength": 34,
+ "src": "s && (this._size() , this._position())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9596,
+ "nodeLength": 76,
+ "src": "this.uiDialog.is(\":data(ui-resizable)\") && this.uiDialog.resizable(\"option\", n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9723,
+ "nodeLength": 619,
+ "src": "\"disabled\" !== e && (this._super(e, i) , \"appendTo\" === e && this.uiDialog.appendTo(this._appendTo()) , \"buttons\" === e && this._createButtons() , \"closeText\" === e && this.uiDialogTitlebarClose.button({\n label: t(\"
\").text(\"\" + this.options.closeText).html()}) , \"draggable\" === e && (s = o.is(\":data(ui-draggable)\") , s && !i && o.draggable(\"destroy\") , !s && i && this._makeDraggable()) , \"position\" === e && this._position() , \"resizable\" === e && (n = o.is(\":data(ui-resizable)\") , n && !i && o.resizable(\"destroy\") , n && \"string\" == typeof i && o.resizable(\"option\", \"handles\", i) , n || i === !1 || this._makeResizable()) , \"title\" === e && this._title(this.uiDialogTitlebar.find(\".ui-dialog-title\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9723,
+ "nodeLength": 14,
+ "src": "\"disabled\" !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9757,
+ "nodeLength": 56,
+ "src": "\"appendTo\" === e && this.uiDialog.appendTo(this._appendTo())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9757,
+ "nodeLength": 14,
+ "src": "\"appendTo\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9814,
+ "nodeLength": 36,
+ "src": "\"buttons\" === e && this._createButtons()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9814,
+ "nodeLength": 13,
+ "src": "\"buttons\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9851,
+ "nodeLength": 107,
+ "src": "\"closeText\" === e && this.uiDialogTitlebarClose.button({\n label: t(\"\").text(\"\" + this.options.closeText).html()})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9851,
+ "nodeLength": 15,
+ "src": "\"closeText\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9959,
+ "nodeLength": 107,
+ "src": "\"draggable\" === e && (s = o.is(\":data(ui-draggable)\") , s && !i && o.draggable(\"destroy\") , !s && i && this._makeDraggable())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9959,
+ "nodeLength": 15,
+ "src": "\"draggable\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10007,
+ "nodeLength": 29,
+ "src": "s && !i && o.draggable(\"destroy\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10010,
+ "nodeLength": 26,
+ "src": "!i && o.draggable(\"destroy\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10037,
+ "nodeLength": 28,
+ "src": "!s && i && this._makeDraggable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10041,
+ "nodeLength": 24,
+ "src": "i && this._makeDraggable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10067,
+ "nodeLength": 32,
+ "src": "\"position\" === e && this._position()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10067,
+ "nodeLength": 14,
+ "src": "\"position\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10100,
+ "nodeLength": 168,
+ "src": "\"resizable\" === e && (n = o.is(\":data(ui-resizable)\") , n && !i && o.resizable(\"destroy\") , n && \"string\" == typeof i && o.resizable(\"option\", \"handles\", i) , n || i === !1 || this._makeResizable())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10100,
+ "nodeLength": 15,
+ "src": "\"resizable\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10148,
+ "nodeLength": 29,
+ "src": "n && !i && o.resizable(\"destroy\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10151,
+ "nodeLength": 26,
+ "src": "!i && o.resizable(\"destroy\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10178,
+ "nodeLength": 56,
+ "src": "n && \"string\" == typeof i && o.resizable(\"option\", \"handles\", i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10181,
+ "nodeLength": 53,
+ "src": "\"string\" == typeof i && o.resizable(\"option\", \"handles\", i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10181,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10235,
+ "nodeLength": 32,
+ "src": "n || i === !1 || this._makeResizable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10238,
+ "nodeLength": 29,
+ "src": "i === !1 || this._makeResizable()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10238,
+ "nodeLength": 6,
+ "src": "i === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10269,
+ "nodeLength": 72,
+ "src": "\"title\" === e && this._title(this.uiDialogTitlebar.find(\".ui-dialog-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10269,
+ "nodeLength": 11,
+ "src": "\"title\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10464,
+ "nodeLength": 40,
+ "src": "s.minWidth > s.width && (s.width = s.minWidth)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10464,
+ "nodeLength": 18,
+ "src": "s.minWidth > s.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10600,
+ "nodeLength": 28,
+ "src": "\"number\" == typeof s.maxHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10662,
+ "nodeLength": 17,
+ "src": "\"auto\" === s.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10782,
+ "nodeLength": 104,
+ "src": "this.uiDialog.is(\":data(ui-resizable)\") && this.uiDialog.resizable(\"option\", \"minHeight\", this._minHeight())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11149,
+ "nodeLength": 72,
+ "src": "this.iframeBlocks && (this.iframeBlocks.remove() , delete this.iframeBlocks)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11260,
+ "nodeLength": 40,
+ "src": "t(e.target).closest(\".ui-dialog\").length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11381,
+ "nodeLength": 18,
+ "src": "this.options.modal",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11440,
+ "nodeLength": 185,
+ "src": "this.document.data(\"ui-dialog-overlays\") || this._on(this.document, {\n focusin: function(t) {\n e || this._allowInteraction(t) || (t.preventDefault() , this._trackingInstances()[0]._focusTabbable());\n}})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11526,
+ "nodeLength": 96,
+ "src": "e || this._allowInteraction(t) || (t.preventDefault() , this._trackingInstances()[0]._focusTabbable())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11529,
+ "nodeLength": 93,
+ "src": "this._allowInteraction(t) || (t.preventDefault() , this._trackingInstances()[0]._focusTabbable())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11829,
+ "nodeLength": 43,
+ "src": "this.document.data(\"ui-dialog-overlays\") || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11909,
+ "nodeLength": 32,
+ "src": "this.options.modal && this.overlay",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11992,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52032,
+ "nodeLength": 308,
+ "src": "t.uiBackCompat !== !1 && t.widget(\"ui.dialog\", t.ui.dialog, {\n options: {\n dialogClass: \"\"}, \n _createWrapper: function() {\n this._super() , this.uiDialog.addClass(this.options.dialogClass);\n}, \n _setOption: function(t, e) {\n \"dialogClass\" === t && this.uiDialog.removeClass(this.options.dialogClass).addClass(e) , this._superApply(arguments);\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 52033,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 194,
+ "nodeLength": 82,
+ "src": "\"dialogClass\" === t && this.uiDialog.removeClass(this.options.dialogClass).addClass(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 194,
+ "nodeLength": 17,
+ "src": "\"dialogClass\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52654,
+ "nodeLength": 15,
+ "src": "t.isFunction(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52735,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52776,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52883,
+ "nodeLength": 44,
+ "src": "i.addClasses && this._addClass(\"ui-droppable\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52984,
+ "nodeLength": 32,
+ "src": "t.ui.ddmanager.droppables[e] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53090,
+ "nodeLength": 10,
+ "src": "t.length > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53105,
+ "nodeLength": 26,
+ "src": "t[e] === this && t.splice(e, 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53105,
+ "nodeLength": 11,
+ "src": "t[e] === this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53250,
+ "nodeLength": 12,
+ "src": "\"accept\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53275,
+ "nodeLength": 15,
+ "src": "t.isFunction(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53329,
+ "nodeLength": 11,
+ "src": "\"scope\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53524,
+ "nodeLength": 41,
+ "src": "i && this._trigger(\"activate\", e, this.ui(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53646,
+ "nodeLength": 43,
+ "src": "i && this._trigger(\"deactivate\", e, this.ui(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53738,
+ "nodeLength": 170,
+ "src": "i && (i.currentItem || i.element)[0] !== this.element[0] && this.accept.call(this.element[0], i.currentItem || i.element) && (this._addHoverClass() , this._trigger(\"over\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53742,
+ "nodeLength": 166,
+ "src": "(i.currentItem || i.element)[0] !== this.element[0] && this.accept.call(this.element[0], i.currentItem || i.element) && (this._addHoverClass() , this._trigger(\"over\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53742,
+ "nodeLength": 46,
+ "src": "(i.currentItem || i.element)[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53742,
+ "nodeLength": 24,
+ "src": "i.currentItem || i.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53790,
+ "nodeLength": 118,
+ "src": "this.accept.call(this.element[0], i.currentItem || i.element) && (this._addHoverClass() , this._trigger(\"over\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53823,
+ "nodeLength": 24,
+ "src": "i.currentItem || i.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53956,
+ "nodeLength": 172,
+ "src": "i && (i.currentItem || i.element)[0] !== this.element[0] && this.accept.call(this.element[0], i.currentItem || i.element) && (this._removeHoverClass() , this._trigger(\"out\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53960,
+ "nodeLength": 168,
+ "src": "(i.currentItem || i.element)[0] !== this.element[0] && this.accept.call(this.element[0], i.currentItem || i.element) && (this._removeHoverClass() , this._trigger(\"out\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53960,
+ "nodeLength": 46,
+ "src": "(i.currentItem || i.element)[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53960,
+ "nodeLength": 24,
+ "src": "i.currentItem || i.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54008,
+ "nodeLength": 120,
+ "src": "this.accept.call(this.element[0], i.currentItem || i.element) && (this._removeHoverClass() , this._trigger(\"out\", e, this.ui(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54041,
+ "nodeLength": 24,
+ "src": "i.currentItem || i.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54156,
+ "nodeLength": 25,
+ "src": "i || t.ui.ddmanager.current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54194,
+ "nodeLength": 50,
+ "src": "s && (s.currentItem || s.element)[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54198,
+ "nodeLength": 46,
+ "src": "(s.currentItem || s.element)[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54198,
+ "nodeLength": 24,
+ "src": "s.currentItem || s.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54376,
+ "nodeLength": 194,
+ "src": "i.options.greedy && !i.options.disabled && i.options.scope === s.options.scope && i.accept.call(i.element[0], s.currentItem || s.element) && v(s, t.extend(i, {\n offset: i.element.offset()}), i.options.tolerance, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54394,
+ "nodeLength": 176,
+ "src": "!i.options.disabled && i.options.scope === s.options.scope && i.accept.call(i.element[0], s.currentItem || s.element) && v(s, t.extend(i, {\n offset: i.element.offset()}), i.options.tolerance, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54415,
+ "nodeLength": 155,
+ "src": "i.options.scope === s.options.scope && i.accept.call(i.element[0], s.currentItem || s.element) && v(s, t.extend(i, {\n offset: i.element.offset()}), i.options.tolerance, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54415,
+ "nodeLength": 33,
+ "src": "i.options.scope === s.options.scope",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54450,
+ "nodeLength": 120,
+ "src": "i.accept.call(i.element[0], s.currentItem || s.element) && v(s, t.extend(i, {\n offset: i.element.offset()}), i.options.tolerance, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54477,
+ "nodeLength": 24,
+ "src": "s.currentItem || s.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54590,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54595,
+ "nodeLength": 58,
+ "src": "this.accept.call(this.element[0], s.currentItem || s.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54628,
+ "nodeLength": 24,
+ "src": "s.currentItem || s.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54795,
+ "nodeLength": 24,
+ "src": "t.currentItem || t.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173763,
+ "nodeLength": 11,
+ "src": "t >= e && e + i > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173763,
+ "nodeLength": 4,
+ "src": "t >= e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173769,
+ "nodeLength": 5,
+ "src": "e + i > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173803,
+ "nodeLength": 9,
+ "src": "!i.offset",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173829,
+ "nodeLength": 34,
+ "src": "e.positionAbs || e.position.absolute",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173888,
+ "nodeLength": 34,
+ "src": "e.positionAbs || e.position.absolute",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174114,
+ "nodeLength": 22,
+ "src": "o >= l && u >= r && a >= c && d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174114,
+ "nodeLength": 4,
+ "src": "o >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174120,
+ "nodeLength": 16,
+ "src": "u >= r && a >= c && d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174120,
+ "nodeLength": 4,
+ "src": "u >= r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174126,
+ "nodeLength": 10,
+ "src": "a >= c && d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174126,
+ "nodeLength": 4,
+ "src": "a >= c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174132,
+ "nodeLength": 4,
+ "src": "d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174160,
+ "nodeLength": 132,
+ "src": "o + e.helperProportions.width / 2 > l && u > r - e.helperProportions.width / 2 && a + e.helperProportions.height / 2 > c && d > h - e.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174160,
+ "nodeLength": 31,
+ "src": "o + e.helperProportions.width / 2 > l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174193,
+ "nodeLength": 99,
+ "src": "u > r - e.helperProportions.width / 2 && a + e.helperProportions.height / 2 > c && d > h - e.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174193,
+ "nodeLength": 31,
+ "src": "u > r - e.helperProportions.width / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174226,
+ "nodeLength": 66,
+ "src": "a + e.helperProportions.height / 2 > c && d > h - e.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174226,
+ "nodeLength": 32,
+ "src": "a + e.helperProportions.height / 2 > c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174260,
+ "nodeLength": 32,
+ "src": "d > h - e.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174314,
+ "nodeLength": 71,
+ "src": "t(n.pageY, c, i.proportions().height) && t(n.pageX, l, i.proportions().width)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174405,
+ "nodeLength": 69,
+ "src": "(a >= c && d >= a || h >= c && d >= h || c > a && h > d) && (o >= l && u >= o || r >= l && u >= r || l > o && r > u)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174405,
+ "nodeLength": 32,
+ "src": "a >= c && d >= a || h >= c && d >= h || c > a && h > d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174405,
+ "nodeLength": 10,
+ "src": "a >= c && d >= a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174405,
+ "nodeLength": 4,
+ "src": "a >= c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174411,
+ "nodeLength": 4,
+ "src": "d >= a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174417,
+ "nodeLength": 20,
+ "src": "h >= c && d >= h || c > a && h > d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174417,
+ "nodeLength": 10,
+ "src": "h >= c && d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174417,
+ "nodeLength": 4,
+ "src": "h >= c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174423,
+ "nodeLength": 4,
+ "src": "d >= h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174429,
+ "nodeLength": 8,
+ "src": "c > a && h > d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174429,
+ "nodeLength": 3,
+ "src": "c > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174434,
+ "nodeLength": 3,
+ "src": "h > d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174441,
+ "nodeLength": 32,
+ "src": "o >= l && u >= o || r >= l && u >= r || l > o && r > u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174441,
+ "nodeLength": 10,
+ "src": "o >= l && u >= o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174441,
+ "nodeLength": 4,
+ "src": "o >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174447,
+ "nodeLength": 4,
+ "src": "u >= o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174453,
+ "nodeLength": 20,
+ "src": "r >= l && u >= r || l > o && r > u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174453,
+ "nodeLength": 10,
+ "src": "r >= l && u >= r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174453,
+ "nodeLength": 4,
+ "src": "r >= l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174459,
+ "nodeLength": 4,
+ "src": "u >= r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174465,
+ "nodeLength": 8,
+ "src": "l > o && r > u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174465,
+ "nodeLength": 3,
+ "src": "l > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174470,
+ "nodeLength": 3,
+ "src": "r > u",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174591,
+ "nodeLength": 46,
+ "src": "t.ui.ddmanager.droppables[e.options.scope] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174640,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174657,
+ "nodeLength": 24,
+ "src": "e.currentItem || e.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174731,
+ "nodeLength": 10,
+ "src": "o.length > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174749,
+ "nodeLength": 88,
+ "src": "!(o[s].options.disabled || e && !o[s].accept.call(o[s].element[0], e.currentItem || e.element))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174751,
+ "nodeLength": 85,
+ "src": "o[s].options.disabled || e && !o[s].accept.call(o[s].element[0], e.currentItem || e.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174774,
+ "nodeLength": 62,
+ "src": "e && !o[s].accept.call(o[s].element[0], e.currentItem || e.element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174811,
+ "nodeLength": 24,
+ "src": "e.currentItem || e.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174847,
+ "nodeLength": 10,
+ "src": "r.length > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174865,
+ "nodeLength": 22,
+ "src": "r[n] === o[s].element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174941,
+ "nodeLength": 36,
+ "src": "\"none\" !== o[s].element.css(\"display\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174978,
+ "nodeLength": 184,
+ "src": "o[s].visible && (\"mousedown\" === a && o[s]._activate.call(o[s], i) , o[s].offset = o[s].element.offset() , o[s].proportions({\n width: o[s].element[0].offsetWidth, \n height: o[s].element[0].offsetHeight}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174993,
+ "nodeLength": 44,
+ "src": "\"mousedown\" === a && o[s]._activate.call(o[s], i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 174993,
+ "nodeLength": 15,
+ "src": "\"mousedown\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175208,
+ "nodeLength": 46,
+ "src": "t.ui.ddmanager.droppables[e.options.scope] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175275,
+ "nodeLength": 279,
+ "src": "this.options && (!this.options.disabled && this.visible && v(e, this, this.options.tolerance, i) && (s = this._drop.call(this, i) || s) , !this.options.disabled && this.visible && this.accept.call(this.element[0], e.currentItem || e.element) && (this.isout = !0 , this.isover = !1 , this._deactivate.call(this, i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175290,
+ "nodeLength": 104,
+ "src": "!this.options.disabled && this.visible && v(e, this, this.options.tolerance, i) && (s = this._drop.call(this, i) || s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175314,
+ "nodeLength": 80,
+ "src": "this.visible && v(e, this, this.options.tolerance, i) && (s = this._drop.call(this, i) || s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175328,
+ "nodeLength": 66,
+ "src": "v(e, this, this.options.tolerance, i) && (s = this._drop.call(this, i) || s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175367,
+ "nodeLength": 26,
+ "src": "this._drop.call(this, i) || s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175395,
+ "nodeLength": 158,
+ "src": "!this.options.disabled && this.visible && this.accept.call(this.element[0], e.currentItem || e.element) && (this.isout = !0 , this.isover = !1 , this._deactivate.call(this, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175419,
+ "nodeLength": 134,
+ "src": "this.visible && this.accept.call(this.element[0], e.currentItem || e.element) && (this.isout = !0 , this.isover = !1 , this._deactivate.call(this, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175433,
+ "nodeLength": 120,
+ "src": "this.accept.call(this.element[0], e.currentItem || e.element) && (this.isout = !0 , this.isover = !1 , this._deactivate.call(this, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175466,
+ "nodeLength": 24,
+ "src": "e.currentItem || e.element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175648,
+ "nodeLength": 62,
+ "src": "e.options.refreshPositions || t.ui.ddmanager.prepareOffsets(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175733,
+ "nodeLength": 62,
+ "src": "e.options.refreshPositions && t.ui.ddmanager.prepareOffsets(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175803,
+ "nodeLength": 46,
+ "src": "t.ui.ddmanager.droppables[e.options.scope] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175864,
+ "nodeLength": 55,
+ "src": "!this.options.disabled && !this.greedyChild && this.visible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175888,
+ "nodeLength": 31,
+ "src": "!this.greedyChild && this.visible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175970,
+ "nodeLength": 15,
+ "src": "!a && this.isover",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 175994,
+ "nodeLength": 15,
+ "src": "a && !this.isover",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176024,
+ "nodeLength": 454,
+ "src": "r && (this.options.greedy && (n = this.options.scope , o = this.element.parents(\":data(ui-droppable)\").filter(function() {\n return t(this).droppable(\"instance\").options.scope === n;\n}) , o.length && (s = t(o[0]).droppable(\"instance\") , s.greedyChild = \"isover\" === r)) , s && \"isover\" === r && (s.isover = !1 , s.isout = !0 , s._out.call(s, i)) , this[r] = !0 , this[\"isout\" === r ? \"isover\" : \"isout\"] = !1 , this[\"isover\" === r ? \"_over\" : \"_out\"].call(this, i) , s && \"isout\" === r && (s.isout = !1 , s.isover = !0 , s._over.call(s, i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176028,
+ "nodeLength": 235,
+ "src": "this.options.greedy && (n = this.options.scope , o = this.element.parents(\":data(ui-droppable)\").filter(function() {\n return t(this).droppable(\"instance\").options.scope === n;\n}) , o.length && (s = t(o[0]).droppable(\"instance\") , s.greedyChild = \"isover\" === r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176142,
+ "nodeLength": 47,
+ "src": "t(this).droppable(\"instance\").options.scope === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176192,
+ "nodeLength": 70,
+ "src": "o.length && (s = t(o[0]).droppable(\"instance\") , s.greedyChild = \"isover\" === r)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176249,
+ "nodeLength": 12,
+ "src": "\"isover\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176264,
+ "nodeLength": 58,
+ "src": "s && \"isover\" === r && (s.isover = !1 , s.isout = !0 , s._out.call(s, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176267,
+ "nodeLength": 55,
+ "src": "\"isover\" === r && (s.isover = !1 , s.isout = !0 , s._out.call(s, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176267,
+ "nodeLength": 12,
+ "src": "\"isover\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176339,
+ "nodeLength": 11,
+ "src": "\"isout\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176377,
+ "nodeLength": 12,
+ "src": "\"isover\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176419,
+ "nodeLength": 58,
+ "src": "s && \"isout\" === r && (s.isout = !1 , s.isover = !0 , s._over.call(s, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176422,
+ "nodeLength": 55,
+ "src": "\"isout\" === r && (s.isout = !1 , s.isover = !0 , s._over.call(s, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176422,
+ "nodeLength": 11,
+ "src": "\"isout\" === r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176561,
+ "nodeLength": 62,
+ "src": "e.options.refreshPositions || t.ui.ddmanager.prepareOffsets(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176626,
+ "nodeLength": 571,
+ "src": "t.uiBackCompat !== !1 && t.widget(\"ui.droppable\", t.ui.droppable, {\n options: {\n hoverClass: !1, \n activeClass: !1}, \n _addActiveClass: function() {\n this._super() , this.options.activeClass && this.element.addClass(this.options.activeClass);\n}, \n _removeActiveClass: function() {\n this._super() , this.options.activeClass && this.element.removeClass(this.options.activeClass);\n}, \n _addHoverClass: function() {\n this._super() , this.options.hoverClass && this.element.addClass(this.options.hoverClass);\n}, \n _removeHoverClass: function() {\n this._super() , this.options.hoverClass && this.element.removeClass(this.options.hoverClass);\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 176626,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 176767,
+ "nodeLength": 73,
+ "src": "this.options.activeClass && this.element.addClass(this.options.activeClass)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 176886,
+ "nodeLength": 76,
+ "src": "this.options.activeClass && this.element.removeClass(this.options.activeClass)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 177004,
+ "nodeLength": 71,
+ "src": "this.options.hoverClass && this.element.addClass(this.options.hoverClass)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 177120,
+ "nodeLength": 74,
+ "src": "this.options.hoverClass && this.element.removeClass(this.options.hoverClass)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 177932,
+ "nodeLength": 10,
+ "src": "void 0 === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178075,
+ "nodeLength": 34,
+ "src": "void 0 === t && (t = this.options.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178075,
+ "nodeLength": 10,
+ "src": "void 0 === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178129,
+ "nodeLength": 6,
+ "src": "t === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178136,
+ "nodeLength": 25,
+ "src": "\"number\" != typeof t && (t = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178136,
+ "nodeLength": 18,
+ "src": "\"number\" != typeof t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178162,
+ "nodeLength": 18,
+ "src": "this.indeterminate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178393,
+ "nodeLength": 35,
+ "src": "\"max\" === t && (e = Math.max(this.min, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178393,
+ "nodeLength": 9,
+ "src": "\"max\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178609,
+ "nodeLength": 18,
+ "src": "this.indeterminate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178787,
+ "nodeLength": 30,
+ "src": "this.indeterminate || e > this.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41,
+ "nodeLength": 10,
+ "src": "e > this.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178906,
+ "nodeLength": 20,
+ "src": "e === this.options.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 178997,
+ "nodeLength": 18,
+ "src": "this.indeterminate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179058,
+ "nodeLength": 126,
+ "src": "this.overlayDiv || (this.overlayDiv = t(\"\").appendTo(this.valueDiv) , this._addClass(this.overlayDiv, \"ui-progressbar-overlay\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179259,
+ "nodeLength": 64,
+ "src": "this.overlayDiv && (this.overlayDiv.remove() , this.overlayDiv = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179325,
+ "nodeLength": 60,
+ "src": "this.oldValue !== e && (this.oldValue = e , this._trigger(\"change\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179325,
+ "nodeLength": 17,
+ "src": "this.oldValue !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179386,
+ "nodeLength": 47,
+ "src": "e === this.options.max && this._trigger(\"complete\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 179386,
+ "nodeLength": 20,
+ "src": "e === this.options.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 180581,
+ "nodeLength": 952,
+ "src": "this.options.disabled || (this.selectees = t(s.filter, this.element[0]) , this._trigger(\"start\", e) , t(s.appendTo).append(this.helper) , this.helper.css({\n left: e.pageX, \n top: e.pageY, \n width: 0, \n height: 0}) , s.autoRefresh && this.refresh() , this.selectees.filter(\".ui-selected\").each(function() {\n var s = t.data(this, \"selectable-item\");\n s.startselected = !0 , e.metaKey || e.ctrlKey || (i._removeClass(s.$element, \"ui-selected\") , s.selected = !1 , i._addClass(s.$element, \"ui-unselecting\") , s.unselecting = !0 , i._trigger(\"unselecting\", e, {\n unselecting: s.element}));\n}) , t(e.target).parents().addBack().each(function() {\n var s, n = t.data(this, \"selectable-item\");\n return n ? (s = !e.metaKey && !e.ctrlKey || !n.$element.hasClass(\"ui-selected\") , i._removeClass(n.$element, s ? \"ui-unselecting\" : \"ui-selected\")._addClass(n.$element, s ? \"ui-selecting\" : \"ui-unselecting\") , n.unselecting = !s , n.selecting = s , n.selected = s , s ? i._trigger(\"selecting\", e, {\n selecting: n.element}) : i._trigger(\"unselecting\", e, {\n unselecting: n.element}) , !1) : void 0;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 180768,
+ "nodeLength": 29,
+ "src": "s.autoRefresh && this.refresh()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 180908,
+ "nodeLength": 188,
+ "src": "e.metaKey || e.ctrlKey || (i._removeClass(s.$element, \"ui-selected\") , s.selected = !1 , i._addClass(s.$element, \"ui-unselecting\") , s.unselecting = !0 , i._trigger(\"unselecting\", e, {\n unselecting: s.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 180919,
+ "nodeLength": 177,
+ "src": "e.ctrlKey || (i._removeClass(s.$element, \"ui-selected\") , s.selected = !1 , i._addClass(s.$element, \"ui-unselecting\") , s.unselecting = !0 , i._trigger(\"unselecting\", e, {\n unselecting: s.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181193,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181198,
+ "nodeLength": 59,
+ "src": "!e.metaKey && !e.ctrlKey || !n.$element.hasClass(\"ui-selected\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181198,
+ "nodeLength": 22,
+ "src": "!e.metaKey && !e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181284,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181339,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181418,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181561,
+ "nodeLength": 38,
+ "src": "this.dragged = !0 , !this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181686,
+ "nodeLength": 18,
+ "src": "o > r && (i = r , r = o , o = i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181686,
+ "nodeLength": 3,
+ "src": "o > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181705,
+ "nodeLength": 18,
+ "src": "a > h && (i = h , h = a , a = i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181705,
+ "nodeLength": 3,
+ "src": "a > h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181855,
+ "nodeLength": 1138,
+ "src": "i && i.element !== s.element[0] && (c.left = i.left + s.elementPos.left , c.right = i.right + s.elementPos.left , c.top = i.top + s.elementPos.top , c.bottom = i.bottom + s.elementPos.top , \"touch\" === n.tolerance ? l = !(c.left > r || o > c.right || c.top > h || a > c.bottom) : \"fit\" === n.tolerance && (l = c.left > o && r > c.right && c.top > a && h > c.bottom) , l ? (i.selected && (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1) , i.unselecting && (s._removeClass(i.$element, \"ui-unselecting\") , i.unselecting = !1) , i.selecting || (s._addClass(i.$element, \"ui-selecting\") , i.selecting = !0 , s._trigger(\"selecting\", e, {\n selecting: i.element}))) : (i.selecting && ((e.metaKey || e.ctrlKey) && i.startselected ? (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , s._addClass(i.$element, \"ui-selected\") , i.selected = !0) : (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , i.startselected && (s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0) , s._trigger(\"unselecting\", e, {\n unselecting: i.element}))) , i.selected && (e.metaKey || e.ctrlKey || i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element})))))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181858,
+ "nodeLength": 1135,
+ "src": "i.element !== s.element[0] && (c.left = i.left + s.elementPos.left , c.right = i.right + s.elementPos.left , c.top = i.top + s.elementPos.top , c.bottom = i.bottom + s.elementPos.top , \"touch\" === n.tolerance ? l = !(c.left > r || o > c.right || c.top > h || a > c.bottom) : \"fit\" === n.tolerance && (l = c.left > o && r > c.right && c.top > a && h > c.bottom) , l ? (i.selected && (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1) , i.unselecting && (s._removeClass(i.$element, \"ui-unselecting\") , i.unselecting = !1) , i.selecting || (s._addClass(i.$element, \"ui-selecting\") , i.selecting = !0 , s._trigger(\"selecting\", e, {\n selecting: i.element}))) : (i.selecting && ((e.metaKey || e.ctrlKey) && i.startselected ? (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , s._addClass(i.$element, \"ui-selected\") , i.selected = !0) : (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , i.startselected && (s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0) , s._trigger(\"unselecting\", e, {\n unselecting: i.element}))) , i.selected && (e.metaKey || e.ctrlKey || i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element})))))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 181858,
+ "nodeLength": 24,
+ "src": "i.element !== s.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182015,
+ "nodeLength": 21,
+ "src": "\"touch\" === n.tolerance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182041,
+ "nodeLength": 40,
+ "src": "c.left > r || o > c.right || c.top > h || a > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182041,
+ "nodeLength": 8,
+ "src": "c.left > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182051,
+ "nodeLength": 30,
+ "src": "o > c.right || c.top > h || a > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182051,
+ "nodeLength": 9,
+ "src": "o > c.right",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182062,
+ "nodeLength": 19,
+ "src": "c.top > h || a > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182062,
+ "nodeLength": 7,
+ "src": "c.top > h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182071,
+ "nodeLength": 10,
+ "src": "a > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182083,
+ "nodeLength": 65,
+ "src": "\"fit\" === n.tolerance && (l = c.left > o && r > c.right && c.top > a && h > c.bottom)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182083,
+ "nodeLength": 19,
+ "src": "\"fit\" === n.tolerance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182107,
+ "nodeLength": 40,
+ "src": "c.left > o && r > c.right && c.top > a && h > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182107,
+ "nodeLength": 8,
+ "src": "c.left > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182117,
+ "nodeLength": 30,
+ "src": "r > c.right && c.top > a && h > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182117,
+ "nodeLength": 9,
+ "src": "r > c.right",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182128,
+ "nodeLength": 19,
+ "src": "c.top > a && h > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182128,
+ "nodeLength": 7,
+ "src": "c.top > a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182137,
+ "nodeLength": 10,
+ "src": "h > c.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182149,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182152,
+ "nodeLength": 68,
+ "src": "i.selected && (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182221,
+ "nodeLength": 77,
+ "src": "i.unselecting && (s._removeClass(i.$element, \"ui-unselecting\") , i.unselecting = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182299,
+ "nodeLength": 116,
+ "src": "i.selecting || (s._addClass(i.$element, \"ui-selecting\") , i.selecting = !0 , s._trigger(\"selecting\", e, {\n selecting: i.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182418,
+ "nodeLength": 353,
+ "src": "i.selecting && ((e.metaKey || e.ctrlKey) && i.startselected ? (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , s._addClass(i.$element, \"ui-selected\") , i.selected = !0) : (s._removeClass(i.$element, \"ui-selecting\") , i.selecting = !1 , i.startselected && (s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0) , s._trigger(\"unselecting\", e, {\n unselecting: i.element})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182433,
+ "nodeLength": 38,
+ "src": "(e.metaKey || e.ctrlKey) && i.startselected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182433,
+ "nodeLength": 20,
+ "src": "e.metaKey || e.ctrlKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182641,
+ "nodeLength": 76,
+ "src": "i.startselected && (s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182772,
+ "nodeLength": 219,
+ "src": "i.selected && (e.metaKey || e.ctrlKey || i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182785,
+ "nodeLength": 205,
+ "src": "e.metaKey || e.ctrlKey || i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182796,
+ "nodeLength": 194,
+ "src": "e.ctrlKey || i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 182807,
+ "nodeLength": 183,
+ "src": "i.startselected || (s._removeClass(i.$element, \"ui-selected\") , i.selected = !1 , s._addClass(i.$element, \"ui-unselecting\") , i.unselecting = !0 , s._trigger(\"unselecting\", e, {\n unselecting: i.element}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 184522,
+ "nodeLength": 21,
+ "src": "this.options.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185022,
+ "nodeLength": 45,
+ "src": "this.options.width !== !1 && this._resizeButton()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185022,
+ "nodeLength": 23,
+ "src": "this.options.width !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185146,
+ "nodeLength": 29,
+ "src": "i._rendered || i._refreshMenu()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185705,
+ "nodeLength": 101,
+ "src": "null != e.focusIndex && s.index !== e.focusIndex && (e._trigger(\"focus\", t, {\n item: s}) , e.isOpen || e._select(s, t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185705,
+ "nodeLength": 18,
+ "src": "null != e.focusIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185725,
+ "nodeLength": 81,
+ "src": "s.index !== e.focusIndex && (e._trigger(\"focus\", t, {\n item: s}) , e.isOpen || e._select(s, t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185725,
+ "nodeLength": 22,
+ "src": "s.index !== e.focusIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 185781,
+ "nodeLength": 24,
+ "src": "e.isOpen || e._select(s, t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186187,
+ "nodeLength": 54,
+ "src": "this._getSelectedItem().data(\"ui-selectmenu-item\") || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186244,
+ "nodeLength": 47,
+ "src": "null === this.options.width && this._resizeButton()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186244,
+ "nodeLength": 25,
+ "src": "null === this.options.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186575,
+ "nodeLength": 171,
+ "src": "e.length && (t = this._getSelectedItem() , this.menuInstance.focus(null, t) , this._setAria(t.data(\"ui-selectmenu-item\")) , this._setOption(\"disabled\", this.element.prop(\"disabled\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186765,
+ "nodeLength": 355,
+ "src": "this.options.disabled || (this._rendered ? (this._removeClass(this.menu.find(\".ui-state-active\"), null, \"ui-state-active\") , this.menuInstance.focus(null, this._getSelectedItem())) : this._refreshMenu() , this.menuItems.length && (this.isOpen = !0 , this._toggleAttr() , this._resizeMenu() , this._position() , this._on(this.document, this._documentClick) , this._trigger(\"open\", t)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186789,
+ "nodeLength": 14,
+ "src": "this._rendered",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186957,
+ "nodeLength": 162,
+ "src": "this.menuItems.length && (this.isOpen = !0 , this._toggleAttr() , this._resizeMenu() , this._position() , this._on(this.document, this._documentClick) , this._trigger(\"open\", t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187235,
+ "nodeLength": 114,
+ "src": "this.isOpen && (this.isOpen = !1 , this._toggleAttr() , this.range = null , this._off(this.document) , this._trigger(\"close\", t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187622,
+ "nodeLength": 204,
+ "src": "o.optgroup !== n && (a = t(\"
\", {\n text: o.optgroup}) , s._addClass(a, \"ui-selectmenu-optgroup\", \"ui-menu-divider\" + (o.element.parent(\"optgroup\").prop(\"disabled\") ? \" ui-state-disabled\" : \"\")) , a.appendTo(e) , n = o.optgroup)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187622,
+ "nodeLength": 14,
+ "src": "o.optgroup !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187727,
+ "nodeLength": 45,
+ "src": "o.element.parent(\"optgroup\").prop(\"disabled\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188036,
+ "nodeLength": 54,
+ "src": "i.disabled && this._addClass(s, null, \"ui-state-disabled\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188164,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188240,
+ "nodeLength": 11,
+ "src": "this.isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188400,
+ "nodeLength": 23,
+ "src": "\"first\" === t || \"last\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188400,
+ "nodeLength": 11,
+ "src": "\"first\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188413,
+ "nodeLength": 10,
+ "src": "\"last\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188426,
+ "nodeLength": 11,
+ "src": "\"first\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188489,
+ "nodeLength": 38,
+ "src": "s.length && this.menuInstance.focus(e, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188652,
+ "nodeLength": 11,
+ "src": "this.isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188715,
+ "nodeLength": 142,
+ "src": "this.range && (window.getSelection ? (t = window.getSelection() , t.removeAllRanges() , t.addRange(this.range)) : this.range.select() , this.button.focus())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188728,
+ "nodeLength": 19,
+ "src": "window.getSelection",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188897,
+ "nodeLength": 119,
+ "src": "this.isOpen && (t(e.target).closest(\".ui-selectmenu-menu, #\" + t.ui.escapeSelector(this.ids.button)).length || this.close(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188911,
+ "nodeLength": 104,
+ "src": "t(e.target).closest(\".ui-selectmenu-menu, #\" + t.ui.escapeSelector(this.ids.button)).length || this.close(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189061,
+ "nodeLength": 19,
+ "src": "window.getSelection",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189106,
+ "nodeLength": 42,
+ "src": "t.rangeCount && (this.range = t.getRangeAt(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189394,
+ "nodeLength": 39,
+ "src": "this.isOpen && this._selectFocusedItem(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189461,
+ "nodeLength": 8,
+ "src": "e.altKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189536,
+ "nodeLength": 8,
+ "src": "e.altKey",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189612,
+ "nodeLength": 11,
+ "src": "this.isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189962,
+ "nodeLength": 21,
+ "src": "i && e.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190071,
+ "nodeLength": 77,
+ "src": "e.hasClass(\"ui-state-disabled\") || this._select(e.data(\"ui-selectmenu-item\"), t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190369,
+ "nodeLength": 47,
+ "src": "t.index !== i && this._trigger(\"change\", e, {\n item: t})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190369,
+ "nodeLength": 11,
+ "src": "t.index !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190634,
+ "nodeLength": 11,
+ "src": "\"icons\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190782,
+ "nodeLength": 56,
+ "src": "\"appendTo\" === t && this.menuWrap.appendTo(this._appendTo())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190782,
+ "nodeLength": 14,
+ "src": "\"appendTo\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190839,
+ "nodeLength": 33,
+ "src": "\"width\" === t && this._resizeButton()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 190839,
+ "nodeLength": 11,
+ "src": "\"width\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191085,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191222,
+ "nodeLength": 60,
+ "src": "e && (e = e.jquery || e.nodeType ? t(e) : this.document.find(e).eq(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191228,
+ "nodeLength": 20,
+ "src": "e.jquery || e.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191283,
+ "nodeLength": 54,
+ "src": "e && e[0] || (e = this.element.closest(\".ui-front, dialog\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191283,
+ "nodeLength": 7,
+ "src": "e && e[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191338,
+ "nodeLength": 35,
+ "src": "e.length || (e = this.document[0].body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191501,
+ "nodeLength": 11,
+ "src": "this.isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191578,
+ "nodeLength": 11,
+ "src": "this.isOpen",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191775,
+ "nodeLength": 6,
+ "src": "t === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191820,
+ "nodeLength": 66,
+ "src": "null === t && (t = this.element.show().outerWidth() , this.element.hide())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191820,
+ "nodeLength": 8,
+ "src": "null === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192365,
+ "nodeLength": 19,
+ "src": "i.attr(\"label\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192394,
+ "nodeLength": 38,
+ "src": "i.prop(\"disabled\") || t.prop(\"disabled\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "12": [
+ null,
+ {
+ "position": 861,
+ "nodeLength": 28,
+ "src": "s.values && s.values.length || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 861,
+ "nodeLength": 25,
+ "src": "s.values && s.values.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 890,
+ "nodeLength": 48,
+ "src": "n.length > i && (n.slice(i).remove() , n = n.slice(0, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 890,
+ "nodeLength": 10,
+ "src": "n.length > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 950,
+ "nodeLength": 3,
+ "src": "i > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1259,
+ "nodeLength": 7,
+ "src": "e.range",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1268,
+ "nodeLength": 191,
+ "src": "e.range === !0 && (e.values ? e.values.length && 2 !== e.values.length ? e.values = [e.values[0], e.values[0]] : t.isArray(e.values) && (e.values = e.values.slice(0)) : e.values = [this._valueMin(), this._valueMin()])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1268,
+ "nodeLength": 12,
+ "src": "e.range === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1283,
+ "nodeLength": 8,
+ "src": "e.values",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1292,
+ "nodeLength": 36,
+ "src": "e.values.length && 2 !== e.values.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1309,
+ "nodeLength": 19,
+ "src": "2 !== e.values.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1364,
+ "nodeLength": 49,
+ "src": "t.isArray(e.values) && (e.values = e.values.slice(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1460,
+ "nodeLength": 29,
+ "src": "this.range && this.range.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1693,
+ "nodeLength": 88,
+ "src": "(\"min\" === e.range || \"max\" === e.range) && this._addClass(this.range, \"ui-slider-range-\" + e.range)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1693,
+ "nodeLength": 32,
+ "src": "\"min\" === e.range || \"max\" === e.range",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1693,
+ "nodeLength": 15,
+ "src": "\"min\" === e.range",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1710,
+ "nodeLength": 15,
+ "src": "\"max\" === e.range",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1784,
+ "nodeLength": 31,
+ "src": "this.range && this.range.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2027,
+ "nodeLength": 31,
+ "src": "this.range && this.range.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2156,
+ "nodeLength": 10,
+ "src": "u.disabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2450,
+ "nodeLength": 80,
+ "src": "(n > i || n === i && (e === c._lastChangedValue || c.values(e) === u.min)) && (n = i , o = t(this) , a = e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2450,
+ "nodeLength": 58,
+ "src": "n > i || n === i && (e === c._lastChangedValue || c.values(e) === u.min)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2450,
+ "nodeLength": 3,
+ "src": "n > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2455,
+ "nodeLength": 53,
+ "src": "n === i && (e === c._lastChangedValue || c.values(e) === u.min)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2455,
+ "nodeLength": 5,
+ "src": "n === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2463,
+ "nodeLength": 44,
+ "src": "e === c._lastChangedValue || c.values(e) === u.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2463,
+ "nodeLength": 23,
+ "src": "e === c._lastChangedValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2488,
+ "nodeLength": 19,
+ "src": "c.values(e) === u.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2552,
+ "nodeLength": 6,
+ "src": "r === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2755,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2837,
+ "nodeLength": 39,
+ "src": "parseInt(o.css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2879,
+ "nodeLength": 42,
+ "src": "parseInt(o.css(\"borderBottomWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2924,
+ "nodeLength": 34,
+ "src": "parseInt(o.css(\"marginTop\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2961,
+ "nodeLength": 59,
+ "src": "this.handles.hasClass(\"ui-state-hover\") || this._slide(e, a, s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3497,
+ "nodeLength": 37,
+ "src": "\"vertical\" === this.options.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3612,
+ "nodeLength": 31,
+ "src": "\"horizontal\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3701,
+ "nodeLength": 17,
+ "src": "this._clickOffset",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3803,
+ "nodeLength": 17,
+ "src": "this._clickOffset",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3853,
+ "nodeLength": 10,
+ "src": "s > 1 && (s = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3853,
+ "nodeLength": 3,
+ "src": "s > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3864,
+ "nodeLength": 10,
+ "src": "0 > s && (s = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3864,
+ "nodeLength": 3,
+ "src": "0 > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3875,
+ "nodeLength": 38,
+ "src": "\"vertical\" === this.orientation && (s = 1 - s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3875,
+ "nodeLength": 29,
+ "src": "\"vertical\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4072,
+ "nodeLength": 10,
+ "src": "void 0 !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4106,
+ "nodeLength": 90,
+ "src": "this._hasMultipleValues() && (s.value = void 0 !== e ? e : this.values(t) , s.values = i || this.values())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4142,
+ "nodeLength": 10,
+ "src": "void 0 !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4179,
+ "nodeLength": 16,
+ "src": "i || this.values()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4237,
+ "nodeLength": 47,
+ "src": "this.options.values && this.options.values.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4418,
+ "nodeLength": 168,
+ "src": "this._hasMultipleValues() && (n = this.values(e ? 0 : 1) , o = this.values(e) , 2 === this.options.values.length && this.options.range === !0 && (i = 0 === e ? Math.min(n, i) : Math.max(n, i)) , a[e] = i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4460,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4484,
+ "nodeLength": 94,
+ "src": "2 === this.options.values.length && this.options.range === !0 && (i = 0 === e ? Math.min(n, i) : Math.max(n, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4484,
+ "nodeLength": 30,
+ "src": "2 === this.options.values.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4516,
+ "nodeLength": 62,
+ "src": "this.options.range === !0 && (i = 0 === e ? Math.min(n, i) : Math.max(n, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4516,
+ "nodeLength": 23,
+ "src": "this.options.range === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4544,
+ "nodeLength": 5,
+ "src": "0 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4587,
+ "nodeLength": 122,
+ "src": "i !== o && (s = this._trigger(\"slide\", t, this._uiHash(e, i, a)) , s !== !1 && (this._hasMultipleValues() ? this.values(e, i) : this.value(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4587,
+ "nodeLength": 5,
+ "src": "i !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4642,
+ "nodeLength": 66,
+ "src": "s !== !1 && (this._hasMultipleValues() ? this.values(e, i) : this.value(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4642,
+ "nodeLength": 6,
+ "src": "s !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4651,
+ "nodeLength": 25,
+ "src": "this._hasMultipleValues()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4794,
+ "nodeLength": 106,
+ "src": "this._keySliding || this._mouseSliding || (this._lastChangedValue = e , this._trigger(\"change\", t, this._uiHash(e)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4812,
+ "nodeLength": 88,
+ "src": "this._mouseSliding || (this._lastChangedValue = e , this._trigger(\"change\", t, this._uiHash(e)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4927,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5087,
+ "nodeLength": 18,
+ "src": "arguments.length > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5212,
+ "nodeLength": 17,
+ "src": "!arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5255,
+ "nodeLength": 24,
+ "src": "!t.isArray(arguments[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5287,
+ "nodeLength": 25,
+ "src": "this._hasMultipleValues()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5387,
+ "nodeLength": 10,
+ "src": "s.length > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5520,
+ "nodeLength": 211,
+ "src": "\"range\" === e && this.options.range === !0 && (\"min\" === i ? (this.options.value = this._values(0) , this.options.values = null) : \"max\" === i && (this.options.value = this._values(this.options.values.length - 1) , this.options.values = null))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5520,
+ "nodeLength": 11,
+ "src": "\"range\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5533,
+ "nodeLength": 198,
+ "src": "this.options.range === !0 && (\"min\" === i ? (this.options.value = this._values(0) , this.options.values = null) : \"max\" === i && (this.options.value = this._values(this.options.values.length - 1) , this.options.values = null))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5533,
+ "nodeLength": 23,
+ "src": "this.options.range === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5559,
+ "nodeLength": 9,
+ "src": "\"min\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5631,
+ "nodeLength": 99,
+ "src": "\"max\" === i && (this.options.value = this._values(this.options.values.length - 1) , this.options.values = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5631,
+ "nodeLength": 9,
+ "src": "\"max\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5732,
+ "nodeLength": 62,
+ "src": "t.isArray(this.options.values) && (n = this.options.values.length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5982,
+ "nodeLength": 41,
+ "src": "this.options.range && this._refreshRange(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6041,
+ "nodeLength": 16,
+ "src": "\"horizontal\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6248,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6701,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6779,
+ "nodeLength": 25,
+ "src": "this._hasMultipleValues()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6844,
+ "nodeLength": 10,
+ "src": "i.length > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6942,
+ "nodeLength": 19,
+ "src": "this._valueMin() >= t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6989,
+ "nodeLength": 19,
+ "src": "t >= this._valueMax()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7039,
+ "nodeLength": 19,
+ "src": "this.options.step > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7117,
+ "nodeLength": 31,
+ "src": "2 * Math.abs(i) >= e && (s += i > 0 ? e : -e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7117,
+ "nodeLength": 16,
+ "src": "2 * Math.abs(i) >= e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7139,
+ "nodeLength": 3,
+ "src": "i > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7295,
+ "nodeLength": 26,
+ "src": "t > this.options.max && (t -= i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7295,
+ "nodeLength": 18,
+ "src": "t > this.options.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7445,
+ "nodeLength": 76,
+ "src": "null !== this.options.min && (t = Math.max(t, this._precisionOf(this.options.min)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7445,
+ "nodeLength": 23,
+ "src": "null !== this.options.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7584,
+ "nodeLength": 6,
+ "src": "-1 === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7717,
+ "nodeLength": 50,
+ "src": "\"vertical\" === t && this.range.css({\n width: \"\", \n left: \"\"})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7717,
+ "nodeLength": 14,
+ "src": "\"vertical\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7768,
+ "nodeLength": 55,
+ "src": "\"horizontal\" === t && this.range.css({\n height: \"\", \n bottom: \"\"})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7768,
+ "nodeLength": 16,
+ "src": "\"horizontal\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7909,
+ "nodeLength": 16,
+ "src": "this._animateOff",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7944,
+ "nodeLength": 25,
+ "src": "this._hasMultipleValues()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8068,
+ "nodeLength": 28,
+ "src": "\"horizontal\" === h.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8138,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8170,
+ "nodeLength": 357,
+ "src": "h.options.range === !0 && (\"horizontal\" === h.orientation ? (0 === s && h.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n left: i + \"%\"}, r.animate) , 1 === s && h.range[l ? \"animate\" : \"css\"]({\n width: i - e + \"%\"}, {\n queue: !1, \n duration: r.animate})) : (0 === s && h.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n bottom: i + \"%\"}, r.animate) , 1 === s && h.range[l ? \"animate\" : \"css\"]({\n height: i - e + \"%\"}, {\n queue: !1, \n duration: r.animate})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8170,
+ "nodeLength": 20,
+ "src": "h.options.range === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8193,
+ "nodeLength": 28,
+ "src": "\"horizontal\" === h.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8223,
+ "nodeLength": 67,
+ "src": "0 === s && h.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n left: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8223,
+ "nodeLength": 5,
+ "src": "0 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8248,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8291,
+ "nodeLength": 80,
+ "src": "1 === s && h.range[l ? \"animate\" : \"css\"]({\n width: i - e + \"%\"}, {\n queue: !1, \n duration: r.animate})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8291,
+ "nodeLength": 5,
+ "src": "1 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8306,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8374,
+ "nodeLength": 69,
+ "src": "0 === s && h.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n bottom: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8374,
+ "nodeLength": 5,
+ "src": "0 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8399,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8444,
+ "nodeLength": 81,
+ "src": "1 === s && h.range[l ? \"animate\" : \"css\"]({\n height: i - e + \"%\"}, {\n queue: !1, \n duration: r.animate})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8444,
+ "nodeLength": 5,
+ "src": "1 === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8459,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8590,
+ "nodeLength": 5,
+ "src": "o !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8618,
+ "nodeLength": 31,
+ "src": "\"horizontal\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8695,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8727,
+ "nodeLength": 108,
+ "src": "\"min\" === a && \"horizontal\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n width: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8727,
+ "nodeLength": 9,
+ "src": "\"min\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8738,
+ "nodeLength": 97,
+ "src": "\"horizontal\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n width: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8738,
+ "nodeLength": 31,
+ "src": "\"horizontal\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8792,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8836,
+ "nodeLength": 112,
+ "src": "\"max\" === a && \"horizontal\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n width: 100 - i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8836,
+ "nodeLength": 9,
+ "src": "\"max\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8847,
+ "nodeLength": 101,
+ "src": "\"horizontal\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n width: 100 - i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8847,
+ "nodeLength": 31,
+ "src": "\"horizontal\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8901,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8949,
+ "nodeLength": 107,
+ "src": "\"min\" === a && \"vertical\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n height: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8949,
+ "nodeLength": 9,
+ "src": "\"min\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8960,
+ "nodeLength": 96,
+ "src": "\"vertical\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n height: i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8960,
+ "nodeLength": 29,
+ "src": "\"vertical\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9012,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9057,
+ "nodeLength": 111,
+ "src": "\"max\" === a && \"vertical\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n height: 100 - i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9057,
+ "nodeLength": 9,
+ "src": "\"max\" === a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9068,
+ "nodeLength": 100,
+ "src": "\"vertical\" === this.orientation && this.range.stop(1, 1)[l ? \"animate\" : \"css\"]({\n height: 100 - i + \"%\"}, r.animate)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9068,
+ "nodeLength": 29,
+ "src": "\"vertical\" === this.orientation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9120,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9474,
+ "nodeLength": 136,
+ "src": "e.preventDefault() , !this._keySliding && (this._keySliding = !0 , this._addClass(t(e.target), null, \"ui-state-active\") , i = this._start(e, a) , i === !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9493,
+ "nodeLength": 117,
+ "src": "!this._keySliding && (this._keySliding = !0 , this._addClass(t(e.target), null, \"ui-state-active\") , i = this._start(e, a) , i === !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9603,
+ "nodeLength": 6,
+ "src": "i === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9649,
+ "nodeLength": 25,
+ "src": "this._hasMultipleValues()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10075,
+ "nodeLength": 20,
+ "src": "s === this._valueMax()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10186,
+ "nodeLength": 20,
+ "src": "s === this._valueMin()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10329,
+ "nodeLength": 127,
+ "src": "this._keySliding && (this._keySliding = !1 , this._stop(e, i) , this._change(e, i) , this._removeClass(t(e.target), null, \"ui-state-active\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29216,
+ "nodeLength": 11,
+ "src": "t >= e && e + i > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29216,
+ "nodeLength": 4,
+ "src": "t >= e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29222,
+ "nodeLength": 5,
+ "src": "e + i > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29259,
+ "nodeLength": 77,
+ "src": "/left|right/.test(t.css(\"float\")) || /inline|table-cell/.test(t.css(\"display\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29561,
+ "nodeLength": 40,
+ "src": "\"handle\" === t && this._setHandleClassName()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29561,
+ "nodeLength": 12,
+ "src": "\"handle\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29767,
+ "nodeLength": 28,
+ "src": "this.instance.options.handle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 29947,
+ "nodeLength": 4,
+ "src": "t >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30082,
+ "nodeLength": 14,
+ "src": "this.reverting",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30100,
+ "nodeLength": 51,
+ "src": "this.options.disabled || \"static\" === this.options.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30123,
+ "nodeLength": 28,
+ "src": "\"static\" === this.options.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30223,
+ "nodeLength": 37,
+ "src": "t.data(this, o.widgetName + \"-item\") === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30285,
+ "nodeLength": 58,
+ "src": "t.data(e.target, o.widgetName + \"-item\") === o && (s = t(e.target))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30285,
+ "nodeLength": 41,
+ "src": "t.data(e.target, o.widgetName + \"-item\") === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30344,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30346,
+ "nodeLength": 115,
+ "src": "!this.options.handle || i || (t(this.options.handle, s).find(\"*\").addBack().each(function() {\n this === e.target && (n = !0);\n}) , n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30368,
+ "nodeLength": 93,
+ "src": "i || (t(this.options.handle, s).find(\"*\").addBack().each(function() {\n this === e.target && (n = !0);\n}) , n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30433,
+ "nodeLength": 23,
+ "src": "this === e.target && (n = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30433,
+ "nodeLength": 15,
+ "src": "this === e.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30580,
+ "nodeLength": 1613,
+ "src": "this.currentContainer = this , this.refreshPositions() , this.helper = this._createHelper(e) , this._cacheHelperProportions() , this._cacheMargins() , this.scrollParent = this.helper.scrollParent() , this.offset = this.currentItem.offset() , this.offset = {\n top: this.offset.top - this.margins.top, \n left: this.offset.left - this.margins.left} , t.extend(this.offset, {\n click: {\n left: e.pageX - this.offset.left, \n top: e.pageY - this.offset.top}, \n parent: this._getParentOffset(), \n relative: this._getRelativeOffset()}) , this.helper.css(\"position\", \"absolute\") , this.cssPosition = this.helper.css(\"position\") , this.originalPosition = this._generatePosition(e) , this.originalPageX = e.pageX , this.originalPageY = e.pageY , a.cursorAt && this._adjustOffsetFromHelper(a.cursorAt) , this.domPosition = {\n prev: this.currentItem.prev()[0], \n parent: this.currentItem.parent()[0]} , this.helper[0] !== this.currentItem[0] && this.currentItem.hide() , this._createPlaceholder() , a.containment && this._setContainment() , a.cursor && \"auto\" !== a.cursor && (o = this.document.find(\"body\") , this.storedCursor = o.css(\"cursor\") , o.css(\"cursor\", a.cursor) , this.storedStylesheet = t(\"\").appendTo(o)) , a.opacity && (this.helper.css(\"opacity\") && (this._storedOpacity = this.helper.css(\"opacity\")) , this.helper.css(\"opacity\", a.opacity)) , a.zIndex && (this.helper.css(\"zIndex\") && (this._storedZIndex = this.helper.css(\"zIndex\")) , this.helper.css(\"zIndex\", a.zIndex)) , this.scrollParent[0] !== this.document[0] && \"HTML\" !== this.scrollParent[0].tagName && (this.overflowOffset = this.scrollParent.offset()) , this._trigger(\"start\", e, this._uiHash()) , this._preserveHelperProportions || this._cacheHelperProportions() , !s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31233,
+ "nodeLength": 52,
+ "src": "a.cursorAt && this._adjustOffsetFromHelper(a.cursorAt)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31373,
+ "nodeLength": 61,
+ "src": "this.helper[0] !== this.currentItem[0] && this.currentItem.hide()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31373,
+ "nodeLength": 36,
+ "src": "this.helper[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31461,
+ "nodeLength": 37,
+ "src": "a.containment && this._setContainment()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31499,
+ "nodeLength": 210,
+ "src": "a.cursor && \"auto\" !== a.cursor && (o = this.document.find(\"body\") , this.storedCursor = o.css(\"cursor\") , o.css(\"cursor\", a.cursor) , this.storedStylesheet = t(\"\").appendTo(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31509,
+ "nodeLength": 200,
+ "src": "\"auto\" !== a.cursor && (o = this.document.find(\"body\") , this.storedCursor = o.css(\"cursor\") , o.css(\"cursor\", a.cursor) , this.storedStylesheet = t(\"\").appendTo(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31509,
+ "nodeLength": 17,
+ "src": "\"auto\" !== a.cursor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31710,
+ "nodeLength": 126,
+ "src": "a.opacity && (this.helper.css(\"opacity\") && (this._storedOpacity = this.helper.css(\"opacity\")) , this.helper.css(\"opacity\", a.opacity))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31722,
+ "nodeLength": 76,
+ "src": "this.helper.css(\"opacity\") && (this._storedOpacity = this.helper.css(\"opacity\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31837,
+ "nodeLength": 120,
+ "src": "a.zIndex && (this.helper.css(\"zIndex\") && (this._storedZIndex = this.helper.css(\"zIndex\")) , this.helper.css(\"zIndex\", a.zIndex))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31848,
+ "nodeLength": 73,
+ "src": "this.helper.css(\"zIndex\") && (this._storedZIndex = this.helper.css(\"zIndex\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31958,
+ "nodeLength": 128,
+ "src": "this.scrollParent[0] !== this.document[0] && \"HTML\" !== this.scrollParent[0].tagName && (this.overflowOffset = this.scrollParent.offset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31958,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31999,
+ "nodeLength": 87,
+ "src": "\"HTML\" !== this.scrollParent[0].tagName && (this.overflowOffset = this.scrollParent.offset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31999,
+ "nodeLength": 37,
+ "src": "\"HTML\" !== this.scrollParent[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32127,
+ "nodeLength": 63,
+ "src": "this._preserveHelperProportions || this._cacheHelperProportions()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32225,
+ "nodeLength": 4,
+ "src": "n >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32302,
+ "nodeLength": 45,
+ "src": "t.ui.ddmanager && (t.ui.ddmanager.current = this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32348,
+ "nodeLength": 71,
+ "src": "t.ui.ddmanager && !a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32364,
+ "nodeLength": 55,
+ "src": "!a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32661,
+ "nodeLength": 61,
+ "src": "this.lastPositionAbs || (this.lastPositionAbs = this.positionAbs)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32723,
+ "nodeLength": 1325,
+ "src": "this.options.scroll && (this.scrollParent[0] !== this.document[0] && \"HTML\" !== this.scrollParent[0].tagName ? (this.overflowOffset.top + this.scrollParent[0].offsetHeight - e.pageY < a.scrollSensitivity ? this.scrollParent[0].scrollTop = r = this.scrollParent[0].scrollTop + a.scrollSpeed : e.pageY - this.overflowOffset.top < a.scrollSensitivity && (this.scrollParent[0].scrollTop = r = this.scrollParent[0].scrollTop - a.scrollSpeed) , this.overflowOffset.left + this.scrollParent[0].offsetWidth - e.pageX < a.scrollSensitivity ? this.scrollParent[0].scrollLeft = r = this.scrollParent[0].scrollLeft + a.scrollSpeed : e.pageX - this.overflowOffset.left < a.scrollSensitivity && (this.scrollParent[0].scrollLeft = r = this.scrollParent[0].scrollLeft - a.scrollSpeed)) : (e.pageY - this.document.scrollTop() < a.scrollSensitivity ? r = this.document.scrollTop(this.document.scrollTop() - a.scrollSpeed) : this.window.height() - (e.pageY - this.document.scrollTop()) < a.scrollSensitivity && (r = this.document.scrollTop(this.document.scrollTop() + a.scrollSpeed)) , e.pageX - this.document.scrollLeft() < a.scrollSensitivity ? r = this.document.scrollLeft(this.document.scrollLeft() - a.scrollSpeed) : this.window.width() - (e.pageX - this.document.scrollLeft()) < a.scrollSensitivity && (r = this.document.scrollLeft(this.document.scrollLeft() + a.scrollSpeed))) , r !== !1 && t.ui.ddmanager && !a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32745,
+ "nodeLength": 78,
+ "src": "this.scrollParent[0] !== this.document[0] && \"HTML\" !== this.scrollParent[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32745,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32786,
+ "nodeLength": 37,
+ "src": "\"HTML\" !== this.scrollParent[0].tagName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32825,
+ "nodeLength": 85,
+ "src": "this.overflowOffset.top + this.scrollParent[0].offsetHeight - e.pageY < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32989,
+ "nodeLength": 132,
+ "src": "e.pageY - this.overflowOffset.top < a.scrollSensitivity && (this.scrollParent[0].scrollTop = r = this.scrollParent[0].scrollTop - a.scrollSpeed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32989,
+ "nodeLength": 51,
+ "src": "e.pageY - this.overflowOffset.top < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33122,
+ "nodeLength": 85,
+ "src": "this.overflowOffset.left + this.scrollParent[0].offsetWidth - e.pageX < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33288,
+ "nodeLength": 135,
+ "src": "e.pageX - this.overflowOffset.left < a.scrollSensitivity && (this.scrollParent[0].scrollLeft = r = this.scrollParent[0].scrollLeft - a.scrollSpeed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33288,
+ "nodeLength": 52,
+ "src": "e.pageX - this.overflowOffset.left < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33426,
+ "nodeLength": 53,
+ "src": "e.pageY - this.document.scrollTop() < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33547,
+ "nodeLength": 146,
+ "src": "this.window.height() - (e.pageY - this.document.scrollTop()) < a.scrollSensitivity && (r = this.document.scrollTop(this.document.scrollTop() + a.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33547,
+ "nodeLength": 76,
+ "src": "this.window.height() - (e.pageY - this.document.scrollTop()) < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33694,
+ "nodeLength": 54,
+ "src": "e.pageX - this.document.scrollLeft() < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33818,
+ "nodeLength": 148,
+ "src": "this.window.width() - (e.pageX - this.document.scrollLeft()) < a.scrollSensitivity && (r = this.document.scrollLeft(this.document.scrollLeft() + a.scrollSpeed))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33818,
+ "nodeLength": 76,
+ "src": "this.window.width() - (e.pageX - this.document.scrollLeft()) < a.scrollSensitivity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33968,
+ "nodeLength": 79,
+ "src": "r !== !1 && t.ui.ddmanager && !a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33968,
+ "nodeLength": 6,
+ "src": "r !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33976,
+ "nodeLength": 71,
+ "src": "t.ui.ddmanager && !a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33992,
+ "nodeLength": 55,
+ "src": "!a.dropBehaviour && t.ui.ddmanager.prepareOffsets(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34102,
+ "nodeLength": 95,
+ "src": "this.options.axis && \"y\" === this.options.axis || (this.helper[0].style.left = this.position.left + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34102,
+ "nodeLength": 42,
+ "src": "this.options.axis && \"y\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34121,
+ "nodeLength": 23,
+ "src": "\"y\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34198,
+ "nodeLength": 93,
+ "src": "this.options.axis && \"x\" === this.options.axis || (this.helper[0].style.top = this.position.top + \"px\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34198,
+ "nodeLength": 42,
+ "src": "this.options.axis && \"x\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34217,
+ "nodeLength": 23,
+ "src": "\"x\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34314,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34326,
+ "nodeLength": 279,
+ "src": "s = this.items[i] , n = s.item[0] , o = this._intersectsWithPointer(s) , o && s.instance === this.currentContainer && n !== this.currentItem[0] && this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n && !t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34387,
+ "nodeLength": 218,
+ "src": "o && s.instance === this.currentContainer && n !== this.currentItem[0] && this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n && !t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34390,
+ "nodeLength": 215,
+ "src": "s.instance === this.currentContainer && n !== this.currentItem[0] && this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n && !t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34390,
+ "nodeLength": 34,
+ "src": "s.instance === this.currentContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34426,
+ "nodeLength": 179,
+ "src": "n !== this.currentItem[0] && this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n && !t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34426,
+ "nodeLength": 23,
+ "src": "n !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34451,
+ "nodeLength": 154,
+ "src": "this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n && !t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34451,
+ "nodeLength": 46,
+ "src": "this.placeholder[1 === o ? \"next\" : \"prev\"]()[0] !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34468,
+ "nodeLength": 5,
+ "src": "1 === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34499,
+ "nodeLength": 106,
+ "src": "!t.contains(this.placeholder[0], n) && (\"semi-dynamic\" === this.options.type ? !t.contains(this.element[0], n) : !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34536,
+ "nodeLength": 34,
+ "src": "\"semi-dynamic\" === this.options.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34610,
+ "nodeLength": 98,
+ "src": "this.direction = 1 === o ? \"down\" : \"up\" , \"pointer\" !== this.options.tolerance && !this._intersectsWithSides(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34625,
+ "nodeLength": 5,
+ "src": "1 === o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34643,
+ "nodeLength": 65,
+ "src": "\"pointer\" !== this.options.tolerance && !this._intersectsWithSides(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34643,
+ "nodeLength": 34,
+ "src": "\"pointer\" !== this.options.tolerance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34817,
+ "nodeLength": 43,
+ "src": "t.ui.ddmanager && t.ui.ddmanager.drag(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34970,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34976,
+ "nodeLength": 92,
+ "src": "t.ui.ddmanager && !this.options.dropBehaviour && t.ui.ddmanager.drop(this, e) , this.options.revert",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34976,
+ "nodeLength": 72,
+ "src": "t.ui.ddmanager && !this.options.dropBehaviour && t.ui.ddmanager.drop(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34992,
+ "nodeLength": 56,
+ "src": "!this.options.dropBehaviour && t.ui.ddmanager.drop(this, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35134,
+ "nodeLength": 150,
+ "src": "o && \"x\" !== o || (a.left = n.left - this.offset.parent.left - this.margins.left + (this.offsetParent[0] === this.document[0].body ? 0 : this.offsetParent[0].scrollLeft))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35134,
+ "nodeLength": 10,
+ "src": "o && \"x\" !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35137,
+ "nodeLength": 7,
+ "src": "\"x\" !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35204,
+ "nodeLength": 44,
+ "src": "this.offsetParent[0] === this.document[0].body",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35285,
+ "nodeLength": 145,
+ "src": "o && \"y\" !== o || (a.top = n.top - this.offset.parent.top - this.margins.top + (this.offsetParent[0] === this.document[0].body ? 0 : this.offsetParent[0].scrollTop))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35285,
+ "nodeLength": 10,
+ "src": "o && \"y\" !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35288,
+ "nodeLength": 7,
+ "src": "\"y\" !== o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35351,
+ "nodeLength": 44,
+ "src": "this.offsetParent[0] === this.document[0].body",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35474,
+ "nodeLength": 37,
+ "src": "parseInt(this.options.revert, 10) || 500",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35591,
+ "nodeLength": 13,
+ "src": "this.dragging",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35658,
+ "nodeLength": 32,
+ "src": "\"original\" === this.options.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35847,
+ "nodeLength": 4,
+ "src": "e >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35922,
+ "nodeLength": 141,
+ "src": "this.containers[e].containerCache.over && (this.containers[e]._trigger(\"out\", null, this._uiHash(this)) , this.containers[e].containerCache.over = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36071,
+ "nodeLength": 406,
+ "src": "this.placeholder && (this.placeholder[0].parentNode && this.placeholder[0].parentNode.removeChild(this.placeholder[0]) , \"original\" !== this.options.helper && this.helper && this.helper[0].parentNode && this.helper.remove() , t.extend(this, {\n helper: null, \n dragging: !1, \n reverting: !1, \n _noFinalSort: null}) , this.domPosition.prev ? t(this.domPosition.prev).after(this.currentItem) : t(this.domPosition.parent).prepend(this.currentItem))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36090,
+ "nodeLength": 95,
+ "src": "this.placeholder[0].parentNode && this.placeholder[0].parentNode.removeChild(this.placeholder[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36186,
+ "nodeLength": 94,
+ "src": "\"original\" !== this.options.helper && this.helper && this.helper[0].parentNode && this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36186,
+ "nodeLength": 32,
+ "src": "\"original\" !== this.options.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36220,
+ "nodeLength": 60,
+ "src": "this.helper && this.helper[0].parentNode && this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36233,
+ "nodeLength": 47,
+ "src": "this.helper[0].parentNode && this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36353,
+ "nodeLength": 21,
+ "src": "this.domPosition.prev",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36535,
+ "nodeLength": 14,
+ "src": "e && e.connected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36565,
+ "nodeLength": 5,
+ "src": "e || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36599,
+ "nodeLength": 43,
+ "src": "t(e.item || this).attr(e.attribute || \"id\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36601,
+ "nodeLength": 12,
+ "src": "e.item || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36620,
+ "nodeLength": 17,
+ "src": "e.attribute || \"id\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36650,
+ "nodeLength": 30,
+ "src": "e.expression || /(.+)[\\-=_](.+)/",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36682,
+ "nodeLength": 65,
+ "src": "i && s.push((e.key || i[1] + \"[]\") + \"=\" + (e.key && e.expression ? i[1] : i[2]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36693,
+ "nodeLength": 16,
+ "src": "e.key || i[1] + \"[]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36716,
+ "nodeLength": 19,
+ "src": "e.key && e.expression",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36750,
+ "nodeLength": 35,
+ "src": "!s.length && e.key && s.push(e.key + \"=\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36761,
+ "nodeLength": 24,
+ "src": "e.key && s.push(e.key + \"=\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36848,
+ "nodeLength": 14,
+ "src": "e && e.connected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36878,
+ "nodeLength": 5,
+ "src": "e || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36909,
+ "nodeLength": 43,
+ "src": "t(e.item || this).attr(e.attribute || \"id\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 12,
+ "src": "e.item || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 28,
+ "nodeLength": 17,
+ "src": "e.attribute || \"id\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37198,
+ "nodeLength": 37,
+ "src": "\"x\" === this.options.axis || s + l > r && h > s + l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37198,
+ "nodeLength": 23,
+ "src": "\"x\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37223,
+ "nodeLength": 12,
+ "src": "s + l > r && h > s + l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37223,
+ "nodeLength": 5,
+ "src": "s + l > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37230,
+ "nodeLength": 5,
+ "src": "h > s + l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37238,
+ "nodeLength": 37,
+ "src": "\"y\" === this.options.axis || e + c > o && a > e + c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37238,
+ "nodeLength": 23,
+ "src": "\"y\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37263,
+ "nodeLength": 12,
+ "src": "e + c > o && a > e + c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37263,
+ "nodeLength": 5,
+ "src": "e + c > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37270,
+ "nodeLength": 5,
+ "src": "a > e + c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37278,
+ "nodeLength": 4,
+ "src": "u && d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37289,
+ "nodeLength": 200,
+ "src": "\"pointer\" === this.options.tolerance || this.options.forcePointerForContainers || \"pointer\" !== this.options.tolerance && this.helperProportions[this.floating ? \"width\" : \"height\"] > t[this.floating ? \"width\" : \"height\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37289,
+ "nodeLength": 34,
+ "src": "\"pointer\" === this.options.tolerance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37325,
+ "nodeLength": 164,
+ "src": "this.options.forcePointerForContainers || \"pointer\" !== this.options.tolerance && this.helperProportions[this.floating ? \"width\" : \"height\"] > t[this.floating ? \"width\" : \"height\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37365,
+ "nodeLength": 124,
+ "src": "\"pointer\" !== this.options.tolerance && this.helperProportions[this.floating ? \"width\" : \"height\"] > t[this.floating ? \"width\" : \"height\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37365,
+ "nodeLength": 34,
+ "src": "\"pointer\" !== this.options.tolerance",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37401,
+ "nodeLength": 88,
+ "src": "this.helperProportions[this.floating ? \"width\" : \"height\"] > t[this.floating ? \"width\" : \"height\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37424,
+ "nodeLength": 13,
+ "src": "this.floating",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37458,
+ "nodeLength": 13,
+ "src": "this.floating",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37492,
+ "nodeLength": 144,
+ "src": "e + this.helperProportions.width / 2 > o && a > i - this.helperProportions.width / 2 && s + this.helperProportions.height / 2 > r && h > n - this.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37492,
+ "nodeLength": 34,
+ "src": "e + this.helperProportions.width / 2 > o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37528,
+ "nodeLength": 108,
+ "src": "a > i - this.helperProportions.width / 2 && s + this.helperProportions.height / 2 > r && h > n - this.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37528,
+ "nodeLength": 34,
+ "src": "a > i - this.helperProportions.width / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37564,
+ "nodeLength": 72,
+ "src": "s + this.helperProportions.height / 2 > r && h > n - this.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37564,
+ "nodeLength": 35,
+ "src": "s + this.helperProportions.height / 2 > r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37601,
+ "nodeLength": 35,
+ "src": "h > n - this.helperProportions.height / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37683,
+ "nodeLength": 100,
+ "src": "\"x\" === this.options.axis || this._isOverAxis(this.positionAbs.top + this.offset.click.top, t.top, t.height)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37683,
+ "nodeLength": 23,
+ "src": "\"x\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37786,
+ "nodeLength": 102,
+ "src": "\"y\" === this.options.axis || this._isOverAxis(this.positionAbs.left + this.offset.click.left, t.left, t.width)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37786,
+ "nodeLength": 23,
+ "src": "\"y\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37891,
+ "nodeLength": 4,
+ "src": "s && n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37903,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37978,
+ "nodeLength": 13,
+ "src": "this.floating",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37992,
+ "nodeLength": 23,
+ "src": "\"right\" === i || \"down\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 37992,
+ "nodeLength": 11,
+ "src": "\"right\" === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38005,
+ "nodeLength": 10,
+ "src": "\"down\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38020,
+ "nodeLength": 19,
+ "src": "e && (\"down\" === e ? 2 : 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38024,
+ "nodeLength": 10,
+ "src": "\"down\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38340,
+ "nodeLength": 16,
+ "src": "this.floating && n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38357,
+ "nodeLength": 30,
+ "src": "\"right\" === n && i || \"left\" === n && !i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38357,
+ "nodeLength": 14,
+ "src": "\"right\" === n && i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38357,
+ "nodeLength": 11,
+ "src": "\"right\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38373,
+ "nodeLength": 14,
+ "src": "\"left\" === n && !i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38373,
+ "nodeLength": 10,
+ "src": "\"left\" === n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38388,
+ "nodeLength": 32,
+ "src": "s && (\"down\" === s && e || \"up\" === s && !e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38392,
+ "nodeLength": 27,
+ "src": "\"down\" === s && e || \"up\" === s && !e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38392,
+ "nodeLength": 13,
+ "src": "\"down\" === s && e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38392,
+ "nodeLength": 10,
+ "src": "\"down\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38407,
+ "nodeLength": 12,
+ "src": "\"up\" === s && !e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38407,
+ "nodeLength": 8,
+ "src": "\"up\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38518,
+ "nodeLength": 24,
+ "src": "0 !== t && (t > 0 ? \"down\" : \"up\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38518,
+ "nodeLength": 5,
+ "src": "0 !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38526,
+ "nodeLength": 3,
+ "src": "t > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38644,
+ "nodeLength": 27,
+ "src": "0 !== t && (t > 0 ? \"right\" : \"left\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38644,
+ "nodeLength": 5,
+ "src": "0 !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38652,
+ "nodeLength": 3,
+ "src": "t > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38829,
+ "nodeLength": 34,
+ "src": "t.connectWith.constructor === String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38998,
+ "nodeLength": 4,
+ "src": "l && e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39020,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39073,
+ "nodeLength": 4,
+ "src": "n >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39117,
+ "nodeLength": 195,
+ "src": "a && a !== this && !a.options.disabled && h.push([t.isFunction(a.options.items) ? a.options.items.call(a.element) : t(a.options.items, a.element).not(\".ui-sortable-helper\").not(\".ui-sortable-placeholder\"), a])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39120,
+ "nodeLength": 192,
+ "src": "a !== this && !a.options.disabled && h.push([t.isFunction(a.options.items) ? a.options.items.call(a.element) : t(a.options.items, a.element).not(\".ui-sortable-helper\").not(\".ui-sortable-placeholder\"), a])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39120,
+ "nodeLength": 8,
+ "src": "a !== this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39130,
+ "nodeLength": 182,
+ "src": "!a.options.disabled && h.push([t.isFunction(a.options.items) ? a.options.items.call(a.element) : t(a.options.items, a.element).not(\".ui-sortable-helper\").not(\".ui-sortable-placeholder\"), a])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39159,
+ "nodeLength": 29,
+ "src": "t.isFunction(a.options.items)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39325,
+ "nodeLength": 32,
+ "src": "t.isFunction(this.options.items)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39560,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39750,
+ "nodeLength": 10,
+ "src": "e.length > i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39768,
+ "nodeLength": 16,
+ "src": "e[i] === t.item[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39906,
+ "nodeLength": 32,
+ "src": "t.isFunction(this.options.items)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40073,
+ "nodeLength": 13,
+ "src": "d && this.ready",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40104,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40157,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40201,
+ "nodeLength": 191,
+ "src": "o && o !== this && !o.options.disabled && (u.push([t.isFunction(o.options.items) ? o.options.items.call(o.element[0], e, {\n item: this.currentItem}) : t(o.options.items, o.element), o]) , this.containers.push(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40204,
+ "nodeLength": 188,
+ "src": "o !== this && !o.options.disabled && (u.push([t.isFunction(o.options.items) ? o.options.items.call(o.element[0], e, {\n item: this.currentItem}) : t(o.options.items, o.element), o]) , this.containers.push(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40204,
+ "nodeLength": 8,
+ "src": "o !== this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40214,
+ "nodeLength": 178,
+ "src": "!o.options.disabled && (u.push([t.isFunction(o.options.items) ? o.options.items.call(o.element[0], e, {\n item: this.currentItem}) : t(o.options.items, o.element), o]) , this.containers.push(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40244,
+ "nodeLength": 29,
+ "src": "t.isFunction(o.options.items)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40410,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40458,
+ "nodeLength": 3,
+ "src": "l > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40612,
+ "nodeLength": 17,
+ "src": "this.items.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40630,
+ "nodeLength": 61,
+ "src": "\"x\" === this.options.axis || this._isFloating(this.items[0].item)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40630,
+ "nodeLength": 23,
+ "src": "\"x\" === this.options.axis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40695,
+ "nodeLength": 76,
+ "src": "this.offsetParent && this.helper && (this.offset.parent = this._getParentOffset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40714,
+ "nodeLength": 57,
+ "src": "this.helper && (this.offset.parent = this._getParentOffset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40810,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40835,
+ "nodeLength": 264,
+ "src": "s.instance !== this.currentContainer && this.currentContainer && s.item[0] !== this.currentItem[0] || (n = this.options.toleranceElement ? t(this.options.toleranceElement, s.item) : s.item , e || (s.width = n.outerWidth() , s.height = n.outerHeight()) , o = n.offset() , s.left = o.left , s.top = o.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40835,
+ "nodeLength": 90,
+ "src": "s.instance !== this.currentContainer && this.currentContainer && s.item[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40835,
+ "nodeLength": 34,
+ "src": "s.instance !== this.currentContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40871,
+ "nodeLength": 54,
+ "src": "this.currentContainer && s.item[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40894,
+ "nodeLength": 31,
+ "src": "s.item[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40930,
+ "nodeLength": 29,
+ "src": "this.options.toleranceElement",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41007,
+ "nodeLength": 52,
+ "src": "e || (s.width = n.outerWidth() , s.height = n.outerHeight())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41103,
+ "nodeLength": 58,
+ "src": "this.options.custom && this.options.custom.refreshContainers",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41247,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41592,
+ "nodeLength": 7,
+ "src": "e || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41618,
+ "nodeLength": 880,
+ "src": "s.placeholder && s.placeholder.constructor !== String || (i = s.placeholder , s.placeholder = {\n element: function() {\n var s = e.currentItem[0].nodeName.toLowerCase(), n = t(\"<\" + s + \">\", e.document[0]);\n return e._addClass(n, \"ui-sortable-placeholder\", i || e.currentItem[0].className)._removeClass(n, \"ui-sortable-helper\") , \"tbody\" === s ? e._createTrPlaceholder(e.currentItem.find(\"tr\").eq(0), t(\"\", e.document[0]).appendTo(n)) : \"tr\" === s ? e._createTrPlaceholder(e.currentItem, n) : \"img\" === s && n.attr(\"src\", e.currentItem.attr(\"src\")) , i || n.css(\"visibility\", \"hidden\") , n;\n}, \n update: function(t, n) {\n (!i || s.forcePlaceholderSize) && (n.height() || n.height(e.currentItem.innerHeight() - parseInt(e.currentItem.css(\"paddingTop\") || 0, 10) - parseInt(e.currentItem.css(\"paddingBottom\") || 0, 10)) , n.width() || n.width(e.currentItem.innerWidth() - parseInt(e.currentItem.css(\"paddingLeft\") || 0, 10) - parseInt(e.currentItem.css(\"paddingRight\") || 0, 10)));\n}})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41618,
+ "nodeLength": 49,
+ "src": "s.placeholder && s.placeholder.constructor !== String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41633,
+ "nodeLength": 34,
+ "src": "s.placeholder.constructor !== String",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41842,
+ "nodeLength": 29,
+ "src": "i || e.currentItem[0].className",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 41910,
+ "nodeLength": 11,
+ "src": "\"tbody\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42013,
+ "nodeLength": 8,
+ "src": "\"tr\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42062,
+ "nodeLength": 50,
+ "src": "\"img\" === s && n.attr(\"src\", e.currentItem.attr(\"src\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42062,
+ "nodeLength": 9,
+ "src": "\"img\" === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42113,
+ "nodeLength": 31,
+ "src": "i || n.css(\"visibility\", \"hidden\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42170,
+ "nodeLength": 325,
+ "src": "(!i || s.forcePlaceholderSize) && (n.height() || n.height(e.currentItem.innerHeight() - parseInt(e.currentItem.css(\"paddingTop\") || 0, 10) - parseInt(e.currentItem.css(\"paddingBottom\") || 0, 10)) , n.width() || n.width(e.currentItem.innerWidth() - parseInt(e.currentItem.css(\"paddingLeft\") || 0, 10) - parseInt(e.currentItem.css(\"paddingRight\") || 0, 10)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42170,
+ "nodeLength": 26,
+ "src": "!i || s.forcePlaceholderSize",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42200,
+ "nodeLength": 148,
+ "src": "n.height() || n.height(e.currentItem.innerHeight() - parseInt(e.currentItem.css(\"paddingTop\") || 0, 10) - parseInt(e.currentItem.css(\"paddingBottom\") || 0, 10))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42258,
+ "nodeLength": 34,
+ "src": "e.currentItem.css(\"paddingTop\") || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42306,
+ "nodeLength": 37,
+ "src": "e.currentItem.css(\"paddingBottom\") || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42349,
+ "nodeLength": 145,
+ "src": "n.width() || n.width(e.currentItem.innerWidth() - parseInt(e.currentItem.css(\"paddingLeft\") || 0, 10) - parseInt(e.currentItem.css(\"paddingRight\") || 0, 10))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42404,
+ "nodeLength": 35,
+ "src": "e.currentItem.css(\"paddingLeft\") || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42453,
+ "nodeLength": 36,
+ "src": "e.currentItem.css(\"paddingRight\") || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42767,
+ "nodeLength": 26,
+ "src": "t(this).attr(\"colspan\") || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42910,
+ "nodeLength": 4,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42922,
+ "nodeLength": 62,
+ "src": "!t.contains(this.currentItem[0], this.containers[i].element[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42988,
+ "nodeLength": 55,
+ "src": "this._intersectsWith(this.containers[i].containerCache)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43048,
+ "nodeLength": 57,
+ "src": "d && t.contains(this.containers[i].element[0], d.element[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43145,
+ "nodeLength": 138,
+ "src": "this.containers[i].containerCache.over && (this.containers[i]._trigger(\"out\", e, this._uiHash(this)) , this.containers[i].containerCache.over = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43287,
+ "nodeLength": 1,
+ "src": "d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43292,
+ "nodeLength": 26,
+ "src": "1 === this.containers.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43319,
+ "nodeLength": 139,
+ "src": "this.containers[p].containerCache.over || (this.containers[p]._trigger(\"over\", e, this._uiHash(this)) , this.containers[p].containerCache.over = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43483,
+ "nodeLength": 46,
+ "src": "d.floating || this._isFloating(this.currentItem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43532,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43549,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43570,
+ "nodeLength": 1,
+ "src": "c",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43610,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43619,
+ "nodeLength": 269,
+ "src": "t.contains(this.containers[p].element[0], this.items[s].item[0]) && this.items[s].item[0] !== this.currentItem[0] && (h = this.items[s].item.offset()[a] , l = !1 , e[u] - h > this.items[s][r] / 2 && (l = !0) , n > Math.abs(e[u] - h) && (n = Math.abs(e[u] - h) , o = this.items[s] , this.direction = l ? \"up\" : \"down\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43684,
+ "nodeLength": 204,
+ "src": "this.items[s].item[0] !== this.currentItem[0] && (h = this.items[s].item.offset()[a] , l = !1 , e[u] - h > this.items[s][r] / 2 && (l = !0) , n > Math.abs(e[u] - h) && (n = Math.abs(e[u] - h) , o = this.items[s] , this.direction = l ? \"up\" : \"down\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43684,
+ "nodeLength": 43,
+ "src": "this.items[s].item[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43768,
+ "nodeLength": 33,
+ "src": "e[u] - h > this.items[s][r] / 2 && (l = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43768,
+ "nodeLength": 25,
+ "src": "e[u] - h > this.items[s][r] / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43802,
+ "nodeLength": 85,
+ "src": "n > Math.abs(e[u] - h) && (n = Math.abs(e[u] - h) , o = this.items[s] , this.direction = l ? \"up\" : \"down\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43802,
+ "nodeLength": 18,
+ "src": "n > Math.abs(e[u] - h)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43873,
+ "nodeLength": 1,
+ "src": "l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43892,
+ "nodeLength": 29,
+ "src": "!o && !this.options.dropOnEmpty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43932,
+ "nodeLength": 42,
+ "src": "this.currentContainer === this.containers[p]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43982,
+ "nodeLength": 141,
+ "src": "this.currentContainer.containerCache.over || (this.containers[p]._trigger(\"over\", e, this._uiHash()) , this.currentContainer.containerCache.over = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44131,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44576,
+ "nodeLength": 22,
+ "src": "t.isFunction(i.helper)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44655,
+ "nodeLength": 18,
+ "src": "\"clone\" === i.helper",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44723,
+ "nodeLength": 113,
+ "src": "s.parents(\"body\").length || t(\"parent\" !== i.appendTo ? i.appendTo : this.currentItem[0].parentNode)[0].appendChild(s[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44751,
+ "nodeLength": 21,
+ "src": "\"parent\" !== i.appendTo",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44837,
+ "nodeLength": 233,
+ "src": "s[0] === this.currentItem[0] && (this._storedCSS = {\n width: this.currentItem[0].style.width, \n height: this.currentItem[0].style.height, \n position: this.currentItem.css(\"position\"), \n top: this.currentItem.css(\"top\"), \n left: this.currentItem.css(\"left\")})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44837,
+ "nodeLength": 26,
+ "src": "s[0] === this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45072,
+ "nodeLength": 72,
+ "src": "(!s[0].style.width || i.forceHelperSize) && s.width(this.currentItem.width())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45072,
+ "nodeLength": 36,
+ "src": "!s[0].style.width || i.forceHelperSize",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45146,
+ "nodeLength": 75,
+ "src": "(!s[0].style.height || i.forceHelperSize) && s.height(this.currentItem.height())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45146,
+ "nodeLength": 37,
+ "src": "!s[0].style.height || i.forceHelperSize",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45261,
+ "nodeLength": 36,
+ "src": "\"string\" == typeof e && (e = e.split(\" \"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45261,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45298,
+ "nodeLength": 43,
+ "src": "t.isArray(e) && (e = {\n left: +e[0], \n top: +e[1] || 0})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45331,
+ "nodeLength": 8,
+ "src": "+e[1] || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45342,
+ "nodeLength": 61,
+ "src": "\"left\" in e && (this.offset.click.left = e.left + this.margins.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45404,
+ "nodeLength": 92,
+ "src": "\"right\" in e && (this.offset.click.left = this.helperProportions.width - e.right + this.margins.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45497,
+ "nodeLength": 57,
+ "src": "\"top\" in e && (this.offset.click.top = e.top + this.margins.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45555,
+ "nodeLength": 93,
+ "src": "\"bottom\" in e && (this.offset.click.top = this.helperProportions.height - e.bottom + this.margins.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45762,
+ "nodeLength": 204,
+ "src": "\"absolute\" === this.cssPosition && this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45762,
+ "nodeLength": 29,
+ "src": "\"absolute\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45793,
+ "nodeLength": 173,
+ "src": "this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45793,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45834,
+ "nodeLength": 132,
+ "src": "t.contains(this.scrollParent[0], this.offsetParent[0]) && (e.left += this.scrollParent.scrollLeft() , e.top += this.scrollParent.scrollTop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45968,
+ "nodeLength": 157,
+ "src": "(this.offsetParent[0] === this.document[0].body || this.offsetParent[0].tagName && \"html\" === this.offsetParent[0].tagName.toLowerCase() && t.ui.ie) && (e = {\n top: 0, \n left: 0})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45968,
+ "nodeLength": 136,
+ "src": "this.offsetParent[0] === this.document[0].body || this.offsetParent[0].tagName && \"html\" === this.offsetParent[0].tagName.toLowerCase() && t.ui.ie",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45968,
+ "nodeLength": 44,
+ "src": "this.offsetParent[0] === this.document[0].body",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46014,
+ "nodeLength": 90,
+ "src": "this.offsetParent[0].tagName && \"html\" === this.offsetParent[0].tagName.toLowerCase() && t.ui.ie",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46044,
+ "nodeLength": 60,
+ "src": "\"html\" === this.offsetParent[0].tagName.toLowerCase() && t.ui.ie",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46044,
+ "nodeLength": 51,
+ "src": "\"html\" === this.offsetParent[0].tagName.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46138,
+ "nodeLength": 55,
+ "src": "parseInt(this.offsetParent.css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46208,
+ "nodeLength": 56,
+ "src": "parseInt(this.offsetParent.css(\"borderLeftWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46301,
+ "nodeLength": 29,
+ "src": "\"relative\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46384,
+ "nodeLength": 38,
+ "src": "parseInt(this.helper.css(\"top\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46467,
+ "nodeLength": 39,
+ "src": "parseInt(this.helper.css(\"left\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46606,
+ "nodeLength": 50,
+ "src": "parseInt(this.currentItem.css(\"marginLeft\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46661,
+ "nodeLength": 49,
+ "src": "parseInt(this.currentItem.css(\"marginTop\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46890,
+ "nodeLength": 67,
+ "src": "\"parent\" === n.containment && (n.containment = this.helper[0].parentNode)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46890,
+ "nodeLength": 24,
+ "src": "\"parent\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46959,
+ "nodeLength": 499,
+ "src": "(\"document\" === n.containment || \"window\" === n.containment) && (this.containment = [0 - this.offset.relative.left - this.offset.parent.left, 0 - this.offset.relative.top - this.offset.parent.top, \"document\" === n.containment ? this.document.width() : this.window.width() - this.helperProportions.width - this.margins.left, (\"document\" === n.containment ? this.document.height() || document.body.parentNode.scrollHeight : this.window.height() || this.document[0].body.parentNode.scrollHeight) - this.helperProportions.height - this.margins.top])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46959,
+ "nodeLength": 52,
+ "src": "\"document\" === n.containment || \"window\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46959,
+ "nodeLength": 26,
+ "src": "\"document\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 46987,
+ "nodeLength": 24,
+ "src": "\"window\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47135,
+ "nodeLength": 26,
+ "src": "\"document\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47252,
+ "nodeLength": 26,
+ "src": "\"document\" === n.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47279,
+ "nodeLength": 61,
+ "src": "this.document.height() || document.body.parentNode.scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47341,
+ "nodeLength": 67,
+ "src": "this.window.height() || this.document[0].body.parentNode.scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47459,
+ "nodeLength": 776,
+ "src": "/^(document|window|parent)$/.test(n.containment) || (e = t(n.containment)[0] , i = t(n.containment).offset() , s = \"hidden\" !== t(e).css(\"overflow\") , this.containment = [i.left + (parseInt(t(e).css(\"borderLeftWidth\"), 10) || 0) + (parseInt(t(e).css(\"paddingLeft\"), 10) || 0) - this.margins.left, i.top + (parseInt(t(e).css(\"borderTopWidth\"), 10) || 0) + (parseInt(t(e).css(\"paddingTop\"), 10) || 0) - this.margins.top, i.left + (s ? Math.max(e.scrollWidth, e.offsetWidth) : e.offsetWidth) - (parseInt(t(e).css(\"borderLeftWidth\"), 10) || 0) - (parseInt(t(e).css(\"paddingRight\"), 10) || 0) - this.helperProportions.width - this.margins.left, i.top + (s ? Math.max(e.scrollHeight, e.offsetHeight) : e.offsetHeight) - (parseInt(t(e).css(\"borderTopWidth\"), 10) || 0) - (parseInt(t(e).css(\"paddingBottom\"), 10) || 0) - this.helperProportions.height - this.margins.top])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47562,
+ "nodeLength": 31,
+ "src": "\"hidden\" !== t(e).css(\"overflow\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47620,
+ "nodeLength": 43,
+ "src": "parseInt(t(e).css(\"borderLeftWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47666,
+ "nodeLength": 39,
+ "src": "parseInt(t(e).css(\"paddingLeft\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47732,
+ "nodeLength": 42,
+ "src": "parseInt(t(e).css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47777,
+ "nodeLength": 38,
+ "src": "parseInt(t(e).css(\"paddingTop\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47842,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47898,
+ "nodeLength": 43,
+ "src": "parseInt(t(e).css(\"borderLeftWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47944,
+ "nodeLength": 40,
+ "src": "parseInt(t(e).css(\"paddingRight\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48040,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48099,
+ "nodeLength": 42,
+ "src": "parseInt(t(e).css(\"borderTopWidth\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48144,
+ "nodeLength": 41,
+ "src": "parseInt(t(e).css(\"paddingBottom\"), 10) || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48270,
+ "nodeLength": 20,
+ "src": "i || (i = this.position)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48297,
+ "nodeLength": 14,
+ "src": "\"absolute\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48319,
+ "nodeLength": 125,
+ "src": "\"absolute\" !== this.cssPosition || this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48319,
+ "nodeLength": 29,
+ "src": "\"absolute\" !== this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48350,
+ "nodeLength": 94,
+ "src": "this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48350,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48587,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48645,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48733,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48792,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48891,
+ "nodeLength": 125,
+ "src": "\"absolute\" !== this.cssPosition || this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48891,
+ "nodeLength": 29,
+ "src": "\"absolute\" !== this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48922,
+ "nodeLength": 94,
+ "src": "this.scrollParent[0] !== this.document[0] && t.contains(this.scrollParent[0], this.offsetParent[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 48922,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49095,
+ "nodeLength": 165,
+ "src": "\"relative\" !== this.cssPosition || this.scrollParent[0] !== this.document[0] && this.scrollParent[0] !== this.offsetParent[0] || (this.offset.relative = this._getRelativeOffset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49095,
+ "nodeLength": 29,
+ "src": "\"relative\" !== this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49126,
+ "nodeLength": 134,
+ "src": "this.scrollParent[0] !== this.document[0] && this.scrollParent[0] !== this.offsetParent[0] || (this.offset.relative = this._getRelativeOffset())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49126,
+ "nodeLength": 84,
+ "src": "this.scrollParent[0] !== this.document[0] && this.scrollParent[0] !== this.offsetParent[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49126,
+ "nodeLength": 39,
+ "src": "this.scrollParent[0] !== this.document[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49167,
+ "nodeLength": 43,
+ "src": "this.scrollParent[0] !== this.offsetParent[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49261,
+ "nodeLength": 967,
+ "src": "this.originalPosition && (this.containment && (e.pageX - this.offset.click.left < this.containment[0] && (o = this.containment[0] + this.offset.click.left) , e.pageY - this.offset.click.top < this.containment[1] && (a = this.containment[1] + this.offset.click.top) , e.pageX - this.offset.click.left > this.containment[2] && (o = this.containment[2] + this.offset.click.left) , e.pageY - this.offset.click.top > this.containment[3] && (a = this.containment[3] + this.offset.click.top)) , n.grid && (i = this.originalPageY + Math.round((a - this.originalPageY) / n.grid[1]) * n.grid[1] , a = this.containment ? i - this.offset.click.top >= this.containment[1] && i - this.offset.click.top <= this.containment[3] ? i : i - this.offset.click.top >= this.containment[1] ? i - n.grid[1] : i + n.grid[1] : i , s = this.originalPageX + Math.round((o - this.originalPageX) / n.grid[0]) * n.grid[0] , o = this.containment ? s - this.offset.click.left >= this.containment[0] && s - this.offset.click.left <= this.containment[2] ? s : s - this.offset.click.left >= this.containment[0] ? s - n.grid[0] : s + n.grid[0] : s))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49285,
+ "nodeLength": 411,
+ "src": "this.containment && (e.pageX - this.offset.click.left < this.containment[0] && (o = this.containment[0] + this.offset.click.left) , e.pageY - this.offset.click.top < this.containment[1] && (a = this.containment[1] + this.offset.click.top) , e.pageX - this.offset.click.left > this.containment[2] && (o = this.containment[2] + this.offset.click.left) , e.pageY - this.offset.click.top > this.containment[3] && (a = this.containment[3] + this.offset.click.top))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49304,
+ "nodeLength": 98,
+ "src": "e.pageX - this.offset.click.left < this.containment[0] && (o = this.containment[0] + this.offset.click.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49304,
+ "nodeLength": 50,
+ "src": "e.pageX - this.offset.click.left < this.containment[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49403,
+ "nodeLength": 96,
+ "src": "e.pageY - this.offset.click.top < this.containment[1] && (a = this.containment[1] + this.offset.click.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49403,
+ "nodeLength": 49,
+ "src": "e.pageY - this.offset.click.top < this.containment[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49500,
+ "nodeLength": 98,
+ "src": "e.pageX - this.offset.click.left > this.containment[2] && (o = this.containment[2] + this.offset.click.left)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49500,
+ "nodeLength": 50,
+ "src": "e.pageX - this.offset.click.left > this.containment[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49599,
+ "nodeLength": 96,
+ "src": "e.pageY - this.offset.click.top > this.containment[3] && (a = this.containment[3] + this.offset.click.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49599,
+ "nodeLength": 49,
+ "src": "e.pageY - this.offset.click.top > this.containment[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49697,
+ "nodeLength": 530,
+ "src": "n.grid && (i = this.originalPageY + Math.round((a - this.originalPageY) / n.grid[1]) * n.grid[1] , a = this.containment ? i - this.offset.click.top >= this.containment[1] && i - this.offset.click.top <= this.containment[3] ? i : i - this.offset.click.top >= this.containment[1] ? i - n.grid[1] : i + n.grid[1] : i , s = this.originalPageX + Math.round((o - this.originalPageX) / n.grid[0]) * n.grid[0] , o = this.containment ? s - this.offset.click.left >= this.containment[0] && s - this.offset.click.left <= this.containment[2] ? s : s - this.offset.click.left >= this.containment[0] ? s - n.grid[0] : s + n.grid[0] : s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49784,
+ "nodeLength": 16,
+ "src": "this.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49801,
+ "nodeLength": 90,
+ "src": "i - this.offset.click.top >= this.containment[1] && i - this.offset.click.top <= this.containment[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49801,
+ "nodeLength": 44,
+ "src": "i - this.offset.click.top >= this.containment[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49847,
+ "nodeLength": 44,
+ "src": "i - this.offset.click.top <= this.containment[3]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49894,
+ "nodeLength": 44,
+ "src": "i - this.offset.click.top >= this.containment[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50043,
+ "nodeLength": 16,
+ "src": "this.containment",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50060,
+ "nodeLength": 92,
+ "src": "s - this.offset.click.left >= this.containment[0] && s - this.offset.click.left <= this.containment[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50060,
+ "nodeLength": 45,
+ "src": "s - this.offset.click.left >= this.containment[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50107,
+ "nodeLength": 45,
+ "src": "s - this.offset.click.left <= this.containment[2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50155,
+ "nodeLength": 45,
+ "src": "s - this.offset.click.left >= this.containment[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50307,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50365,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50465,
+ "nodeLength": 26,
+ "src": "\"fixed\" === this.cssPosition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50524,
+ "nodeLength": 1,
+ "src": "h",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50575,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50669,
+ "nodeLength": 23,
+ "src": "\"down\" === this.direction",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50739,
+ "nodeLength": 12,
+ "src": "this.counter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "13": [
+ null,
+ {
+ "position": 237,
+ "nodeLength": 43,
+ "src": "n === this.counter && this.refreshPositions(!s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 237,
+ "nodeLength": 16,
+ "src": "n === this.counter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22323,
+ "nodeLength": 155,
+ "src": "!this._noFinalSort && this.currentItem.parent().length && this.placeholder.before(this.currentItem) , this._noFinalSort = null , this.helper[0] === this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22323,
+ "nodeLength": 95,
+ "src": "!this._noFinalSort && this.currentItem.parent().length && this.placeholder.before(this.currentItem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22343,
+ "nodeLength": 75,
+ "src": "this.currentItem.parent().length && this.placeholder.before(this.currentItem)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22442,
+ "nodeLength": 36,
+ "src": "this.helper[0] === this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22506,
+ "nodeLength": 84,
+ "src": "(\"auto\" === this._storedCSS[s] || \"static\" === this._storedCSS[s]) && (this._storedCSS[s] = \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22506,
+ "nodeLength": 58,
+ "src": "\"auto\" === this._storedCSS[s] || \"static\" === this._storedCSS[s]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22506,
+ "nodeLength": 27,
+ "src": "\"auto\" === this._storedCSS[s]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22535,
+ "nodeLength": 29,
+ "src": "\"static\" === this._storedCSS[s]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22719,
+ "nodeLength": 100,
+ "src": "this.fromOutside && !e && n.push(function(t) {\n this._trigger(\"receive\", t, this._uiHash(this.fromOutside));\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22737,
+ "nodeLength": 82,
+ "src": "!e && n.push(function(t) {\n this._trigger(\"receive\", t, this._uiHash(this.fromOutside));\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22820,
+ "nodeLength": 218,
+ "src": "!this.fromOutside && this.domPosition.prev === this.currentItem.prev().not(\".ui-sortable-helper\")[0] && this.domPosition.parent === this.currentItem.parent()[0] || e || n.push(function(t) {\n this._trigger(\"update\", t, this._uiHash());\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22820,
+ "nodeLength": 152,
+ "src": "!this.fromOutside && this.domPosition.prev === this.currentItem.prev().not(\".ui-sortable-helper\")[0] && this.domPosition.parent === this.currentItem.parent()[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22839,
+ "nodeLength": 133,
+ "src": "this.domPosition.prev === this.currentItem.prev().not(\".ui-sortable-helper\")[0] && this.domPosition.parent === this.currentItem.parent()[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22839,
+ "nodeLength": 77,
+ "src": "this.domPosition.prev === this.currentItem.prev().not(\".ui-sortable-helper\")[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22918,
+ "nodeLength": 54,
+ "src": "this.domPosition.parent === this.currentItem.parent()[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 22974,
+ "nodeLength": 64,
+ "src": "e || n.push(function(t) {\n this._trigger(\"update\", t, this._uiHash());\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23039,
+ "nodeLength": 331,
+ "src": "this !== this.currentContainer && (e || (n.push(function(t) {\n this._trigger(\"remove\", t, this._uiHash());\n}) , n.push(function(t) {\n return function(e) {\n t._trigger(\"receive\", e, this._uiHash(this));\n};\n}.call(this, this.currentContainer)) , n.push(function(t) {\n return function(e) {\n t._trigger(\"update\", e, this._uiHash(this));\n};\n}.call(this, this.currentContainer))))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23039,
+ "nodeLength": 28,
+ "src": "this !== this.currentContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23070,
+ "nodeLength": 299,
+ "src": "e || (n.push(function(t) {\n this._trigger(\"remove\", t, this._uiHash());\n}) , n.push(function(t) {\n return function(e) {\n t._trigger(\"receive\", e, this._uiHash(this));\n};\n}.call(this, this.currentContainer)) , n.push(function(t) {\n return function(e) {\n t._trigger(\"update\", e, this._uiHash(this));\n};\n}.call(this, this.currentContainer)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23398,
+ "nodeLength": 4,
+ "src": "s >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23407,
+ "nodeLength": 50,
+ "src": "e || n.push(i(\"deactivate\", this, this.containers[s]))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23458,
+ "nodeLength": 123,
+ "src": "this.containers[s].containerCache.over && (n.push(i(\"out\", this, this.containers[s])) , this.containers[s].containerCache.over = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23585,
+ "nodeLength": 510,
+ "src": "this.storedCursor && (this.document.find(\"body\").css(\"cursor\", this.storedCursor) , this.storedStylesheet.remove()) , this._storedOpacity && this.helper.css(\"opacity\", this._storedOpacity) , this._storedZIndex && this.helper.css(\"zIndex\", \"auto\" === this._storedZIndex ? \"\" : this._storedZIndex) , this.dragging = !1 , e || this._trigger(\"beforeStop\", t, this._uiHash()) , this.placeholder[0].parentNode.removeChild(this.placeholder[0]) , this.cancelHelperRemoval || (this.helper[0] !== this.currentItem[0] && this.helper.remove() , this.helper = null) , !e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23585,
+ "nodeLength": 110,
+ "src": "this.storedCursor && (this.document.find(\"body\").css(\"cursor\", this.storedCursor) , this.storedStylesheet.remove())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23696,
+ "nodeLength": 67,
+ "src": "this._storedOpacity && this.helper.css(\"opacity\", this._storedOpacity)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23764,
+ "nodeLength": 95,
+ "src": "this._storedZIndex && this.helper.css(\"zIndex\", \"auto\" === this._storedZIndex ? \"\" : this._storedZIndex)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23809,
+ "nodeLength": 27,
+ "src": "\"auto\" === this._storedZIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23877,
+ "nodeLength": 47,
+ "src": "e || this._trigger(\"beforeStop\", t, this._uiHash())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23989,
+ "nodeLength": 103,
+ "src": "this.cancelHelperRemoval || (this.helper[0] !== this.currentItem[0] && this.helper.remove() , this.helper = null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24016,
+ "nodeLength": 58,
+ "src": "this.helper[0] !== this.currentItem[0] && this.helper.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24016,
+ "nodeLength": 36,
+ "src": "this.helper[0] !== this.currentItem[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24105,
+ "nodeLength": 10,
+ "src": "n.length > s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24251,
+ "nodeLength": 69,
+ "src": "t.Widget.prototype._trigger.apply(this, arguments) === !1 && this.cancel()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24251,
+ "nodeLength": 54,
+ "src": "t.Widget.prototype._trigger.apply(this, arguments) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24348,
+ "nodeLength": 7,
+ "src": "e || this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24391,
+ "nodeLength": 20,
+ "src": "i.placeholder || t([])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24515,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53720,
+ "nodeLength": 53,
+ "src": "\"\" !== this.value() && this._value(this.element.val(), !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53720,
+ "nodeLength": 17,
+ "src": "\"\" !== this.value()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54045,
+ "nodeLength": 27,
+ "src": "null != n && n.length && (e[s] = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54045,
+ "nodeLength": 7,
+ "src": "null != n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54054,
+ "nodeLength": 18,
+ "src": "n.length && (e[s] = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54107,
+ "nodeLength": 52,
+ "src": "this._start(t) && this._keydown(t) && t.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54123,
+ "nodeLength": 36,
+ "src": "this._keydown(t) && t.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54250,
+ "nodeLength": 15,
+ "src": "this.cancelBlur",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54328,
+ "nodeLength": 61,
+ "src": "this.previous !== this.element.val() && this._trigger(\"change\", t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54328,
+ "nodeLength": 34,
+ "src": "this.previous !== this.element.val()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54427,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54433,
+ "nodeLength": 31,
+ "src": "!this.spinning && !this._start(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54486,
+ "nodeLength": 3,
+ "src": "e > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54596,
+ "nodeLength": 28,
+ "src": "this.spinning && this._stop(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54714,
+ "nodeLength": 58,
+ "src": "this.element[0] === t.ui.safeActiveElement(this.document[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54773,
+ "nodeLength": 91,
+ "src": "e || (this.element.trigger(\"focus\") , this.previous = s , this._delay(function() {\n this.previous = s;\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 54873,
+ "nodeLength": 58,
+ "src": "this.element[0] === t.ui.safeActiveElement(this.document[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55077,
+ "nodeLength": 91,
+ "src": "this._start(e) !== !1 && this._repeat(null, t(e.currentTarget).hasClass(\"ui-spinner-up\") ? 1 : -1, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55077,
+ "nodeLength": 19,
+ "src": "this._start(e) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55116,
+ "nodeLength": 44,
+ "src": "t(e.currentTarget).hasClass(\"ui-spinner-up\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55258,
+ "nodeLength": 46,
+ "src": "t(e.currentTarget).hasClass(\"ui-state-active\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55305,
+ "nodeLength": 19,
+ "src": "this._start(e) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55347,
+ "nodeLength": 44,
+ "src": "t(e.currentTarget).hasClass(\"ui-spinner-up\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56217,
+ "nodeLength": 134,
+ "src": "this.buttons.height() > Math.ceil(.5 * this.uiSpinner.height()) && this.uiSpinner.height() > 0 && this.uiSpinner.height(this.uiSpinner.height())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56217,
+ "nodeLength": 59,
+ "src": "this.buttons.height() > Math.ceil(.5 * this.uiSpinner.height())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56278,
+ "nodeLength": 73,
+ "src": "this.uiSpinner.height() > 0 && this.uiSpinner.height(this.uiSpinner.height())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56278,
+ "nodeLength": 25,
+ "src": "this.uiSpinner.height() > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56660,
+ "nodeLength": 44,
+ "src": "this.spinning || this._trigger(\"start\", t) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56675,
+ "nodeLength": 29,
+ "src": "this._trigger(\"start\", t) !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56706,
+ "nodeLength": 30,
+ "src": "this.counter || (this.counter = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56788,
+ "nodeLength": 6,
+ "src": "t || 500",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56940,
+ "nodeLength": 15,
+ "src": "this.value() || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56956,
+ "nodeLength": 30,
+ "src": "this.counter || (this.counter = 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57042,
+ "nodeLength": 86,
+ "src": "this.spinning && this._trigger(\"spin\", e, {\n value: i}) === !1 || (this._value(i) , this.counter++)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57042,
+ "nodeLength": 53,
+ "src": "this.spinning && this._trigger(\"spin\", e, {\n value: i}) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57057,
+ "nodeLength": 38,
+ "src": "this._trigger(\"spin\", e, {\n value: i}) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57191,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57193,
+ "nodeLength": 15,
+ "src": "t.isFunction(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57330,
+ "nodeLength": 76,
+ "src": "null !== this.options.min && (t = Math.max(t, this._precisionOf(this.options.min)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57330,
+ "nodeLength": 23,
+ "src": "null !== this.options.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57469,
+ "nodeLength": 6,
+ "src": "-1 === i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57549,
+ "nodeLength": 12,
+ "src": "null !== s.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57655,
+ "nodeLength": 21,
+ "src": "null !== s.max && t > s.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57655,
+ "nodeLength": 12,
+ "src": "null !== s.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57669,
+ "nodeLength": 7,
+ "src": "t > s.max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57683,
+ "nodeLength": 21,
+ "src": "null !== s.min && s.min > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57683,
+ "nodeLength": 12,
+ "src": "null !== s.min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57697,
+ "nodeLength": 7,
+ "src": "s.min > t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57732,
+ "nodeLength": 132,
+ "src": "this.spinning && (clearTimeout(this.timer) , clearTimeout(this.mousewheelTimer) , this.counter = 0 , this.spinning = !1 , this._trigger(\"stop\", t))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57907,
+ "nodeLength": 33,
+ "src": "\"culture\" === t || \"numberFormat\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57907,
+ "nodeLength": 13,
+ "src": "\"culture\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57922,
+ "nodeLength": 18,
+ "src": "\"numberFormat\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58038,
+ "nodeLength": 73,
+ "src": "(\"max\" === t || \"min\" === t || \"step\" === t) && \"string\" == typeof e && (e = this._parse(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58038,
+ "nodeLength": 32,
+ "src": "\"max\" === t || \"min\" === t || \"step\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58038,
+ "nodeLength": 9,
+ "src": "\"max\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58049,
+ "nodeLength": 21,
+ "src": "\"min\" === t || \"step\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58049,
+ "nodeLength": 9,
+ "src": "\"min\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58060,
+ "nodeLength": 10,
+ "src": "\"step\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58073,
+ "nodeLength": 38,
+ "src": "\"string\" == typeof e && (e = this._parse(e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58073,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58112,
+ "nodeLength": 249,
+ "src": "\"icons\" === t && (s = this.buttons.first().find(\".ui-icon\") , this._removeClass(s, null, this.options.icons.up) , this._addClass(s, null, e.up) , n = this.buttons.last().find(\".ui-icon\") , this._removeClass(n, null, this.options.icons.down) , this._addClass(n, null, e.down))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58112,
+ "nodeLength": 11,
+ "src": "\"icons\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58551,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58642,
+ "nodeLength": 126,
+ "src": "\"string\" == typeof t && \"\" !== t && (t = window.Globalize && this.options.numberFormat ? Globalize.parseFloat(t, 10, this.options.culture) : +t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58642,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58662,
+ "nodeLength": 106,
+ "src": "\"\" !== t && (t = window.Globalize && this.options.numberFormat ? Globalize.parseFloat(t, 10, this.options.culture) : +t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58662,
+ "nodeLength": 6,
+ "src": "\"\" !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58673,
+ "nodeLength": 43,
+ "src": "window.Globalize && this.options.numberFormat",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58769,
+ "nodeLength": 16,
+ "src": "\"\" === t || isNaN(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58769,
+ "nodeLength": 6,
+ "src": "\"\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58820,
+ "nodeLength": 6,
+ "src": "\"\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58830,
+ "nodeLength": 43,
+ "src": "window.Globalize && this.options.numberFormat",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59145,
+ "nodeLength": 8,
+ "src": "null === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59157,
+ "nodeLength": 24,
+ "src": "t === this._adjustValue(t)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59210,
+ "nodeLength": 84,
+ "src": "\"\" !== t && (i = this._parse(t) , null !== i && (e || (i = this._adjustValue(i)) , t = this._format(i)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59210,
+ "nodeLength": 6,
+ "src": "\"\" !== t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59236,
+ "nodeLength": 57,
+ "src": "null !== i && (e || (i = this._adjustValue(i)) , t = this._format(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59236,
+ "nodeLength": 8,
+ "src": "null !== i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59247,
+ "nodeLength": 27,
+ "src": "e || (i = this._adjustValue(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59560,
+ "nodeLength": 66,
+ "src": "this._start() && (this._spin((t || 1) * this.options.step) , this._stop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59588,
+ "nodeLength": 4,
+ "src": "t || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59693,
+ "nodeLength": 67,
+ "src": "this._start() && (this._spin((t || 1) * -this.options.step) , this._stop())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59721,
+ "nodeLength": 4,
+ "src": "t || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59797,
+ "nodeLength": 4,
+ "src": "t || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59863,
+ "nodeLength": 4,
+ "src": "t || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59915,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60045,
+ "nodeLength": 287,
+ "src": "t.uiBackCompat !== !1 && t.widget(\"ui.spinner\", t.ui.spinner, {\n _enhance: function() {\n this.uiSpinner = this.element.attr(\"autocomplete\", \"off\").wrap(this._uiSpinnerHtml()).parent().append(this._buttonHtml());\n}, \n _uiSpinnerHtml: function() {\n return \"\";\n}, \n _buttonHtml: function() {\n return \"\";\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 60046,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 60873,
+ "nodeLength": 22,
+ "src": "e.hash.length > 1 && i === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60873,
+ "nodeLength": 15,
+ "src": "e.hash.length > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60890,
+ "nodeLength": 5,
+ "src": "i === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61128,
+ "nodeLength": 153,
+ "src": "t.isArray(i.disabled) && (i.disabled = t.unique(i.disabled.concat(t.map(this.tabs.filter(\".ui-state-disabled\"), function(t) {\n return e.tabs.index(t);\n}))).sort())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61294,
+ "nodeLength": 45,
+ "src": "this.options.active !== !1 && this.anchors.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61294,
+ "nodeLength": 24,
+ "src": "this.options.active !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61387,
+ "nodeLength": 39,
+ "src": "this.active.length && this.load(i.active)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61543,
+ "nodeLength": 212,
+ "src": "null === e && (s && this.tabs.each(function(i, n) {\n return t(n).attr(\"aria-controls\") === s ? (e = i , !1) : void 0;\n}) , null === e && (e = this.tabs.index(this.tabs.filter(\".ui-tabs-active\"))) , (null === e || -1 === e) && (e = this.tabs.length ? 0 : !1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61543,
+ "nodeLength": 8,
+ "src": "null === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61554,
+ "nodeLength": 87,
+ "src": "s && this.tabs.each(function(i, n) {\n return t(n).attr(\"aria-controls\") === s ? (e = i , !1) : void 0;\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61593,
+ "nodeLength": 30,
+ "src": "t(n).attr(\"aria-controls\") === s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61642,
+ "nodeLength": 66,
+ "src": "null === e && (e = this.tabs.index(this.tabs.filter(\".ui-tabs-active\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61642,
+ "nodeLength": 8,
+ "src": "null === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61710,
+ "nodeLength": 44,
+ "src": "(null === e || -1 === e) && (e = this.tabs.length ? 0 : !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61710,
+ "nodeLength": 16,
+ "src": "null === e || -1 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61710,
+ "nodeLength": 8,
+ "src": "null === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61720,
+ "nodeLength": 6,
+ "src": "-1 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61732,
+ "nodeLength": 16,
+ "src": "this.tabs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61756,
+ "nodeLength": 63,
+ "src": "e !== !1 && (e = this.tabs.index(this.tabs.eq(e)) , -1 === e && (e = i ? !1 : 0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61756,
+ "nodeLength": 6,
+ "src": "e !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61800,
+ "nodeLength": 18,
+ "src": "-1 === e && (e = i ? !1 : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61800,
+ "nodeLength": 6,
+ "src": "-1 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61811,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61820,
+ "nodeLength": 38,
+ "src": "!i && e === !1 && this.anchors.length && (e = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61824,
+ "nodeLength": 34,
+ "src": "e === !1 && this.anchors.length && (e = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61824,
+ "nodeLength": 6,
+ "src": "e === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61832,
+ "nodeLength": 26,
+ "src": "this.anchors.length && (e = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61922,
+ "nodeLength": 18,
+ "src": "this.active.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62098,
+ "nodeLength": 23,
+ "src": "!this._handlePageNav(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62542,
+ "nodeLength": 23,
+ "src": "s === this.options.active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62669,
+ "nodeLength": 176,
+ "src": "e.ctrlKey || e.metaKey || (i.attr(\"aria-selected\", \"false\") , this.tabs.eq(s).attr(\"aria-selected\", \"true\") , this.activating = this._delay(function() {\n this.option(\"active\", s);\n}, this.delay))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62680,
+ "nodeLength": 165,
+ "src": "e.metaKey || (i.attr(\"aria-selected\", \"false\") , this.tabs.eq(s).attr(\"aria-selected\", \"true\") , this.activating = this._delay(function() {\n this.option(\"active\", s);\n}, this.delay))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62874,
+ "nodeLength": 113,
+ "src": "this._handlePageNav(e) || e.ctrlKey && e.keyCode === t.ui.keyCode.UP && (e.preventDefault() , this.active.trigger(\"focus\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62898,
+ "nodeLength": 89,
+ "src": "e.ctrlKey && e.keyCode === t.ui.keyCode.UP && (e.preventDefault() , this.active.trigger(\"focus\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62909,
+ "nodeLength": 78,
+ "src": "e.keyCode === t.ui.keyCode.UP && (e.preventDefault() , this.active.trigger(\"focus\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62909,
+ "nodeLength": 27,
+ "src": "e.keyCode === t.ui.keyCode.UP",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63023,
+ "nodeLength": 42,
+ "src": "e.altKey && e.keyCode === t.ui.keyCode.PAGE_UP",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63033,
+ "nodeLength": 32,
+ "src": "e.keyCode === t.ui.keyCode.PAGE_UP",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63132,
+ "nodeLength": 44,
+ "src": "e.altKey && e.keyCode === t.ui.keyCode.PAGE_DOWN",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63142,
+ "nodeLength": 34,
+ "src": "e.keyCode === t.ui.keyCode.PAGE_DOWN",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63298,
+ "nodeLength": 10,
+ "src": "e > n && (e = 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63298,
+ "nodeLength": 3,
+ "src": "e > n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63309,
+ "nodeLength": 10,
+ "src": "0 > e && (e = n)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63309,
+ "nodeLength": 3,
+ "src": "0 > e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63351,
+ "nodeLength": 41,
+ "src": "-1 !== t.inArray(s(), this.options.disabled)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63396,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63543,
+ "nodeLength": 12,
+ "src": "\"active\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63601,
+ "nodeLength": 115,
+ "src": "\"collapsible\" === t && (this._toggleClass(\"ui-tabs-collapsible\", null, e) , e || this.options.active !== !1 || this._activate(0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63601,
+ "nodeLength": 17,
+ "src": "\"collapsible\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63669,
+ "nodeLength": 46,
+ "src": "e || this.options.active !== !1 || this._activate(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63672,
+ "nodeLength": 43,
+ "src": "this.options.active !== !1 || this._activate(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63672,
+ "nodeLength": 24,
+ "src": "this.options.active !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63717,
+ "nodeLength": 33,
+ "src": "\"event\" === t && this._setupEvents(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63717,
+ "nodeLength": 11,
+ "src": "\"event\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63751,
+ "nodeLength": 44,
+ "src": "\"heightStyle\" === t && this._setupHeightStyle(e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63751,
+ "nodeLength": 17,
+ "src": "\"heightStyle\" === t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63842,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64083,
+ "nodeLength": 34,
+ "src": "e.active !== !1 && this.anchors.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64083,
+ "nodeLength": 13,
+ "src": "e.active !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64118,
+ "nodeLength": 63,
+ "src": "this.active.length && !t.contains(this.tablist[0], this.active[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64182,
+ "nodeLength": 36,
+ "src": "this.tabs.length === e.disabled.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64731,
+ "nodeLength": 18,
+ "src": "this.active.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 65291,
+ "nodeLength": 52,
+ "src": "t(this).is(\".ui-state-disabled\") && e.preventDefault()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 65406,
+ "nodeLength": 59,
+ "src": "t(this).closest(\"li\").is(\".ui-state-disabled\") && this.blur()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 65889,
+ "nodeLength": 13,
+ "src": "e._isLocal(s)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 65975,
+ "nodeLength": 47,
+ "src": "h.attr(\"aria-controls\") || t({}).uniqueId()[0].id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66051,
+ "nodeLength": 71,
+ "src": "o.length || (o = e._createPanel(a) , o.insertAfter(e.panels[i - 1] || e.tablist))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66096,
+ "nodeLength": 24,
+ "src": "e.panels[i - 1] || e.tablist",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66153,
+ "nodeLength": 36,
+ "src": "o.length && (e.panels = e.panels.add(o))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66190,
+ "nodeLength": 36,
+ "src": "l && h.data(\"ui-tabs-aria-controls\", l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66405,
+ "nodeLength": 93,
+ "src": "i && (this._off(i.not(this.tabs)) , this._off(s.not(this.anchors)) , this._off(n.not(this.panels)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66527,
+ "nodeLength": 47,
+ "src": "this.tablist || this.element.find(\"ol, ul\").eq(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66705,
+ "nodeLength": 68,
+ "src": "t.isArray(e) && (e.length ? e.length === this.anchors.length && (e = !0) : e = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66720,
+ "nodeLength": 8,
+ "src": "e.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66729,
+ "nodeLength": 38,
+ "src": "e.length === this.anchors.length && (e = !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66729,
+ "nodeLength": 30,
+ "src": "e.length === this.anchors.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66778,
+ "nodeLength": 14,
+ "src": "s = this.tabs[n]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66804,
+ "nodeLength": 27,
+ "src": "e === !0 || -1 !== t.inArray(n, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66804,
+ "nodeLength": 6,
+ "src": "e === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66812,
+ "nodeLength": 19,
+ "src": "-1 !== t.inArray(n, e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67079,
+ "nodeLength": 6,
+ "src": "e === !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67122,
+ "nodeLength": 59,
+ "src": "e && t.each(e.split(\" \"), function(t, e) {\n i[e] = \"_eventHandler\";\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67536,
+ "nodeLength": 10,
+ "src": "\"fill\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67697,
+ "nodeLength": 51,
+ "src": "\"absolute\" !== s && \"fixed\" !== s && (i -= e.outerHeight(!0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67697,
+ "nodeLength": 14,
+ "src": "\"absolute\" !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67713,
+ "nodeLength": 35,
+ "src": "\"fixed\" !== s && (i -= e.outerHeight(!0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67713,
+ "nodeLength": 11,
+ "src": "\"fixed\" !== s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67960,
+ "nodeLength": 99,
+ "src": "\"auto\" === e && (i = 0 , this.panels.each(function() {\n i = Math.max(i, t(this).height(\"\").height());\n}).height(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67960,
+ "nodeLength": 10,
+ "src": "\"auto\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68161,
+ "nodeLength": 11,
+ "src": "o[0] === s[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68175,
+ "nodeLength": 16,
+ "src": "a && i.collapsible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68194,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68226,
+ "nodeLength": 8,
+ "src": "s.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68293,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68332,
+ "nodeLength": 360,
+ "src": "o.hasClass(\"ui-state-disabled\") || o.hasClass(\"ui-tabs-loading\") || this.running || a && !i.collapsible || this._trigger(\"beforeActivate\", e, c) === !1 || (i.active = r ? !1 : this.tabs.index(o) , this.active = a ? t() : o , this.xhr && this.xhr.abort() , l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\") , h.length && this.load(this.tabs.index(o), e) , this._toggle(e, c))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68365,
+ "nodeLength": 327,
+ "src": "o.hasClass(\"ui-tabs-loading\") || this.running || a && !i.collapsible || this._trigger(\"beforeActivate\", e, c) === !1 || (i.active = r ? !1 : this.tabs.index(o) , this.active = a ? t() : o , this.xhr && this.xhr.abort() , l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\") , h.length && this.load(this.tabs.index(o), e) , this._toggle(e, c))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68396,
+ "nodeLength": 296,
+ "src": "this.running || a && !i.collapsible || this._trigger(\"beforeActivate\", e, c) === !1 || (i.active = r ? !1 : this.tabs.index(o) , this.active = a ? t() : o , this.xhr && this.xhr.abort() , l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\") , h.length && this.load(this.tabs.index(o), e) , this._toggle(e, c))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68410,
+ "nodeLength": 282,
+ "src": "a && !i.collapsible || this._trigger(\"beforeActivate\", e, c) === !1 || (i.active = r ? !1 : this.tabs.index(o) , this.active = a ? t() : o , this.xhr && this.xhr.abort() , l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\") , h.length && this.load(this.tabs.index(o), e) , this._toggle(e, c))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68410,
+ "nodeLength": 17,
+ "src": "a && !i.collapsible",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68429,
+ "nodeLength": 263,
+ "src": "this._trigger(\"beforeActivate\", e, c) === !1 || (i.active = r ? !1 : this.tabs.index(o) , this.active = a ? t() : o , this.xhr && this.xhr.abort() , l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\") , h.length && this.load(this.tabs.index(o), e) , this._toggle(e, c))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68429,
+ "nodeLength": 40,
+ "src": "this._trigger(\"beforeActivate\", e, c) === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68481,
+ "nodeLength": 1,
+ "src": "r",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68517,
+ "nodeLength": 1,
+ "src": "a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68525,
+ "nodeLength": 26,
+ "src": "this.xhr && this.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68552,
+ "nodeLength": 79,
+ "src": "l.length || h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68562,
+ "nodeLength": 69,
+ "src": "h.length || t.error(\"jQuery UI Tabs: Mismatching fragment identifier.\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68632,
+ "nodeLength": 41,
+ "src": "h.length && this.load(this.tabs.index(o), e)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68853,
+ "nodeLength": 24,
+ "src": "a.length && o.options.show",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68974,
+ "nodeLength": 27,
+ "src": "r.length && this.options.hide",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69310,
+ "nodeLength": 18,
+ "src": "a.length && r.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69358,
+ "nodeLength": 95,
+ "src": "a.length && this.tabs.filter(function() {\n return 0 === t(this).attr(\"tabIndex\");\n}).attr(\"tabIndex\", -1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69403,
+ "nodeLength": 28,
+ "src": "0 === t(this).attr(\"tabIndex\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69609,
+ "nodeLength": 150,
+ "src": "s[0] !== this.active[0] && (s.length || (s = this.active) , i = s.find(\".ui-tabs-anchor\")[0] , this._eventHandler({\n target: i, \n currentTarget: i, \n preventDefault: t.noop}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69609,
+ "nodeLength": 21,
+ "src": "s[0] !== this.active[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69633,
+ "nodeLength": 25,
+ "src": "s.length || (s = this.active)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69792,
+ "nodeLength": 6,
+ "src": "e === !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69848,
+ "nodeLength": 103,
+ "src": "\"string\" == typeof e && (e = this.anchors.index(this.anchors.filter(\"[href$='\" + t.ui.escapeSelector(e) + \"']\")))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69848,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69975,
+ "nodeLength": 26,
+ "src": "this.xhr && this.xhr.abort()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70160,
+ "nodeLength": 30,
+ "src": "t.data(this, \"ui-tabs-destroy\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70396,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70511,
+ "nodeLength": 66,
+ "src": "\"content\" !== this.options.heightStyle && this.panels.css(\"height\", \"\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70511,
+ "nodeLength": 36,
+ "src": "\"content\" !== this.options.heightStyle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70626,
+ "nodeLength": 183,
+ "src": "i !== !1 && (void 0 === e ? i = !1 : (e = this._getIndex(e) , i = t.isArray(i) ? t.map(i, function(t) {\n return t !== e ? t : null;\n}) : t.map(this.tabs, function(t, i) {\n return i !== e ? i : null;\n})) , this._setOptionDisabled(i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70626,
+ "nodeLength": 6,
+ "src": "i !== !1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70635,
+ "nodeLength": 10,
+ "src": "void 0 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70674,
+ "nodeLength": 12,
+ "src": "t.isArray(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70714,
+ "nodeLength": 5,
+ "src": "t !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70766,
+ "nodeLength": 5,
+ "src": "i !== e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70862,
+ "nodeLength": 6,
+ "src": "i !== !0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70873,
+ "nodeLength": 10,
+ "src": "void 0 === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70897,
+ "nodeLength": 39,
+ "src": "e = this._getIndex(e) , -1 !== t.inArray(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70917,
+ "nodeLength": 19,
+ "src": "-1 !== t.inArray(e, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 70946,
+ "nodeLength": 12,
+ "src": "t.isArray(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71170,
+ "nodeLength": 33,
+ "src": "\"abort\" === e && s.panels.stop(!1, !0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71170,
+ "nodeLength": 11,
+ "src": "\"abort\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71266,
+ "nodeLength": 23,
+ "src": "t === s.xhr && delete s.xhr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71266,
+ "nodeLength": 9,
+ "src": "t === s.xhr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71291,
+ "nodeLength": 326,
+ "src": "this._isLocal(o[0]) || (this.xhr = t.ajax(this._ajaxSettings(o, i, r)) , this.xhr && \"canceled\" !== this.xhr.statusText && (this._addClass(n, \"ui-tabs-loading\") , a.attr(\"aria-busy\", \"true\") , this.xhr.done(function(t, e, n) {\n setTimeout(function() {\n a.html(t) , s._trigger(\"load\", i, r) , h(n, e);\n}, 1);\n}).fail(function(t, e) {\n setTimeout(function() {\n h(t, e);\n}, 1);\n})))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71356,
+ "nodeLength": 260,
+ "src": "this.xhr && \"canceled\" !== this.xhr.statusText && (this._addClass(n, \"ui-tabs-loading\") , a.attr(\"aria-busy\", \"true\") , this.xhr.done(function(t, e, n) {\n setTimeout(function() {\n a.html(t) , s._trigger(\"load\", i, r) , h(n, e);\n}, 1);\n}).fail(function(t, e) {\n setTimeout(function() {\n h(t, e);\n}, 1);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71366,
+ "nodeLength": 250,
+ "src": "\"canceled\" !== this.xhr.statusText && (this._addClass(n, \"ui-tabs-loading\") , a.attr(\"aria-busy\", \"true\") , this.xhr.done(function(t, e, n) {\n setTimeout(function() {\n a.html(t) , s._trigger(\"load\", i, r) , h(n, e);\n}, 1);\n}).fail(function(t, e) {\n setTimeout(function() {\n h(t, e);\n}, 1);\n}))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71366,
+ "nodeLength": 32,
+ "src": "\"canceled\" !== this.xhr.statusText",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 71924,
+ "nodeLength": 140,
+ "src": "t.uiBackCompat !== !1 && t.widget(\"ui.tabs\", t.ui.tabs, {\n _processTabs: function() {\n this._superApply(arguments) , this._addClass(this.tabs, \"ui-tab\");\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 71925,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 72205,
+ "nodeLength": 25,
+ "src": "t(this).attr(\"title\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72449,
+ "nodeLength": 30,
+ "src": "e.attr(\"aria-describedby\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72640,
+ "nodeLength": 30,
+ "src": "e.attr(\"aria-describedby\") || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72702,
+ "nodeLength": 21,
+ "src": "-1 !== n && s.splice(n, 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72702,
+ "nodeLength": 6,
+ "src": "-1 !== n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72776,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73214,
+ "nodeLength": 79,
+ "src": "\"content\" === e && t.each(this.tooltips, function(t, e) {\n s._updateContent(e.element);\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73214,
+ "nodeLength": 13,
+ "src": "\"content\" === e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73331,
+ "nodeLength": 1,
+ "src": "t",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73631,
+ "nodeLength": 15,
+ "src": "e.is(\"[title]\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73790,
+ "nodeLength": 70,
+ "src": "e.data(\"ui-tooltip-title\") && e.attr(\"title\", e.data(\"ui-tooltip-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73922,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73975,
+ "nodeLength": 453,
+ "src": "s.length && !s.data(\"ui-tooltip-id\") && (s.attr(\"title\") && s.data(\"ui-tooltip-title\", s.attr(\"title\")) , s.data(\"ui-tooltip-open\", !0) , e && \"mouseover\" === e.type && s.parents().each(function() {\n var e, s = t(this);\n s.data(\"ui-tooltip-open\") && (e = t.Event(\"blur\") , e.target = e.currentTarget = this , i.close(e, !0)) , s.attr(\"title\") && (s.uniqueId() , i.parents[this.id] = {\n element: this, \n title: s.attr(\"title\")} , s.attr(\"title\", \"\"));\n}) , this._registerCloseHandlers(e, s) , this._updateContent(s, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73985,
+ "nodeLength": 443,
+ "src": "!s.data(\"ui-tooltip-id\") && (s.attr(\"title\") && s.data(\"ui-tooltip-title\", s.attr(\"title\")) , s.data(\"ui-tooltip-open\", !0) , e && \"mouseover\" === e.type && s.parents().each(function() {\n var e, s = t(this);\n s.data(\"ui-tooltip-open\") && (e = t.Event(\"blur\") , e.target = e.currentTarget = this , i.close(e, !0)) , s.attr(\"title\") && (s.uniqueId() , i.parents[this.id] = {\n element: this, \n title: s.attr(\"title\")} , s.attr(\"title\", \"\"));\n}) , this._registerCloseHandlers(e, s) , this._updateContent(s, e))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74012,
+ "nodeLength": 59,
+ "src": "s.attr(\"title\") && s.data(\"ui-tooltip-title\", s.attr(\"title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74101,
+ "nodeLength": 268,
+ "src": "e && \"mouseover\" === e.type && s.parents().each(function() {\n var e, s = t(this);\n s.data(\"ui-tooltip-open\") && (e = t.Event(\"blur\") , e.target = e.currentTarget = this , i.close(e, !0)) , s.attr(\"title\") && (s.uniqueId() , i.parents[this.id] = {\n element: this, \n title: s.attr(\"title\")} , s.attr(\"title\", \"\"));\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74104,
+ "nodeLength": 265,
+ "src": "\"mouseover\" === e.type && s.parents().each(function() {\n var e, s = t(this);\n s.data(\"ui-tooltip-open\") && (e = t.Event(\"blur\") , e.target = e.currentTarget = this , i.close(e, !0)) , s.attr(\"title\") && (s.uniqueId() , i.parents[this.id] = {\n element: this, \n title: s.attr(\"title\")} , s.attr(\"title\", \"\"));\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74104,
+ "nodeLength": 20,
+ "src": "\"mouseover\" === e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74170,
+ "nodeLength": 90,
+ "src": "s.data(\"ui-tooltip-open\") && (e = t.Event(\"blur\") , e.target = e.currentTarget = this , i.close(e, !0))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74261,
+ "nodeLength": 106,
+ "src": "s.attr(\"title\") && (s.uniqueId() , i.parents[this.id] = {\n element: this, \n title: s.attr(\"title\")} , s.attr(\"title\", \"\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74497,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74517,
+ "nodeLength": 40,
+ "src": "\"string\" == typeof s || s.nodeType || s.jquery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74517,
+ "nodeLength": 18,
+ "src": "\"string\" == typeof s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74537,
+ "nodeLength": 20,
+ "src": "s.nodeType || s.jquery",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74623,
+ "nodeLength": 60,
+ "src": "t.data(\"ui-tooltip-open\") && (e && (e.type = o) , this._open(e, t, i))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74651,
+ "nodeLength": 13,
+ "src": "e && (e.type = o)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74688,
+ "nodeLength": 20,
+ "src": "i && this._open(e, t, i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74761,
+ "nodeLength": 30,
+ "src": "a.is(\":hidden\") || a.position(l)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74844,
+ "nodeLength": 1,
+ "src": "s",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74850,
+ "nodeLength": 15,
+ "src": "o = this._find(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74926,
+ "nodeLength": 83,
+ "src": "i.is(\"[title]\") && (e && \"mouseover\" === e.type ? i.attr(\"title\", \"\") : i.removeAttr(\"title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74944,
+ "nodeLength": 23,
+ "src": "e && \"mouseover\" === e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74947,
+ "nodeLength": 20,
+ "src": "\"mouseover\" === e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75338,
+ "nodeLength": 44,
+ "src": "this.options.track && e && /^mouse/.test(e.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75358,
+ "nodeLength": 24,
+ "src": "e && /^mouse/.test(e.type)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75520,
+ "nodeLength": 168,
+ "src": "this.options.track && this.options.show && this.options.show.delay && (r = this.delayedShow = setInterval(function() {\n a.is(\":visible\") && (n(l.of) , clearInterval(r));\n}, t.fx.interval))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75540,
+ "nodeLength": 148,
+ "src": "this.options.show && this.options.show.delay && (r = this.delayedShow = setInterval(function() {\n a.is(\":visible\") && (n(l.of) , clearInterval(r));\n}, t.fx.interval))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75559,
+ "nodeLength": 129,
+ "src": "this.options.show.delay && (r = this.delayedShow = setInterval(function() {\n a.is(\":visible\") && (n(l.of) , clearInterval(r));\n}, t.fx.interval))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75627,
+ "nodeLength": 44,
+ "src": "a.is(\":visible\") && (n(l.of) , clearInterval(r))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75792,
+ "nodeLength": 31,
+ "src": "e.keyCode === t.ui.keyCode.ESCAPE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75883,
+ "nodeLength": 89,
+ "src": "i[0] !== this.element[0] && (s.remove = function() {\n this._removeTooltip(this._find(i).tooltip);\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75883,
+ "nodeLength": 22,
+ "src": "i[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75973,
+ "nodeLength": 47,
+ "src": "e && \"mouseover\" !== e.type || (s.mouseleave = \"close\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75973,
+ "nodeLength": 23,
+ "src": "e && \"mouseover\" !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75976,
+ "nodeLength": 20,
+ "src": "\"mouseover\" !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76021,
+ "nodeLength": 43,
+ "src": "e && \"focusin\" !== e.type || (s.focusout = \"close\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76021,
+ "nodeLength": 21,
+ "src": "e && \"focusin\" !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76024,
+ "nodeLength": 18,
+ "src": "\"focusin\" !== e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76118,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76173,
+ "nodeLength": 1,
+ "src": "o",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76188,
+ "nodeLength": 601,
+ "src": "o.closing || (clearInterval(this.delayedShow) , n.data(\"ui-tooltip-title\") && !n.attr(\"title\") && n.attr(\"title\", n.data(\"ui-tooltip-title\")) , this._removeDescribedBy(n) , o.hiding = !0 , i.stop(!0) , this._hide(i, this.options.hide, function() {\n s._removeTooltip(t(this));\n}) , n.removeData(\"ui-tooltip-open\") , this._off(n, \"mouseleave focusout keyup\") , n[0] !== this.element[0] && this._off(n, \"remove\") , this._off(this.document, \"mousemove\") , e && \"mouseleave\" === e.type && t.each(this.parents, function(e, i) {\n t(i.element).attr(\"title\", i.title) , delete s.parents[e];\n}) , o.closing = !0 , this._trigger(\"close\", e, {\n tooltip: i}) , o.hiding || (o.closing = !1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76232,
+ "nodeLength": 88,
+ "src": "n.data(\"ui-tooltip-title\") && !n.attr(\"title\") && n.attr(\"title\", n.data(\"ui-tooltip-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76260,
+ "nodeLength": 60,
+ "src": "!n.attr(\"title\") && n.attr(\"title\", n.data(\"ui-tooltip-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76514,
+ "nodeLength": 45,
+ "src": "n[0] !== this.element[0] && this._off(n, \"remove\")",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76514,
+ "nodeLength": 22,
+ "src": "n[0] !== this.element[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76597,
+ "nodeLength": 116,
+ "src": "e && \"mouseleave\" === e.type && t.each(this.parents, function(e, i) {\n t(i.element).attr(\"title\", i.title) , delete s.parents[e];\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76600,
+ "nodeLength": 113,
+ "src": "\"mouseleave\" === e.type && t.each(this.parents, function(e, i) {\n t(i.element).attr(\"title\", i.title) , delete s.parents[e];\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76600,
+ "nodeLength": 21,
+ "src": "\"mouseleave\" === e.type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76764,
+ "nodeLength": 24,
+ "src": "o.hiding || (o.closing = !1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77184,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77349,
+ "nodeLength": 35,
+ "src": "e.length || (e = this.document[0].body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77550,
+ "nodeLength": 122,
+ "src": "o.data(\"ui-tooltip-title\") && (o.attr(\"title\") || o.attr(\"title\", o.data(\"ui-tooltip-title\")) , o.removeData(\"ui-tooltip-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77579,
+ "nodeLength": 59,
+ "src": "o.attr(\"title\") || o.attr(\"title\", o.data(\"ui-tooltip-title\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 77703,
+ "nodeLength": 223,
+ "src": "t.uiBackCompat !== !1 && t.widget(\"ui.tooltip\", t.ui.tooltip, {\n options: {\n tooltipClass: null}, \n _tooltip: function() {\n var t = this._superApply(arguments);\n return this.options.tooltipClass && t.tooltip.addClass(this.options.tooltipClass) , t;\n}})",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 77704,
+ "nodeLength": 19,
+ "src": "t.uiBackCompat !== !1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 145,
+ "nodeLength": 72,
+ "src": "this.options.tooltipClass && t.tooltip.addClass(this.options.tooltipClass)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/eventbooking-theme/jquery-ui.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "functionData": [
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "branchData": {
+ "30": [
+ null,
+ {
+ "position": 138,
+ "nodeLength": 10,
+ "src": "beforeShow",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "46": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 11,
+ "src": "afterUpdate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-da.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-de.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-et.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-en-GB.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-fi.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-fr.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "6": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-gl.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-nl.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-nb.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/external/jqueryui.1.12.1/js/i18n/datepicker-sv.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1
+ ],
+ "branchData": {
+ "4": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/vendor/js/bootstrap.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 2,
+ null,
+ 2,
+ 0,
+ 2,
+ 2,
+ 2,
+ null,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0
+ ],
+ "functionData": [
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "branchData": {
+ "7": [
+ null,
+ {
+ "position": 129,
+ "nodeLength": 29,
+ "src": "typeof jQuery === 'undefined'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "14": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 111,
+ "src": "(version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 77,
+ "nodeLength": 32,
+ "src": "version[0] < 2 && version[1] < 9",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 77,
+ "nodeLength": 14,
+ "src": "version[0] < 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 95,
+ "nodeLength": 14,
+ "src": "version[1] < 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 73,
+ "src": "(version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 52,
+ "src": "version[0] == 1 && version[1] == 9 && version[2] < 1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 15,
+ "src": "version[0] == 1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 134,
+ "nodeLength": 33,
+ "src": "version[1] == 9 && version[2] < 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 134,
+ "nodeLength": 15,
+ "src": "version[1] == 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 153,
+ "nodeLength": 14,
+ "src": "version[2] < 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173,
+ "nodeLength": 14,
+ "src": "version[0] > 3",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "45": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 28,
+ "src": "el.style[name] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "58": [
+ null,
+ {
+ "position": 146,
+ "nodeLength": 7,
+ "src": "!called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "66": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 21,
+ "src": "!$.support.transition",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "72": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 20,
+ "src": "$(e.target).is(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "107": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 9,
+ "src": "!selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "109": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 50,
+ "src": "selector && selector.replace(/.*(?=#[^\\s]*$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "112": [
+ null,
+ {
+ "position": 243,
+ "nodeLength": 16,
+ "src": "selector === '#'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "114": [
+ null,
+ {
+ "position": 286,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "116": [
+ null,
+ {
+ "position": 317,
+ "nodeLength": 15,
+ "src": "!$parent.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "122": [
+ null,
+ {
+ "position": 443,
+ "nodeLength": 22,
+ "src": "e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "131": [
+ null,
+ {
+ "position": 666,
+ "nodeLength": 48,
+ "src": "$.support.transition && $parent.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "147": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "148": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "204": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 15,
+ "src": "$el.is('input')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "209": [
+ null,
+ {
+ "position": 160,
+ "nodeLength": 22,
+ "src": "data.resetText == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "213": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 19,
+ "src": "data[state] == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "215": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 22,
+ "src": "state == 'loadingText'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "218": [
+ null,
+ {
+ "position": 207,
+ "nodeLength": 14,
+ "src": "this.isLoading",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "229": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 14,
+ "src": "$parent.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "231": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 30,
+ "src": "$input.prop('type') == 'radio'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "232": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 22,
+ "src": "$input.prop('checked')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "235": [
+ null,
+ {
+ "position": 257,
+ "nodeLength": 33,
+ "src": "$input.prop('type') == 'checkbox'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "236": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 60,
+ "src": "($input.prop('checked')) !== this.$element.hasClass('active')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "240": [
+ null,
+ {
+ "position": 509,
+ "nodeLength": 7,
+ "src": "changed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "255": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 93,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "257": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "259": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 18,
+ "src": "option == 'toggle'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "260": [
+ null,
+ {
+ "position": 268,
+ "nodeLength": 6,
+ "src": "option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "286": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 64,
+ "src": "!($(e.target).is('input[type=\"radio\"], input[type=\"checkbox\"]'))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "290": [
+ null,
+ {
+ "position": 197,
+ "nodeLength": 23,
+ "src": "$btn.is('input,button')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "325": [
+ null,
+ {
+ "position": 277,
+ "nodeLength": 93,
+ "src": "this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "327": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 221,
+ "src": "this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element.on('mouseenter.bs.carousel', $.proxy(this.pause, this)).on('mouseleave.bs.carousel', $.proxy(this.cycle, this))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 376,
+ "nodeLength": 29,
+ "src": "this.options.pause == 'hover'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 409,
+ "nodeLength": 188,
+ "src": "!('ontouchstart' in document.documentElement) && this.$element.on('mouseenter.bs.carousel', $.proxy(this.pause, this)).on('mouseleave.bs.carousel', $.proxy(this.cycle, this))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "344": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 40,
+ "src": "/input|textarea/i.test(e.target.tagName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "355": [
+ null,
+ {
+ "position": 5,
+ "nodeLength": 26,
+ "src": "e || (this.paused = false)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "357": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 45,
+ "src": "this.interval && clearInterval(this.interval)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "359": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 131,
+ "src": "this.options.interval && !this.paused && (this.interval = setInterval($.proxy(this.next, this), this.options.interval))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "360": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 100,
+ "src": "!this.paused && (this.interval = setInterval($.proxy(this.next, this), this.options.interval))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "368": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 20,
+ "src": "item || this.$active",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "373": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 125,
+ "src": "(direction == 'prev' && activeIndex === 0) || (direction == 'next' && activeIndex == (this.$items.length - 1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 40,
+ "src": "direction == 'prev' && activeIndex === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 19,
+ "src": "direction == 'prev'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92,
+ "nodeLength": 17,
+ "src": "activeIndex === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "374": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 62,
+ "src": "direction == 'next' && activeIndex == (this.$items.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61,
+ "nodeLength": 19,
+ "src": "direction == 'next'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84,
+ "nodeLength": 39,
+ "src": "activeIndex == (this.$items.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "375": [
+ null,
+ {
+ "position": 203,
+ "nodeLength": 30,
+ "src": "willWrap && !this.options.wrap",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "376": [
+ null,
+ {
+ "position": 265,
+ "nodeLength": 19,
+ "src": "direction == 'prev'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "385": [
+ null,
+ {
+ "position": 128,
+ "nodeLength": 41,
+ "src": "pos > (this.$items.length - 1) || pos < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128,
+ "nodeLength": 30,
+ "src": "pos > (this.$items.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 162,
+ "nodeLength": 7,
+ "src": "pos < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "387": [
+ null,
+ {
+ "position": 187,
+ "nodeLength": 12,
+ "src": "this.sliding",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "388": [
+ null,
+ {
+ "position": 305,
+ "nodeLength": 18,
+ "src": "activeIndex == pos",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "390": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 17,
+ "src": "pos > activeIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "394": [
+ null,
+ {
+ "position": 5,
+ "nodeLength": 25,
+ "src": "e || (this.paused = true)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "396": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 65,
+ "src": "this.$element.find('.next, .prev').length && $.support.transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "407": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 12,
+ "src": "this.sliding",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "412": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 12,
+ "src": "this.sliding",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "418": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 47,
+ "src": "next || this.getItemForDirection(type, $active)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "420": [
+ null,
+ {
+ "position": 178,
+ "nodeLength": 14,
+ "src": "type == 'next'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "423": [
+ null,
+ {
+ "position": 246,
+ "nodeLength": 24,
+ "src": "$next.hasClass('active')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "431": [
+ null,
+ {
+ "position": 504,
+ "nodeLength": 31,
+ "src": "slideEvent.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "435": [
+ null,
+ {
+ "position": 574,
+ "nodeLength": 25,
+ "src": "isCycling && this.pause()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "437": [
+ null,
+ {
+ "position": 609,
+ "nodeLength": 23,
+ "src": "this.$indicators.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "440": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 51,
+ "src": "$nextIndicator && $nextIndicator.addClass('active')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "444": [
+ null,
+ {
+ "position": 973,
+ "nodeLength": 55,
+ "src": "$.support.transition && this.$element.hasClass('slide')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "466": [
+ null,
+ {
+ "position": 1722,
+ "nodeLength": 25,
+ "src": "isCycling && this.cycle()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "479": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "480": [
+ null,
+ {
+ "position": 198,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "482": [
+ null,
+ {
+ "position": 260,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "483": [
+ null,
+ {
+ "position": 341,
+ "nodeLength": 25,
+ "src": "typeof option == 'number'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "484": [
+ null,
+ {
+ "position": 400,
+ "nodeLength": 6,
+ "src": "action",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "485": [
+ null,
+ {
+ "position": 439,
+ "nodeLength": 16,
+ "src": "options.interval",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "510": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 94,
+ "src": "$this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\\s]+$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 64,
+ "src": "(href = $this.attr('href')) && href.replace(/.*(?=#[^\\s]+$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "511": [
+ null,
+ {
+ "position": 181,
+ "nodeLength": 29,
+ "src": "!$target.hasClass('carousel')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "514": [
+ null,
+ {
+ "position": 337,
+ "nodeLength": 10,
+ "src": "slideIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "518": [
+ null,
+ {
+ "position": 418,
+ "nodeLength": 10,
+ "src": "slideIndex",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "561": [
+ null,
+ {
+ "position": 317,
+ "nodeLength": 19,
+ "src": "this.options.parent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "567": [
+ null,
+ {
+ "position": 472,
+ "nodeLength": 19,
+ "src": "this.options.toggle",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "580": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 8,
+ "src": "hasWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "584": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 50,
+ "src": "this.transitioning || this.$element.hasClass('in')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "587": [
+ null,
+ {
+ "position": 107,
+ "nodeLength": 76,
+ "src": "this.$parent && this.$parent.children('.panel').children('.in, .collapsing')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "589": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 25,
+ "src": "actives && actives.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "591": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 40,
+ "src": "activesData && activesData.transitioning",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "596": [
+ null,
+ {
+ "position": 431,
+ "nodeLength": 31,
+ "src": "startEvent.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "598": [
+ null,
+ {
+ "position": 480,
+ "nodeLength": 25,
+ "src": "actives && actives.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "600": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 48,
+ "src": "activesData || actives.data('bs.collapse', null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "625": [
+ null,
+ {
+ "position": 1125,
+ "nodeLength": 21,
+ "src": "!$.support.transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "635": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 51,
+ "src": "this.transitioning || !this.$element.hasClass('in')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "639": [
+ null,
+ {
+ "position": 165,
+ "nodeLength": 31,
+ "src": "startEvent.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "664": [
+ null,
+ {
+ "position": 750,
+ "nodeLength": 21,
+ "src": "!$.support.transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "673": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 28,
+ "src": "this.$element.hasClass('in')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "697": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 106,
+ "src": "$trigger.attr('data-target') || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\\s]+$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "698": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 67,
+ "src": "(href = $trigger.attr('href')) && href.replace(/.*(?=#[^\\s]+$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "711": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 56,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "713": [
+ null,
+ {
+ "position": 189,
+ "nodeLength": 51,
+ "src": "!data && options.toggle && /show|hide/.test(option)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 198,
+ "nodeLength": 42,
+ "src": "options.toggle && /show|hide/.test(option)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "714": [
+ null,
+ {
+ "position": 275,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "715": [
+ null,
+ {
+ "position": 356,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "740": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 26,
+ "src": "!$this.attr('data-target')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "744": [
+ null,
+ {
+ "position": 194,
+ "nodeLength": 4,
+ "src": "data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "777": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 9,
+ "src": "!selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "779": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 80,
+ "src": "selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\\s]*$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 66,
+ "nodeLength": 68,
+ "src": "/#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\\s]*$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "782": [
+ null,
+ {
+ "position": 244,
+ "nodeLength": 23,
+ "src": "selector && $(selector)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "784": [
+ null,
+ {
+ "position": 280,
+ "nodeLength": 25,
+ "src": "$parent && $parent.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "788": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 18,
+ "src": "e && e.which === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14,
+ "nodeLength": 13,
+ "src": "e.which === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "795": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 25,
+ "src": "!$parent.hasClass('open')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "797": [
+ null,
+ {
+ "position": 184,
+ "nodeLength": 102,
+ "src": "e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189,
+ "nodeLength": 97,
+ "src": "e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 189,
+ "nodeLength": 17,
+ "src": "e.type == 'click'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 210,
+ "nodeLength": 76,
+ "src": "/input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "801": [
+ null,
+ {
+ "position": 377,
+ "nodeLength": 22,
+ "src": "e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "811": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 32,
+ "src": "$this.is('.disabled, :disabled')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "818": [
+ null,
+ {
+ "position": 183,
+ "nodeLength": 9,
+ "src": "!isActive",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "819": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 84,
+ "src": "'ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "830": [
+ null,
+ {
+ "position": 463,
+ "nodeLength": 22,
+ "src": "e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "845": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 74,
+ "src": "!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "852": [
+ null,
+ {
+ "position": 174,
+ "nodeLength": 32,
+ "src": "$this.is('.disabled, :disabled')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "857": [
+ null,
+ {
+ "position": 305,
+ "nodeLength": 55,
+ "src": "!isActive && e.which != 27 || isActive && e.which == 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 305,
+ "nodeLength": 26,
+ "src": "!isActive && e.which != 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 318,
+ "nodeLength": 13,
+ "src": "e.which != 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 335,
+ "nodeLength": 25,
+ "src": "isActive && e.which == 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 347,
+ "nodeLength": 13,
+ "src": "e.which == 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "858": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 13,
+ "src": "e.which == 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "865": [
+ null,
+ {
+ "position": 580,
+ "nodeLength": 14,
+ "src": "!$items.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "869": [
+ null,
+ {
+ "position": 652,
+ "nodeLength": 26,
+ "src": "e.which == 38 && index > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 652,
+ "nodeLength": 13,
+ "src": "e.which == 38",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 669,
+ "nodeLength": 9,
+ "src": "index > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "870": [
+ null,
+ {
+ "position": 726,
+ "nodeLength": 42,
+ "src": "e.which == 40 && index < $items.length - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 726,
+ "nodeLength": 13,
+ "src": "e.which == 40",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 743,
+ "nodeLength": 25,
+ "src": "index < $items.length - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "871": [
+ null,
+ {
+ "position": 802,
+ "nodeLength": 7,
+ "src": "!~index",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "885": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "886": [
+ null,
+ {
+ "position": 154,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "943": [
+ null,
+ {
+ "position": 384,
+ "nodeLength": 19,
+ "src": "this.options.remote",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "964": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 12,
+ "src": "this.isShown",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "973": [
+ null,
+ {
+ "position": 135,
+ "nodeLength": 38,
+ "src": "this.isShown || e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "988": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 29,
+ "src": "$(e.target).is(that.$element)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "993": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 54,
+ "src": "$.support.transition && that.$element.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "995": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 30,
+ "src": "!that.$element.parent().length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1005": [
+ null,
+ {
+ "position": 306,
+ "nodeLength": 10,
+ "src": "transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1015": [
+ null,
+ {
+ "position": 527,
+ "nodeLength": 10,
+ "src": "transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1026": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1032": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 39,
+ "src": "!this.isShown || e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1048": [
+ null,
+ {
+ "position": 433,
+ "nodeLength": 54,
+ "src": "$.support.transition && this.$element.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1059": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 117,
+ "src": "document !== e.target && this.$element[0] !== e.target && !this.$element.has(e.target).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 21,
+ "src": "document !== e.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1060": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 80,
+ "src": "this.$element[0] !== e.target && !this.$element.has(e.target).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 29,
+ "src": "this.$element[0] !== e.target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1068": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 37,
+ "src": "this.isShown && this.options.keyboard",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1070": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 28,
+ "src": "e.which == 27 && this.hide()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 13,
+ "src": "e.which == 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1072": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 13,
+ "src": "!this.isShown",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1078": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 12,
+ "src": "this.isShown",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1097": [
+ null,
+ {
+ "position": 5,
+ "nodeLength": 41,
+ "src": "this.$backdrop && this.$backdrop.remove()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1103": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 30,
+ "src": "this.$element.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1105": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 37,
+ "src": "this.isShown && this.options.backdrop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1106": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 31,
+ "src": "$.support.transition && animate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1113": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 24,
+ "src": "this.ignoreBackdropClick",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1117": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 28,
+ "src": "e.target !== e.currentTarget",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1118": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 33,
+ "src": "this.options.backdrop == 'static'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1123": [
+ null,
+ {
+ "position": 551,
+ "nodeLength": 9,
+ "src": "doAnimate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1127": [
+ null,
+ {
+ "position": 656,
+ "nodeLength": 9,
+ "src": "!callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1129": [
+ null,
+ {
+ "position": 681,
+ "nodeLength": 9,
+ "src": "doAnimate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1135": [
+ null,
+ {
+ "position": 999,
+ "nodeLength": 31,
+ "src": "!this.isShown && this.$backdrop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1140": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 22,
+ "src": "callback && callback()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1142": [
+ null,
+ {
+ "position": 157,
+ "nodeLength": 54,
+ "src": "$.support.transition && this.$element.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1148": [
+ null,
+ {
+ "position": 1432,
+ "nodeLength": 8,
+ "src": "callback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1160": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 69,
+ "src": "this.$element[0].scrollHeight > document.documentElement.clientHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1163": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 45,
+ "src": "!this.bodyIsOverflowing && modalIsOverflowing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1164": [
+ null,
+ {
+ "position": 115,
+ "nodeLength": 45,
+ "src": "this.bodyIsOverflowing && !modalIsOverflowing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1177": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 16,
+ "src": "!fullWindowWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1181": [
+ null,
+ {
+ "position": 327,
+ "nodeLength": 43,
+ "src": "document.body.clientWidth < fullWindowWidth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1186": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 36,
+ "src": "this.$body.css('padding-right') || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1187": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 38,
+ "src": "document.body.style.paddingRight || ''",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1188": [
+ null,
+ {
+ "position": 146,
+ "nodeLength": 22,
+ "src": "this.bodyIsOverflowing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1212": [
+ null,
+ {
+ "position": 135,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1214": [
+ null,
+ {
+ "position": 183,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1215": [
+ null,
+ {
+ "position": 258,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1216": [
+ null,
+ {
+ "position": 330,
+ "nodeLength": 12,
+ "src": "options.show",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1241": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 73,
+ "src": "$this.attr('data-target') || (href && href.replace(/.*(?=#[^\\s]+$)/, ''))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 42,
+ "src": "href && href.replace(/.*(?=#[^\\s]+$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1242": [
+ null,
+ {
+ "position": 194,
+ "nodeLength": 24,
+ "src": "$target.data('bs.modal')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 251,
+ "nodeLength": 23,
+ "src": "!/#/.test(href) && href",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1244": [
+ null,
+ {
+ "position": 317,
+ "nodeLength": 13,
+ "src": "$this.is('a')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1247": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 30,
+ "src": "showEvent.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1249": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 46,
+ "src": "$this.is(':visible') && $this.trigger('focus')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1310": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 173,
+ "src": "this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport))",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 179,
+ "nodeLength": 35,
+ "src": "$.isFunction(this.options.viewport)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 268,
+ "nodeLength": 55,
+ "src": "this.options.viewport.selector || this.options.viewport",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1313": [
+ null,
+ {
+ "position": 401,
+ "nodeLength": 74,
+ "src": "this.$element[0] instanceof document.constructor && !this.options.selector",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1319": [
+ null,
+ {
+ "position": 700,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "1322": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 18,
+ "src": "trigger == 'click'",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "1324": [
+ null,
+ {
+ "position": 182,
+ "nodeLength": 19,
+ "src": "trigger != 'manual'",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "1325": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 18,
+ "src": "trigger == 'hover'",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "1326": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 18,
+ "src": "trigger == 'hover'",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "1333": [
+ null,
+ {
+ "position": 1276,
+ "nodeLength": 21,
+ "src": "this.options.selector",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1345": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 49,
+ "src": "options.delay && typeof options.delay == 'number'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 105,
+ "nodeLength": 32,
+ "src": "typeof options.delay == 'number'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1359": [
+ null,
+ {
+ "position": 66,
+ "nodeLength": 124,
+ "src": "this._options && $.each(this._options, function(key, value) {\n if (defaults[key] != value) \n options[key] = value;\n})",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1360": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 22,
+ "src": "defaults[key] != value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1367": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 31,
+ "src": "obj instanceof this.constructor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1370": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 5,
+ "src": "!self",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1375": [
+ null,
+ {
+ "position": 277,
+ "nodeLength": 22,
+ "src": "obj instanceof $.Event",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1376": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 21,
+ "src": "obj.type == 'focusin'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1379": [
+ null,
+ {
+ "position": 387,
+ "nodeLength": 52,
+ "src": "self.tip().hasClass('in') || self.hoverState == 'in'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 416,
+ "nodeLength": 23,
+ "src": "self.hoverState == 'in'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1388": [
+ null,
+ {
+ "position": 560,
+ "nodeLength": 47,
+ "src": "!self.options.delay || !self.options.delay.show",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1391": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 23,
+ "src": "self.hoverState == 'in'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1397": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 17,
+ "src": "this.inState[key]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1404": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 31,
+ "src": "obj instanceof this.constructor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1407": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 5,
+ "src": "!self",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1412": [
+ null,
+ {
+ "position": 277,
+ "nodeLength": 22,
+ "src": "obj instanceof $.Event",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1413": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 22,
+ "src": "obj.type == 'focusout'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1416": [
+ null,
+ {
+ "position": 389,
+ "nodeLength": 20,
+ "src": "self.isInStateTrue()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1422": [
+ null,
+ {
+ "position": 488,
+ "nodeLength": 47,
+ "src": "!self.options.delay || !self.options.delay.hide",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1425": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 24,
+ "src": "self.hoverState == 'out'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1432": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 33,
+ "src": "this.hasContent() && this.enabled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1436": [
+ null,
+ {
+ "position": 138,
+ "nodeLength": 32,
+ "src": "e.isDefaultPrevented() || !inDom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1447": [
+ null,
+ {
+ "position": 389,
+ "nodeLength": 22,
+ "src": "this.options.animation",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1449": [
+ null,
+ {
+ "position": 458,
+ "nodeLength": 43,
+ "src": "typeof this.options.placement == 'function'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1455": [
+ null,
+ {
+ "position": 702,
+ "nodeLength": 9,
+ "src": "autoPlace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 725,
+ "nodeLength": 41,
+ "src": "placement.replace(autoToken, '') || 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1463": [
+ null,
+ {
+ "position": 924,
+ "nodeLength": 22,
+ "src": "this.options.container",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1470": [
+ null,
+ {
+ "position": 1224,
+ "nodeLength": 9,
+ "src": "autoPlace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1474": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 71,
+ "src": "placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 118,
+ "nodeLength": 21,
+ "src": "placement == 'bottom'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 143,
+ "nodeLength": 46,
+ "src": "pos.bottom + actualHeight > viewportDim.bottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1475": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 68,
+ "src": "placement == 'top' && pos.top - actualHeight < viewportDim.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 104,
+ "nodeLength": 18,
+ "src": "placement == 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 129,
+ "nodeLength": 43,
+ "src": "pos.top - actualHeight < viewportDim.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1476": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 70,
+ "src": "placement == 'right' && pos.right + actualWidth > viewportDim.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 104,
+ "nodeLength": 20,
+ "src": "placement == 'right'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 129,
+ "nodeLength": 45,
+ "src": "pos.right + actualWidth > viewportDim.width",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1477": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 69,
+ "src": "placement == 'left' && pos.left - actualWidth < viewportDim.left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 104,
+ "nodeLength": 19,
+ "src": "placement == 'left'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 129,
+ "nodeLength": 44,
+ "src": "pos.left - actualWidth < viewportDim.left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1494": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 23,
+ "src": "prevHoverState == 'out'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1497": [
+ null,
+ {
+ "position": 2265,
+ "nodeLength": 50,
+ "src": "$.support.transition && this.$tip.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1515": [
+ null,
+ {
+ "position": 349,
+ "nodeLength": 16,
+ "src": "isNaN(marginTop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1516": [
+ null,
+ {
+ "position": 391,
+ "nodeLength": 17,
+ "src": "isNaN(marginLeft)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1538": [
+ null,
+ {
+ "position": 1006,
+ "nodeLength": 44,
+ "src": "placement == 'top' && actualHeight != height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1006,
+ "nodeLength": 18,
+ "src": "placement == 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1028,
+ "nodeLength": 22,
+ "src": "actualHeight != height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1544": [
+ null,
+ {
+ "position": 1216,
+ "nodeLength": 10,
+ "src": "delta.left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1548": [
+ null,
+ {
+ "position": 1377,
+ "nodeLength": 10,
+ "src": "isVertical",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1549": [
+ null,
+ {
+ "position": 1497,
+ "nodeLength": 10,
+ "src": "isVertical",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1557": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 10,
+ "src": "isVertical",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1558": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 10,
+ "src": "isVertical",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1565": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 17,
+ "src": "this.options.html",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1575": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 23,
+ "src": "that.hoverState != 'in'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1576": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 13,
+ "src": "that.$element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1581": [
+ null,
+ {
+ "position": 278,
+ "nodeLength": 22,
+ "src": "callback && callback()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1586": [
+ null,
+ {
+ "position": 468,
+ "nodeLength": 22,
+ "src": "e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1590": [
+ null,
+ {
+ "position": 532,
+ "nodeLength": 45,
+ "src": "$.support.transition && $tip.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1603": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 69,
+ "src": "$e.attr('title') || typeof $e.attr('data-original-title') != 'string'",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 56,
+ "nodeLength": 49,
+ "src": "typeof $e.attr('data-original-title') != 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1604": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 22,
+ "src": "$e.attr('title') || ''",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1613": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 25,
+ "src": "$element || this.$element",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1616": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 20,
+ "src": "el.tagName == 'BODY'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1619": [
+ null,
+ {
+ "position": 168,
+ "nodeLength": 20,
+ "src": "elRect.width == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1623": [
+ null,
+ {
+ "position": 448,
+ "nodeLength": 52,
+ "src": "window.SVGElement && el instanceof window.SVGElement",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1626": [
+ null,
+ {
+ "position": 663,
+ "nodeLength": 6,
+ "src": "isBody",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 695,
+ "nodeLength": 5,
+ "src": "isSvg",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1627": [
+ null,
+ {
+ "position": 759,
+ "nodeLength": 6,
+ "src": "isBody",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 768,
+ "nodeLength": 61,
+ "src": "document.documentElement.scrollTop || document.body.scrollTop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1628": [
+ null,
+ {
+ "position": 875,
+ "nodeLength": 6,
+ "src": "isBody",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1634": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 21,
+ "src": "placement == 'bottom'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1635": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 18,
+ "src": "placement == 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1636": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 19,
+ "src": "placement == 'left'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1643": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 15,
+ "src": "!this.$viewport",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1645": [
+ null,
+ {
+ "position": 102,
+ "nodeLength": 59,
+ "src": "this.options.viewport && this.options.viewport.padding || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 102,
+ "nodeLength": 54,
+ "src": "this.options.viewport && this.options.viewport.padding",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1648": [
+ null,
+ {
+ "position": 233,
+ "nodeLength": 28,
+ "src": "/right|left/.test(placement)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1651": [
+ null,
+ {
+ "position": 192,
+ "nodeLength": 38,
+ "src": "topEdgeOffset < viewportDimensions.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1653": [
+ null,
+ {
+ "position": 327,
+ "nodeLength": 69,
+ "src": "bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1659": [
+ null,
+ {
+ "position": 135,
+ "nodeLength": 40,
+ "src": "leftEdgeOffset < viewportDimensions.left",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1661": [
+ null,
+ {
+ "position": 276,
+ "nodeLength": 42,
+ "src": "rightEdgeOffset > viewportDimensions.right",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1674": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 103,
+ "src": "$e.attr('data-original-title') || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1675": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 28,
+ "src": "typeof o.title == 'function'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1682": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 31,
+ "src": "document.getElementById(prefix)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1687": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 10,
+ "src": "!this.$tip",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1689": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 21,
+ "src": "this.$tip.length != 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1697": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 48,
+ "src": "this.$arrow || this.tip().find('.tooltip-arrow')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1714": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1716": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 5,
+ "src": "!self",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1722": [
+ null,
+ {
+ "position": 269,
+ "nodeLength": 1,
+ "src": "e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1724": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 20,
+ "src": "self.isInStateTrue()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1727": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 25,
+ "src": "self.tip().hasClass('in')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1736": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 9,
+ "src": "that.$tip",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1754": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 94,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1756": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 36,
+ "src": "!data && /destroy|hide/.test(option)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1757": [
+ null,
+ {
+ "position": 196,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1758": [
+ null,
+ {
+ "position": 275,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1797": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 13,
+ "src": "!$.fn.tooltip",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1825": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 17,
+ "src": "this.options.html",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1827": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 17,
+ "src": "this.options.html",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 139,
+ "nodeLength": 26,
+ "src": "typeof content == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1834": [
+ null,
+ {
+ "position": 585,
+ "nodeLength": 35,
+ "src": "!$tip.find('.popover-title').html()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1838": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 36,
+ "src": "this.getTitle() || this.getContent()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1845": [
+ null,
+ {
+ "position": 66,
+ "nodeLength": 125,
+ "src": "$e.attr('data-content') || (typeof o.content == 'function' ? o.content.call($e[0]) : o.content)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1846": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 30,
+ "src": "typeof o.content == 'function'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1852": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 40,
+ "src": "this.$arrow || this.tip().find('.arrow')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1863": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1865": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 36,
+ "src": "!data && /destroy|hide/.test(option)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1866": [
+ null,
+ {
+ "position": 196,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1867": [
+ null,
+ {
+ "position": 275,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1904": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 28,
+ "src": "$(element).is(document.body)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1906": [
+ null,
+ {
+ "position": 219,
+ "nodeLength": 25,
+ "src": "this.options.target || ''",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1924": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 114,
+ "src": "this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1936": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 35,
+ "src": "!$.isWindow(this.$scrollElement[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1945": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 38,
+ "src": "$el.data('target') || $el.attr('href')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1946": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 27,
+ "src": "/^#./.test(href) && $(href)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1948": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 136,
+ "src": "($href && $href.length && $href.is(':visible') && [[$href[offsetMethod]().top + offsetBase, href]]) || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 153,
+ "nodeLength": 127,
+ "src": "$href && $href.length && $href.is(':visible') && [[$href[offsetMethod]().top + offsetBase, href]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1949": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 108,
+ "src": "$href.length && $href.is(':visible') && [[$href[offsetMethod]().top + offsetBase, href]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1950": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 82,
+ "src": "$href.is(':visible') && [[$href[offsetMethod]().top + offsetBase, href]]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1969": [
+ null,
+ {
+ "position": 345,
+ "nodeLength": 33,
+ "src": "this.scrollHeight != scrollHeight",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1973": [
+ null,
+ {
+ "position": 418,
+ "nodeLength": 22,
+ "src": "scrollTop >= maxScroll",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1974": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 69,
+ "src": "activeTarget != (i = targets[targets.length - 1]) && this.activate(i)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14,
+ "nodeLength": 49,
+ "src": "activeTarget != (i = targets[targets.length - 1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1977": [
+ null,
+ {
+ "position": 542,
+ "nodeLength": 38,
+ "src": "activeTarget && scrollTop < offsets[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 558,
+ "nodeLength": 22,
+ "src": "scrollTop < offsets[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1982": [
+ null,
+ {
+ "position": 677,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1983": [
+ null,
+ {
+ "position": 7,
+ "nodeLength": 170,
+ "src": "activeTarget != targets[i] && scrollTop >= offsets[i] && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) && this.activate(targets[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7,
+ "nodeLength": 26,
+ "src": "activeTarget != targets[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1984": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 132,
+ "src": "scrollTop >= offsets[i] && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) && this.activate(targets[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 23,
+ "src": "scrollTop >= offsets[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1985": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 96,
+ "src": "(offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) && this.activate(targets[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84,
+ "nodeLength": 58,
+ "src": "offsets[i + 1] === undefined || scrollTop < offsets[i + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 84,
+ "nodeLength": 28,
+ "src": "offsets[i + 1] === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 116,
+ "nodeLength": 26,
+ "src": "scrollTop < offsets[i + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2003": [
+ null,
+ {
+ "position": 262,
+ "nodeLength": 38,
+ "src": "active.parent('.dropdown-menu').length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2026": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 96,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2028": [
+ null,
+ {
+ "position": 143,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2029": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2090": [
+ null,
+ {
+ "position": 141,
+ "nodeLength": 9,
+ "src": "!selector",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2092": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 50,
+ "src": "selector && selector.replace(/.*(?=#[^\\s]*$)/, '')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2095": [
+ null,
+ {
+ "position": 290,
+ "nodeLength": 37,
+ "src": "$this.parent('li').hasClass('active')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2108": [
+ null,
+ {
+ "position": 624,
+ "nodeLength": 64,
+ "src": "showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2127": [
+ null,
+ {
+ "position": 71,
+ "nodeLength": 130,
+ "src": "callback && $.support.transition && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2128": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 112,
+ "src": "$.support.transition && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2129": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 80,
+ "src": "$active.length && $active.hasClass('fade') || !!container.find('> .fade').length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 42,
+ "src": "$active.length && $active.hasClass('fade')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2145": [
+ null,
+ {
+ "position": 345,
+ "nodeLength": 10,
+ "src": "transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2152": [
+ null,
+ {
+ "position": 516,
+ "nodeLength": 39,
+ "src": "element.parent('.dropdown-menu').length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2161": [
+ null,
+ {
+ "position": 753,
+ "nodeLength": 22,
+ "src": "callback && callback()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2164": [
+ null,
+ {
+ "position": 1011,
+ "nodeLength": 28,
+ "src": "$active.length && transition",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2182": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2183": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2260": [
+ null,
+ {
+ "position": 149,
+ "nodeLength": 42,
+ "src": "offsetTop != null && this.affixed == 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 149,
+ "nodeLength": 17,
+ "src": "offsetTop != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 170,
+ "nodeLength": 21,
+ "src": "this.affixed == 'top'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 200,
+ "nodeLength": 21,
+ "src": "scrollTop < offsetTop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2262": [
+ null,
+ {
+ "position": 247,
+ "nodeLength": 24,
+ "src": "this.affixed == 'bottom'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2263": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 17,
+ "src": "offsetTop != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38,
+ "nodeLength": 39,
+ "src": "(scrollTop + this.unpin <= position.top)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 38,
+ "nodeLength": 38,
+ "src": "scrollTop + this.unpin <= position.top",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2264": [
+ null,
+ {
+ "position": 111,
+ "nodeLength": 56,
+ "src": "(scrollTop + targetHeight <= scrollHeight - offsetBottom)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 111,
+ "nodeLength": 55,
+ "src": "scrollTop + targetHeight <= scrollHeight - offsetBottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2267": [
+ null,
+ {
+ "position": 493,
+ "nodeLength": 20,
+ "src": "this.affixed == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2268": [
+ null,
+ {
+ "position": 539,
+ "nodeLength": 12,
+ "src": "initializing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2269": [
+ null,
+ {
+ "position": 604,
+ "nodeLength": 12,
+ "src": "initializing",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2271": [
+ null,
+ {
+ "position": 650,
+ "nodeLength": 43,
+ "src": "offsetTop != null && scrollTop <= offsetTop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 650,
+ "nodeLength": 17,
+ "src": "offsetTop != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 671,
+ "nodeLength": 22,
+ "src": "scrollTop <= offsetTop",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2272": [
+ null,
+ {
+ "position": 716,
+ "nodeLength": 85,
+ "src": "offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 716,
+ "nodeLength": 20,
+ "src": "offsetBottom != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 741,
+ "nodeLength": 59,
+ "src": "colliderTop + colliderHeight >= scrollHeight - offsetBottom",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2278": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 17,
+ "src": "this.pinnedOffset",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2290": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 29,
+ "src": "!this.$element.is(':visible')",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2298": [
+ null,
+ {
+ "position": 298,
+ "nodeLength": 25,
+ "src": "typeof offset != 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2299": [
+ null,
+ {
+ "position": 375,
+ "nodeLength": 30,
+ "src": "typeof offsetTop == 'function'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2300": [
+ null,
+ {
+ "position": 459,
+ "nodeLength": 33,
+ "src": "typeof offsetBottom == 'function'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2304": [
+ null,
+ {
+ "position": 625,
+ "nodeLength": 21,
+ "src": "this.affixed != affix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2305": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 18,
+ "src": "this.unpin != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2307": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 5,
+ "src": "affix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2312": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 22,
+ "src": "e.isDefaultPrevented()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2315": [
+ null,
+ {
+ "position": 296,
+ "nodeLength": 17,
+ "src": "affix == 'bottom'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2323": [
+ null,
+ {
+ "position": 1164,
+ "nodeLength": 17,
+ "src": "affix == 'bottom'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2338": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 35,
+ "src": "typeof option == 'object' && option",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92,
+ "nodeLength": 25,
+ "src": "typeof option == 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2340": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 5,
+ "src": "!data",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2341": [
+ null,
+ {
+ "position": 214,
+ "nodeLength": 25,
+ "src": "typeof option == 'string'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2368": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 17,
+ "src": "data.offset || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2370": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 25,
+ "src": "data.offsetBottom != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2371": [
+ null,
+ {
+ "position": 181,
+ "nodeLength": 25,
+ "src": "data.offsetTop != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/js/onlinebooking.js": {
+ "lineData": [
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 5,
+ 5,
+ 5,
+ 15,
+ 15,
+ 3,
+ null,
+ null,
+ 5,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1
+ ],
+ "functionData": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 1,
+ 0,
+ 0,
+ 1,
+ 5,
+ 15,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1
+ ],
+ "branchData": {
+ "1": [
+ null,
+ {
+ "position": 19,
+ "nodeLength": 19,
+ "src": "DinnerBooking || {}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "23": [
+ null,
+ {
+ "position": 171,
+ "nodeLength": 41,
+ "src": "ui.value === undefined || ui.value <= min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 171,
+ "nodeLength": 22,
+ "src": "ui.value === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 197,
+ "nodeLength": 15,
+ "src": "ui.value <= min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "26": [
+ null,
+ {
+ "position": 278,
+ "nodeLength": 16,
+ "src": "ui.value === max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "33": [
+ null,
+ {
+ "position": 485,
+ "nodeLength": 15,
+ "src": "ui.value <= min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "38": [
+ null,
+ {
+ "position": 629,
+ "nodeLength": 15,
+ "src": "ui.value >= min",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "49": [
+ null,
+ {
+ "position": 101,
+ "nodeLength": 17,
+ "src": "now !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "128": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 40,
+ "src": "jqXHR.responseJSON.message !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "149": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 31,
+ "src": "availableTimeId === inputTimeId",
+ "evalFalse": 12,
+ "evalTrue": 3
+ }
+ ],
+ "189": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 32,
+ "src": "date && !isNaN(Date.parse(date))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "243": [
+ null,
+ {
+ "position": 272,
+ "nodeLength": 13,
+ "src": "i < daysCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "244": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 63,
+ "src": "DinnerBooking.Onlinebooking.Day.days[i].TimeDay.day === sqlDate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "245": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 73,
+ "src": "DinnerBooking.Onlinebooking.Day.days[i].Booking.availability_status === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "247": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 13,
+ "src": "date >= today",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "251": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 73,
+ "src": "DinnerBooking.Onlinebooking.Day.days[i].Booking.availability_status === 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "255": [
+ null,
+ {
+ "position": 400,
+ "nodeLength": 67,
+ "src": "DinnerBooking.Onlinebooking.Day.days[i].TimeNote.note !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/vendor/js/qunit/qunit.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ null,
+ null,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 22,
+ 32,
+ 32,
+ 4,
+ 28,
+ 28,
+ null,
+ null,
+ null,
+ null,
+ 22,
+ null,
+ null,
+ 1,
+ 77,
+ 25,
+ null,
+ null,
+ null,
+ 52,
+ 0,
+ null,
+ null,
+ 52,
+ null,
+ null,
+ 52,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 20,
+ 20,
+ 20,
+ 20,
+ 20,
+ 52,
+ null,
+ 52,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 7,
+ 8,
+ 0,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ 1,
+ 7,
+ null,
+ null,
+ 7,
+ 7,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ 1,
+ 21,
+ null,
+ 21,
+ 21,
+ 25,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 5,
+ null,
+ 5,
+ null,
+ 5,
+ 5,
+ 5,
+ 0,
+ null,
+ 5,
+ 4,
+ 4,
+ 8,
+ 0,
+ null,
+ 8,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 5,
+ null,
+ null,
+ null,
+ 5,
+ 5,
+ 5,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ 4,
+ 6,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ 4,
+ 1,
+ 1,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 1,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ 0,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 16,
+ null,
+ 16,
+ 16,
+ 0,
+ null,
+ 16,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 16,
+ 16,
+ null,
+ 16,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 0,
+ 4,
+ 0,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ null,
+ 4,
+ 9,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ 4,
+ 0,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 9,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ 0,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 272,
+ 272,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ 6,
+ 0,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ 1,
+ 44,
+ null,
+ null,
+ 44,
+ 20,
+ 36,
+ null,
+ 20,
+ null,
+ null,
+ 24,
+ 0,
+ 24,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 24,
+ null,
+ null,
+ 24,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 5,
+ null,
+ 5,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 1,
+ 4,
+ 4,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ null,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ null,
+ 2,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 2,
+ null,
+ 2,
+ 2,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 2,
+ 2,
+ null,
+ 2,
+ 2,
+ null,
+ null,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 24,
+ 24,
+ null,
+ null,
+ 16,
+ null,
+ 24,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ 0,
+ null,
+ null,
+ 8,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 3,
+ 3,
+ 3,
+ null,
+ null,
+ 3,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 38,
+ 0,
+ null,
+ 38,
+ null,
+ null,
+ 38,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 22,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 2,
+ 2,
+ 3,
+ null,
+ null,
+ null,
+ 1,
+ 15,
+ null,
+ null,
+ 1,
+ 15,
+ 14,
+ null,
+ null,
+ null,
+ 1,
+ 3,
+ 1,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ null,
+ null,
+ 2,
+ 0,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ 1,
+ 42,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 3,
+ 3,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 3,
+ 3,
+ null,
+ 3,
+ 3,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ 5,
+ null,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ 1,
+ 2,
+ 2,
+ 2,
+ 0,
+ 0,
+ 0,
+ null,
+ 2,
+ 2,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 2,
+ null,
+ 2,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 2,
+ 2,
+ 2,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ null,
+ 8,
+ 8,
+ null,
+ null,
+ 8,
+ null,
+ 8,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ null,
+ 9,
+ 9,
+ 9,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 9,
+ 0,
+ null,
+ null,
+ 9,
+ null,
+ 9,
+ 9,
+ 9,
+ 9,
+ null,
+ null,
+ 1,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ null,
+ 4,
+ null,
+ null,
+ 4,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ null,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 4,
+ 0,
+ null,
+ null,
+ 4,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ null,
+ null,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ null,
+ 4,
+ 0,
+ null,
+ 4,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 0,
+ 0,
+ 4,
+ 1,
+ 1,
+ 1,
+ 4,
+ 0,
+ 8,
+ 0,
+ 0,
+ 22,
+ 77,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 7,
+ 8,
+ 21,
+ 5,
+ 5,
+ 4,
+ 0,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 0,
+ 0,
+ 0,
+ 16,
+ 16,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 9,
+ 0,
+ 4,
+ 0,
+ 0,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 6,
+ 44,
+ 0,
+ 0,
+ 5,
+ 4,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4,
+ 4,
+ 1,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 9,
+ 0,
+ 0,
+ 9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 2,
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 1,
+ 8,
+ 0,
+ 1,
+ 1,
+ 4,
+ 1,
+ 1,
+ 1,
+ 0,
+ 38,
+ 0,
+ 1,
+ 22,
+ 0,
+ 2,
+ 15,
+ 15,
+ 3,
+ 2,
+ 42,
+ 0,
+ 0,
+ 1,
+ 0,
+ 5,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 4,
+ 1,
+ 1,
+ 1,
+ 8,
+ 4,
+ 0,
+ 9,
+ 4,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1
+ ],
+ "branchData": {
+ "14": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 21,
+ "src": "'default' in global$1",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "21": [
+ null,
+ {
+ "position": 256,
+ "nodeLength": 25,
+ "src": "window && window.document",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "22": [
+ null,
+ {
+ "position": 301,
+ "nodeLength": 26,
+ "src": "window && window.navigator",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "23": [
+ null,
+ {
+ "position": 352,
+ "nodeLength": 31,
+ "src": "window && window.sessionStorage",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "25": [
+ null,
+ {
+ "position": 402,
+ "nodeLength": 67,
+ "src": "typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 402,
+ "nodeLength": 28,
+ "src": "typeof Symbol === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 434,
+ "nodeLength": 35,
+ "src": "typeof Symbol.iterator === \"symbol\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "28": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 93,
+ "src": "obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19,
+ "nodeLength": 86,
+ "src": "typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 19,
+ "nodeLength": 28,
+ "src": "typeof Symbol === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51,
+ "nodeLength": 54,
+ "src": "obj.constructor === Symbol && obj !== Symbol.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51,
+ "nodeLength": 26,
+ "src": "obj.constructor === Symbol",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 81,
+ "nodeLength": 24,
+ "src": "obj !== Symbol.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "42": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 34,
+ "src": "!(instance instanceof Constructor)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "49": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 16,
+ "src": "i < props.length",
+ "evalFalse": 1,
+ "evalTrue": 15
+ }
+ ],
+ "51": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 30,
+ "src": "descriptor.enumerable || false",
+ "evalFalse": 15,
+ "evalTrue": 0
+ }
+ ],
+ "53": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 21,
+ "src": "\"value\" in descriptor",
+ "evalFalse": 0,
+ "evalTrue": 15
+ }
+ ],
+ "59": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 10,
+ "src": "protoProps",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "60": [
+ null,
+ {
+ "position": 86,
+ "nodeLength": 11,
+ "src": "staticProps",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "106": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 18,
+ "src": "Array.isArray(arr)",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "107": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 14,
+ "src": "i < arr.length",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "117": [
+ null,
+ {
+ "position": 1896,
+ "nodeLength": 61,
+ "src": "Date.now || function() {\n return new Date().getTime();\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "122": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 39,
+ "src": "window && window.document !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 24,
+ "nodeLength": 29,
+ "src": "window.document !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "123": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 24,
+ "src": "setTimeout !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "132": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 17,
+ "src": "i < result.length",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "133": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 12,
+ "src": "j < b.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "134": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 18,
+ "src": "result[i] === b[j]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "146": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 13,
+ "src": "array.indexOf",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "150": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "151": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 17,
+ "src": "array[i] === elem",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "169": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 16,
+ "src": "is(\"array\", obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "171": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 21,
+ "src": "hasOwn.call(obj, key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "173": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 19,
+ "src": "val === Object(val)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "181": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 20,
+ "src": "hasOwn.call(b, prop)",
+ "evalFalse": 0,
+ "evalTrue": 32
+ }
+ ],
+ "182": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 21,
+ "src": "b[prop] === undefined",
+ "evalFalse": 28,
+ "evalTrue": 4
+ }
+ ],
+ "184": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 46,
+ "src": "!(undefOnly && typeof a[prop] !== \"undefined\")",
+ "evalFalse": 0,
+ "evalTrue": 28
+ },
+ {
+ "position": 76,
+ "nodeLength": 43,
+ "src": "undefOnly && typeof a[prop] !== \"undefined\"",
+ "evalFalse": 28,
+ "evalTrue": 0
+ },
+ {
+ "position": 89,
+ "nodeLength": 30,
+ "src": "typeof a[prop] !== \"undefined\"",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "194": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 26,
+ "src": "typeof obj === \"undefined\"",
+ "evalFalse": 52,
+ "evalTrue": 25
+ }
+ ],
+ "199": [
+ null,
+ {
+ "position": 114,
+ "nodeLength": 12,
+ "src": "obj === null",
+ "evalFalse": 52,
+ "evalTrue": 0
+ }
+ ],
+ "204": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 17,
+ "src": "match && match[1]",
+ "evalFalse": 0,
+ "evalTrue": 52
+ }
+ ],
+ "208": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 10,
+ "src": "isNaN(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "224": [
+ null,
+ {
+ "position": 573,
+ "nodeLength": 69,
+ "src": "(typeof obj === \"undefined\" ? \"undefined\" : _typeof(obj)) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 573,
+ "nodeLength": 26,
+ "src": "typeof obj === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "231": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 24,
+ "src": "objectType(obj) === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "245": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 72,
+ "src": "Object.getPrototypeOf || function(obj) {\n return obj.__proto__;\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "254": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 65,
+ "src": "(typeof a === \"undefined\" ? \"undefined\" : _typeof(a)) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 140,
+ "nodeLength": 24,
+ "src": "typeof a === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "257": [
+ null,
+ {
+ "position": 246,
+ "nodeLength": 65,
+ "src": "(typeof b === \"undefined\" ? \"undefined\" : _typeof(b)) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 24,
+ "src": "typeof b === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "261": [
+ null,
+ {
+ "position": 355,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "269": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 31,
+ "src": "a.constructor === b.constructor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "276": [
+ null,
+ {
+ "position": 322,
+ "nodeLength": 37,
+ "src": "protoA && protoA.constructor === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 332,
+ "nodeLength": 27,
+ "src": "protoA.constructor === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "279": [
+ null,
+ {
+ "position": 397,
+ "nodeLength": 37,
+ "src": "protoB && protoB.constructor === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 407,
+ "nodeLength": 27,
+ "src": "protoB.constructor === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "285": [
+ null,
+ {
+ "position": 581,
+ "nodeLength": 96,
+ "src": "protoA === null && protoB === Object.prototype || protoB === null && protoA === Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 581,
+ "nodeLength": 46,
+ "src": "protoA === null && protoB === Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 581,
+ "nodeLength": 15,
+ "src": "protoA === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 600,
+ "nodeLength": 27,
+ "src": "protoB === Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 631,
+ "nodeLength": 46,
+ "src": "protoB === null && protoA === Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 631,
+ "nodeLength": 15,
+ "src": "protoB === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 650,
+ "nodeLength": 27,
+ "src": "protoA === Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "293": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 17,
+ "src": "\"flags\" in regexp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "310": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 110,
+ "src": "a.source === b.source && getRegExpFlags(a) === getRegExpFlags(b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 21,
+ "src": "a.source === b.source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "313": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 39,
+ "src": "getRegExpFlags(a) === getRegExpFlags(b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "322": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 83,
+ "src": "caller !== Object && typeof caller !== \"undefined\" && a.toString() === b.toString()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 61,
+ "nodeLength": 17,
+ "src": "caller !== Object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82,
+ "nodeLength": 62,
+ "src": "typeof caller !== \"undefined\" && a.toString() === b.toString()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 82,
+ "nodeLength": 29,
+ "src": "typeof caller !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 29,
+ "src": "a.toString() === b.toString()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "329": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 16,
+ "src": "len !== b.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "338": [
+ null,
+ {
+ "position": 269,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "340": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 18,
+ "src": "j < parents.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "341": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 19,
+ "src": "parents[j] === a[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "342": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 20,
+ "src": "parentsB[j] === b[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "343": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 22,
+ "src": "aCircular || bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "344": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 39,
+ "src": "a[i] === b[i] || aCircular && bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 13,
+ "src": "a[i] === b[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 22,
+ "src": "aCircular && bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "353": [
+ null,
+ {
+ "position": 387,
+ "nodeLength": 32,
+ "src": "!loop && !innerEquiv(a[i], b[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "368": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 17,
+ "src": "a.size !== b.size",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "376": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 22,
+ "src": "innerEquiv(bVal, aVal)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "381": [
+ null,
+ {
+ "position": 150,
+ "nodeLength": 8,
+ "src": "!innerEq",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "393": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 17,
+ "src": "a.size !== b.size",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "401": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 38,
+ "src": "innerEquiv([bVal, bKey], [aVal, aKey])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "406": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 8,
+ "src": "!innerEq",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "422": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 35,
+ "src": "compareConstructors(a, b) === false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "436": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 18,
+ "src": "j < parents.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "437": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 19,
+ "src": "parents[j] === a[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "438": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 20,
+ "src": "parentsB[j] === b[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "439": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 22,
+ "src": "aCircular || bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "440": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 39,
+ "src": "a[i] === b[i] || aCircular && bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 13,
+ "src": "a[i] === b[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 22,
+ "src": "aCircular && bCircular",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "449": [
+ null,
+ {
+ "position": 379,
+ "nodeLength": 32,
+ "src": "!loop && !innerEquiv(a[i], b[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "468": [
+ null,
+ {
+ "position": 1185,
+ "nodeLength": 56,
+ "src": "eq && innerEquiv(aProperties.sort(), bProperties.sort())",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "474": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 47,
+ "src": "objectType(b) === type && callbacks[type](b, a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 22,
+ "src": "objectType(b) === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "481": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 20,
+ "src": "arguments.length < 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "486": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 164,
+ "src": "(a === b || typeEquiv(a, b)) && (arguments.length === 2 || innerEquiv.apply(this, [].slice.call(arguments, 1)))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 164,
+ "nodeLength": 26,
+ "src": "a === b || typeEquiv(a, b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 164,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "489": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 77,
+ "src": "arguments.length === 2 || innerEquiv.apply(this, [].slice.call(arguments, 1))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 86,
+ "nodeLength": 22,
+ "src": "arguments.length === 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "552": [
+ null,
+ {
+ "position": 11675,
+ "nodeLength": 45,
+ "src": "window && window.QUnit && window.QUnit.config",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 11685,
+ "nodeLength": 35,
+ "src": "window.QUnit && window.QUnit.config",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "555": [
+ null,
+ {
+ "position": 11794,
+ "nodeLength": 47,
+ "src": "window && window.QUnit && !window.QUnit.version",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 11804,
+ "nodeLength": 37,
+ "src": "window.QUnit && !window.QUnit.version",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "575": [
+ null,
+ {
+ "position": 101,
+ "nodeLength": 8,
+ "src": "arr.join",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "578": [
+ null,
+ {
+ "position": 165,
+ "nodeLength": 4,
+ "src": "!arr",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "587": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 43,
+ "src": "dump.maxDepth && dump.depth > dump.maxDepth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 79,
+ "nodeLength": 26,
+ "src": "dump.depth > dump.maxDepth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "592": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "603": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 218,
+ "src": "toString.call(obj) === \"[object Array]\" || typeof obj.length === \"number\" && obj.item !== undefined && (obj.length ? obj.item(0) === obj[0] : obj.item(0) === null && obj[0] === undefined)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 39,
+ "src": "toString.call(obj) === \"[object Array]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "606": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 144,
+ "src": "typeof obj.length === \"number\" && obj.item !== undefined && (obj.length ? obj.item(0) === obj[0] : obj.item(0) === null && obj[0] === undefined)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 111,
+ "nodeLength": 30,
+ "src": "typeof obj.length === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 110,
+ "src": "obj.item !== undefined && (obj.length ? obj.item(0) === obj[0] : obj.item(0) === null && obj[0] === undefined)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 146,
+ "nodeLength": 22,
+ "src": "obj.item !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26,
+ "nodeLength": 10,
+ "src": "obj.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39,
+ "nodeLength": 22,
+ "src": "obj.item(0) === obj[0]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64,
+ "nodeLength": 44,
+ "src": "obj.item(0) === null && obj[0] === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64,
+ "nodeLength": 20,
+ "src": "obj.item(0) === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88,
+ "nodeLength": 20,
+ "src": "obj[0] === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "615": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 11,
+ "src": "stack || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "621": [
+ null,
+ {
+ "position": 129,
+ "nodeLength": 14,
+ "src": "inStack !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "625": [
+ null,
+ {
+ "position": 230,
+ "nodeLength": 27,
+ "src": "objType || this.typeOf(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "627": [
+ null,
+ {
+ "position": 314,
+ "nodeLength": 29,
+ "src": "typeof parser === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "629": [
+ null,
+ {
+ "position": 387,
+ "nodeLength": 25,
+ "src": "parserType === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "635": [
+ null,
+ {
+ "position": 538,
+ "nodeLength": 23,
+ "src": "parserType === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "640": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 12,
+ "src": "obj === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "642": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 26,
+ "src": "typeof obj === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "644": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 17,
+ "src": "is(\"regexp\", obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "646": [
+ null,
+ {
+ "position": 214,
+ "nodeLength": 15,
+ "src": "is(\"date\", obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "648": [
+ null,
+ {
+ "position": 271,
+ "nodeLength": 19,
+ "src": "is(\"function\", obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "650": [
+ null,
+ {
+ "position": 336,
+ "nodeLength": 89,
+ "src": "obj.setInterval !== undefined && obj.document !== undefined && obj.nodeType === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 336,
+ "nodeLength": 29,
+ "src": "obj.setInterval !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 369,
+ "nodeLength": 56,
+ "src": "obj.document !== undefined && obj.nodeType === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 369,
+ "nodeLength": 26,
+ "src": "obj.document !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 399,
+ "nodeLength": 26,
+ "src": "obj.nodeType === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "652": [
+ null,
+ {
+ "position": 469,
+ "nodeLength": 18,
+ "src": "obj.nodeType === 9",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "654": [
+ null,
+ {
+ "position": 533,
+ "nodeLength": 12,
+ "src": "obj.nodeType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "656": [
+ null,
+ {
+ "position": 587,
+ "nodeLength": 12,
+ "src": "isArray(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "658": [
+ null,
+ {
+ "position": 642,
+ "nodeLength": 47,
+ "src": "obj.constructor === Error.prototype.constructor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "661": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 26,
+ "src": "typeof obj === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "667": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 14,
+ "src": "this.multiline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "668": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 9,
+ "src": "this.HTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "670": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 9,
+ "src": "this.HTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "676": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 15,
+ "src": "!this.multiline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "680": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 9,
+ "src": "this.HTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "683": [
+ null,
+ {
+ "position": 213,
+ "nodeLength": 10,
+ "src": "extra || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "686": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 6,
+ "src": "a || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "689": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 6,
+ "src": "a || 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "717": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 12,
+ "src": "\"name\" in fn",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 21,
+ "src": "reName.exec(fn) || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "719": [
+ null,
+ {
+ "position": 149,
+ "nodeLength": 4,
+ "src": "name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "738": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 43,
+ "src": "dump.maxDepth && dump.depth > dump.maxDepth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 143,
+ "nodeLength": 26,
+ "src": "dump.depth > dump.maxDepth",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "752": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 36,
+ "src": "key in map && inArray(key, keys) < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67,
+ "nodeLength": 22,
+ "src": "inArray(key, keys) < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "757": [
+ null,
+ {
+ "position": 641,
+ "nodeLength": 15,
+ "src": "i < keys.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "769": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 9,
+ "src": "dump.HTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "770": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 9,
+ "src": "dump.HTML",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "775": [
+ null,
+ {
+ "position": 252,
+ "nodeLength": 5,
+ "src": "attrs",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "776": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "782": [
+ null,
+ {
+ "position": 222,
+ "nodeLength": 24,
+ "src": "val && val !== \"inherit\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 229,
+ "nodeLength": 17,
+ "src": "val !== \"inherit\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "790": [
+ null,
+ {
+ "position": 751,
+ "nodeLength": 44,
+ "src": "_node.nodeType === 3 || _node.nodeType === 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 751,
+ "nodeLength": 20,
+ "src": "_node.nodeType === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 775,
+ "nodeLength": 20,
+ "src": "_node.nodeType === 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "802": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 2,
+ "src": "!l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "807": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 3,
+ "src": "l--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "855": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 35,
+ "src": "objectType(callback) !== \"function\"",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "865": [
+ null,
+ {
+ "position": 528,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 1,
+ "evalTrue": 7
+ }
+ ],
+ "869": [
+ null,
+ {
+ "position": 91,
+ "nodeLength": 49,
+ "src": "objectType(config.callbacks[key]) === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 7
+ }
+ ],
+ "881": [
+ null,
+ {
+ "position": 101,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 21,
+ "evalTrue": 25
+ }
+ ],
+ "888": [
+ null,
+ {
+ "position": 20686,
+ "nodeLength": 29,
+ "src": "sourceFromStacktrace(0) || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "891": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 20,
+ "src": "offset === undefined",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "895": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 12,
+ "src": "e && e.stack",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "897": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 25,
+ "src": "/^error$/i.test(stack[0])",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "900": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 8,
+ "src": "fileName",
+ "evalFalse": 1,
+ "evalTrue": 4
+ }
+ ],
+ "902": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 16,
+ "src": "i < stack.length",
+ "evalFalse": 4,
+ "evalTrue": 8
+ }
+ ],
+ "903": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 33,
+ "src": "stack[i].indexOf(fileName) !== -1",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "908": [
+ null,
+ {
+ "position": 182,
+ "nodeLength": 14,
+ "src": "include.length",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "921": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 12,
+ "src": "!error.stack",
+ "evalFalse": 0,
+ "evalTrue": 5
+ }
+ ],
+ "950": [
+ null,
+ {
+ "position": 309,
+ "nodeLength": 12,
+ "src": "i < l.length",
+ "evalFalse": 4,
+ "evalTrue": 6
+ }
+ ],
+ "951": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 43,
+ "src": "this.module.tests[i].name === this.testName",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "963": [
+ null,
+ {
+ "position": 581,
+ "nodeLength": 13,
+ "src": "settings.skip",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "980": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 31,
+ "src": "module && module.testsRun === 0",
+ "evalFalse": 4,
+ "evalTrue": 1
+ },
+ {
+ "position": 72,
+ "nodeLength": 21,
+ "src": "module.testsRun === 0",
+ "evalFalse": 3,
+ "evalTrue": 1
+ }
+ ],
+ "995": [
+ null,
+ {
+ "position": 165,
+ "nodeLength": 6,
+ "src": "i >= 0",
+ "evalFalse": 4,
+ "evalTrue": 1
+ }
+ ],
+ "1006": [
+ null,
+ {
+ "position": 437,
+ "nodeLength": 22,
+ "src": "module.testEnvironment",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "1022": [
+ null,
+ {
+ "position": 914,
+ "nodeLength": 17,
+ "src": "!config.pollution",
+ "evalFalse": 3,
+ "evalTrue": 1
+ }
+ ],
+ "1034": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 17,
+ "src": "config.notrycatch",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1042": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 14,
+ "src": "e.message || e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1048": [
+ null,
+ {
+ "position": 272,
+ "nodeLength": 15,
+ "src": "config.blocking",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1067": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 21,
+ "src": "hookName === \"before\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1068": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 24,
+ "src": "hookOwner.testsRun !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1075": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 75,
+ "src": "hookName === \"after\" && hookOwner.testsRun !== numberOfTests(hookOwner) - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 153,
+ "nodeLength": 20,
+ "src": "hookName === \"after\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 177,
+ "nodeLength": 51,
+ "src": "hookOwner.testsRun !== numberOfTests(hookOwner) - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1080": [
+ null,
+ {
+ "position": 291,
+ "nodeLength": 17,
+ "src": "config.notrycatch",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1087": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 22,
+ "src": "error.message || error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1102": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 19,
+ "src": "module.parentModule",
+ "evalFalse": 16,
+ "evalTrue": 0
+ }
+ ],
+ "1105": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 84,
+ "src": "module.testEnvironment && objectType(module.testEnvironment[handler]) === \"function\"",
+ "evalFalse": 16,
+ "evalTrue": 0
+ },
+ {
+ "position": 122,
+ "nodeLength": 58,
+ "src": "objectType(module.testEnvironment[handler]) === \"function\"",
+ "evalFalse": 16,
+ "evalTrue": 0
+ }
+ ],
+ "1111": [
+ null,
+ {
+ "position": 395,
+ "nodeLength": 10,
+ "src": "!this.skip",
+ "evalFalse": 0,
+ "evalTrue": 16
+ }
+ ],
+ "1119": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 47,
+ "src": "config.requireExpects && this.expected === null",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 61,
+ "nodeLength": 22,
+ "src": "this.expected === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1121": [
+ null,
+ {
+ "position": 220,
+ "nodeLength": 66,
+ "src": "this.expected !== null && this.expected !== this.assertions.length",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 220,
+ "nodeLength": 22,
+ "src": "this.expected !== null",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 40,
+ "src": "this.expected !== this.assertions.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1123": [
+ null,
+ {
+ "position": 431,
+ "nodeLength": 49,
+ "src": "this.expected === null && !this.assertions.length",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 431,
+ "nodeLength": 22,
+ "src": "this.expected === null",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "1140": [
+ null,
+ {
+ "position": 974,
+ "nodeLength": 26,
+ "src": "i < this.assertions.length",
+ "evalFalse": 4,
+ "evalTrue": 9
+ }
+ ],
+ "1141": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 26,
+ "src": "!this.assertions[i].result",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "1151": [
+ null,
+ {
+ "position": 1198,
+ "nodeLength": 7,
+ "src": "storage",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "1152": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 3,
+ "src": "bad",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1166": [
+ null,
+ {
+ "position": 181,
+ "nodeLength": 7,
+ "src": "skipped",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1176": [
+ null,
+ {
+ "position": 1795,
+ "nodeLength": 41,
+ "src": "module.testsRun === numberOfTests(module)",
+ "evalFalse": 3,
+ "evalTrue": 1
+ }
+ ],
+ "1191": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 24,
+ "src": "this.preserveEnvironment",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1202": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 13,
+ "src": "!this.valid()",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1222": [
+ null,
+ {
+ "position": 563,
+ "nodeLength": 97,
+ "src": "config.storage && +config.storage.getItem(\"qunit-test-\" + this.module.name + \"-\" + this.testName)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1225": [
+ null,
+ {
+ "position": 743,
+ "nodeLength": 35,
+ "src": "config.reorder && previousFailCount",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1244": [
+ null,
+ {
+ "position": 233,
+ "nodeLength": 28,
+ "src": "resultInfo.negative || false",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "1248": [
+ null,
+ {
+ "position": 434,
+ "nodeLength": 18,
+ "src": "!resultInfo.result",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "1251": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 6,
+ "src": "source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1265": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 23,
+ "src": "!(this instanceof Test)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1273": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 18,
+ "src": "message || \"error\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1274": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 14,
+ "src": "actual || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1279": [
+ null,
+ {
+ "position": 382,
+ "nodeLength": 6,
+ "src": "source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1296": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 15,
+ "src": "promise != null",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1298": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 31,
+ "src": "objectType(then) === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1303": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 6,
+ "src": "!phase",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125,
+ "nodeLength": 31,
+ "src": "error && error.message || error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125,
+ "nodeLength": 22,
+ "src": "error && error.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1319": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 44,
+ "src": "config.module && config.module.toLowerCase()",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1323": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 15,
+ "src": "testModule.name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1324": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 25,
+ "src": "testModuleName === module",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1326": [
+ null,
+ {
+ "position": 157,
+ "nodeLength": 23,
+ "src": "testModule.parentModule",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1334": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 124,
+ "src": "inArray(testModule.moduleId, config.moduleId) > -1 || testModule.parentModule && moduleChainIdMatch(testModule.parentModule)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 50,
+ "src": "inArray(testModule.moduleId, config.moduleId) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67,
+ "nodeLength": 70,
+ "src": "testModule.parentModule && moduleChainIdMatch(testModule.parentModule)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1338": [
+ null,
+ {
+ "position": 807,
+ "nodeLength": 40,
+ "src": "this.callback && this.callback.validTest",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1342": [
+ null,
+ {
+ "position": 884,
+ "nodeLength": 81,
+ "src": "config.moduleId && config.moduleId.length > 0 && !moduleChainIdMatch(this.module)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 903,
+ "nodeLength": 62,
+ "src": "config.moduleId.length > 0 && !moduleChainIdMatch(this.module)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 903,
+ "nodeLength": 26,
+ "src": "config.moduleId.length > 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1347": [
+ null,
+ {
+ "position": 1004,
+ "nodeLength": 84,
+ "src": "config.testId && config.testId.length > 0 && inArray(this.testId, config.testId) < 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 1021,
+ "nodeLength": 67,
+ "src": "config.testId.length > 0 && inArray(this.testId, config.testId) < 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 1021,
+ "nodeLength": 24,
+ "src": "config.testId.length > 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ },
+ {
+ "position": 1049,
+ "nodeLength": 39,
+ "src": "inArray(this.testId, config.testId) < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1352": [
+ null,
+ {
+ "position": 1127,
+ "nodeLength": 44,
+ "src": "module && !moduleChainNameMatch(this.module)",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1356": [
+ null,
+ {
+ "position": 1209,
+ "nodeLength": 7,
+ "src": "!filter",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "1360": [
+ null,
+ {
+ "position": 1256,
+ "nodeLength": 11,
+ "src": "regexFilter",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1367": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 17,
+ "src": "match !== exclude",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1374": [
+ null,
+ {
+ "position": 94,
+ "nodeLength": 24,
+ "src": "filter.charAt(0) !== \"!\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1375": [
+ null,
+ {
+ "position": 128,
+ "nodeLength": 8,
+ "src": "!include",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1380": [
+ null,
+ {
+ "position": 242,
+ "nodeLength": 31,
+ "src": "fullName.indexOf(filter) !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1390": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 15,
+ "src": "!config.current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1409": [
+ null,
+ {
+ "position": 121,
+ "nodeLength": 7,
+ "src": "i < len",
+ "evalFalse": 6,
+ "evalTrue": 272
+ }
+ ],
+ "1417": [
+ null,
+ {
+ "position": 447,
+ "nodeLength": 14,
+ "src": "hex.length < 8",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "1428": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 32,
+ "src": "objectType(callback) === \"array\"",
+ "evalFalse": 24,
+ "evalTrue": 20
+ }
+ ],
+ "1429": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 15,
+ "src": "callback.length",
+ "evalFalse": 20,
+ "evalTrue": 36
+ }
+ ],
+ "1435": [
+ null,
+ {
+ "position": 181,
+ "nodeLength": 8,
+ "src": "priority",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "1437": [
+ null,
+ {
+ "position": 263,
+ "nodeLength": 4,
+ "src": "seed",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "1438": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 12,
+ "src": "!unitSampler",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1449": [
+ null,
+ {
+ "position": 612,
+ "nodeLength": 41,
+ "src": "internalState.autorun && !config.blocking",
+ "evalFalse": 24,
+ "evalTrue": 0
+ }
+ ],
+ "1458": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 38,
+ "src": "parseInt(generateHash(seed), 16) || -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1465": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 10,
+ "src": "sample < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1476": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 16,
+ "src": "config.noglobals",
+ "evalFalse": 5,
+ "evalTrue": 0
+ }
+ ],
+ "1478": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 26,
+ "src": "hasOwn.call(global$1, key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1481": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 30,
+ "src": "/^qunit-test-output/.test(key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1498": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 21,
+ "src": "newGlobals.length > 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1503": [
+ null,
+ {
+ "position": 308,
+ "nodeLength": 25,
+ "src": "deletedGlobals.length > 0",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1510": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 7,
+ "src": "focused",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1526": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 7,
+ "src": "focused",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1542": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 7,
+ "src": "focused",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1565": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 40,
+ "src": "config.testTimeout && defined.setTimeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1574": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 8,
+ "src": "released",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1594": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 21,
+ "src": "isNaN(test.semaphore)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1602": [
+ null,
+ {
+ "position": 259,
+ "nodeLength": 18,
+ "src": "test.semaphore > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1607": [
+ null,
+ {
+ "position": 367,
+ "nodeLength": 18,
+ "src": "test.semaphore < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1615": [
+ null,
+ {
+ "position": 616,
+ "nodeLength": 18,
+ "src": "defined.setTimeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1616": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 14,
+ "src": "config.timeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1620": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 18,
+ "src": "test.semaphore > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1624": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 14,
+ "src": "config.timeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1640": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 14,
+ "src": "modules.length",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1651": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 28,
+ "src": "module = module.parentModule",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "1672": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 22,
+ "src": "arguments.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1688": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 38,
+ "src": "typeof acceptCallCount === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1696": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 6,
+ "src": "popped",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1702": [
+ null,
+ {
+ "position": 185,
+ "nodeLength": 19,
+ "src": "acceptCallCount > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1719": [
+ null,
+ {
+ "position": 189,
+ "nodeLength": 22,
+ "src": "this instanceof Assert",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1734": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 57,
+ "src": "assert instanceof Assert && assert.test || config.current",
+ "evalFalse": 0,
+ "evalTrue": 9
+ },
+ {
+ "position": 41,
+ "nodeLength": 39,
+ "src": "assert instanceof Assert && assert.test",
+ "evalFalse": 0,
+ "evalTrue": 9
+ }
+ ],
+ "1741": [
+ null,
+ {
+ "position": 557,
+ "nodeLength": 12,
+ "src": "!currentTest",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "1745": [
+ null,
+ {
+ "position": 678,
+ "nodeLength": 61,
+ "src": "currentTest.usedAsync === true && currentTest.semaphore === 0",
+ "evalFalse": 9,
+ "evalTrue": 0
+ },
+ {
+ "position": 678,
+ "nodeLength": 30,
+ "src": "currentTest.usedAsync === true",
+ "evalFalse": 9,
+ "evalTrue": 0
+ },
+ {
+ "position": 712,
+ "nodeLength": 27,
+ "src": "currentTest.semaphore === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1751": [
+ null,
+ {
+ "position": 934,
+ "nodeLength": 27,
+ "src": "!(assert instanceof Assert)",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "1760": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "!message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1761": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 6,
+ "src": "result",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1774": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 8,
+ "src": "!message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1775": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 7,
+ "src": "!result",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1790": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 18,
+ "src": "expected == actual",
+ "evalFalse": 0,
+ "evalTrue": 9
+ }
+ ],
+ "1804": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 18,
+ "src": "expected != actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1866": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 19,
+ "src": "expected === actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1876": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 19,
+ "src": "expected !== actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1888": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 53,
+ "src": "this instanceof Assert && this.test || config.current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68,
+ "nodeLength": 35,
+ "src": "this instanceof Assert && this.test",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1891": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 33,
+ "src": "objectType(expected) === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1892": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 15,
+ "src": "message == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1908": [
+ null,
+ {
+ "position": 727,
+ "nodeLength": 6,
+ "src": "actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1912": [
+ null,
+ {
+ "position": 107,
+ "nodeLength": 9,
+ "src": "!expected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1917": [
+ null,
+ {
+ "position": 216,
+ "nodeLength": 25,
+ "src": "expectedType === \"regexp\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1921": [
+ null,
+ {
+ "position": 380,
+ "nodeLength": 57,
+ "src": "expectedType === \"function\" && actual instanceof expected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 380,
+ "nodeLength": 27,
+ "src": "expectedType === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1925": [
+ null,
+ {
+ "position": 520,
+ "nodeLength": 25,
+ "src": "expectedType === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1926": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 110,
+ "src": "actual instanceof expected.constructor && actual.name === expected.name && actual.message === expected.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59,
+ "nodeLength": 68,
+ "src": "actual.name === expected.name && actual.message === expected.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59,
+ "nodeLength": 29,
+ "src": "actual.name === expected.name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92,
+ "nodeLength": 35,
+ "src": "actual.message === expected.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1929": [
+ null,
+ {
+ "position": 780,
+ "nodeLength": 65,
+ "src": "expectedType === \"function\" && expected.call({}, actual) === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 780,
+ "nodeLength": 27,
+ "src": "expectedType === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 811,
+ "nodeLength": 34,
+ "src": "expected.call({}, actual) === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1962": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 47,
+ "src": "resultErrorString.substring(0, 7) === \"[object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1963": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 10,
+ "src": "error.name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1964": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 13,
+ "src": "error.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1966": [
+ null,
+ {
+ "position": 136,
+ "nodeLength": 15,
+ "src": "name && message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1968": [
+ null,
+ {
+ "position": 206,
+ "nodeLength": 4,
+ "src": "name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1970": [
+ null,
+ {
+ "position": 248,
+ "nodeLength": 7,
+ "src": "message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1983": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 16,
+ "src": "defined.document",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1986": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 36,
+ "src": "window.QUnit && window.QUnit.version",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1994": [
+ null,
+ {
+ "position": 304,
+ "nodeLength": 57,
+ "src": "typeof module !== \"undefined\" && module && module.exports",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 304,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 337,
+ "nodeLength": 24,
+ "src": "module && module.exports",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2002": [
+ null,
+ {
+ "position": 572,
+ "nodeLength": 41,
+ "src": "typeof exports !== \"undefined\" && exports",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 572,
+ "nodeLength": 30,
+ "src": "typeof exports !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2006": [
+ null,
+ {
+ "position": 657,
+ "nodeLength": 42,
+ "src": "typeof define === \"function\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 657,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2015": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 17,
+ "src": "!defined.document",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2028": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 13,
+ "src": "onErrorFnPrev",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2034": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 12,
+ "src": "ret !== true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2035": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 14,
+ "src": "config.current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2036": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 33,
+ "src": "config.current.ignoreGlobalErrors",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2062": [
+ null,
+ {
+ "position": 50229,
+ "nodeLength": 56,
+ "src": "defined.document && window.location.protocol !== \"file:\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 50249,
+ "nodeLength": 36,
+ "src": "window.location.protocol !== \"file:\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2074": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 22,
+ "src": "arguments.length === 2",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2075": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 42,
+ "src": "objectType(testEnvironment) === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2090": [
+ null,
+ {
+ "position": 484,
+ "nodeLength": 37,
+ "src": "objectType(executeNow) === \"function\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2095": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 36,
+ "src": "module.parentModule || currentModule",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2101": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 25,
+ "src": "config.moduleStack.length",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2102": [
+ null,
+ {
+ "position": 115,
+ "nodeLength": 21,
+ "src": "parentModule !== null",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2113": [
+ null,
+ {
+ "position": 401,
+ "nodeLength": 12,
+ "src": "parentModule",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2140": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 15,
+ "src": "!config.current",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2143": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 10,
+ "src": "runStarted",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2145": [
+ null,
+ {
+ "position": 149,
+ "nodeLength": 37,
+ "src": "globalStartAlreadyCalled || count > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 177,
+ "nodeLength": 9,
+ "src": "count > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2147": [
+ null,
+ {
+ "position": 289,
+ "nodeLength": 16,
+ "src": "config.autostart",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2149": [
+ null,
+ {
+ "position": 435,
+ "nodeLength": 39,
+ "src": "!defined.document && !config.pageLoaded",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2153": [
+ null,
+ {
+ "position": 582,
+ "nodeLength": 18,
+ "src": "!config.pageLoaded",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2186": [
+ null,
+ {
+ "position": 230,
+ "nodeLength": 11,
+ "src": "!runStarted",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2189": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 16,
+ "src": "config.autostart",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2196": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 11,
+ "src": "offset || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2213": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 18,
+ "src": "defined.setTimeout",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2228": [
+ null,
+ {
+ "position": 103,
+ "nodeLength": 15,
+ "src": "!config.started",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2234": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 69,
+ "src": "config.modules[0].name === \"\" && config.modules[0].tests.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 140,
+ "nodeLength": 29,
+ "src": "config.modules[0].name === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 173,
+ "nodeLength": 36,
+ "src": "config.modules[0].tests.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2239": [
+ null,
+ {
+ "position": 371,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "2262": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 17,
+ "src": "config.depth || 0",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2264": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 39,
+ "src": "config.queue.length && !config.blocking",
+ "evalFalse": 1,
+ "evalTrue": 24
+ }
+ ],
+ "2265": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 82,
+ "src": "!defined.setTimeout || config.updateRate <= 0 || now() - start < config.updateRate",
+ "evalFalse": 0,
+ "evalTrue": 24
+ },
+ {
+ "position": 32,
+ "nodeLength": 59,
+ "src": "config.updateRate <= 0 || now() - start < config.updateRate",
+ "evalFalse": 0,
+ "evalTrue": 24
+ },
+ {
+ "position": 32,
+ "nodeLength": 22,
+ "src": "config.updateRate <= 0",
+ "evalFalse": 24,
+ "evalTrue": 0
+ },
+ {
+ "position": 58,
+ "nodeLength": 33,
+ "src": "now() - start < config.updateRate",
+ "evalFalse": 0,
+ "evalTrue": 24
+ }
+ ],
+ "2266": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 14,
+ "src": "config.current",
+ "evalFalse": 8,
+ "evalTrue": 16
+ }
+ ],
+ "2278": [
+ null,
+ {
+ "position": 519,
+ "nodeLength": 70,
+ "src": "last && !config.blocking && !config.queue.length && config.depth === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 527,
+ "nodeLength": 62,
+ "src": "!config.blocking && !config.queue.length && config.depth === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 547,
+ "nodeLength": 42,
+ "src": "!config.queue.length && config.depth === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 571,
+ "nodeLength": 18,
+ "src": "config.depth === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2303": [
+ null,
+ {
+ "position": 407,
+ "nodeLength": 33,
+ "src": "storage && config.stats.bad === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 418,
+ "nodeLength": 22,
+ "src": "config.stats.bad === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2304": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 6,
+ "src": "i >= 0",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2306": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 32,
+ "src": "key.indexOf(\"qunit-test-\") === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2314": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 36,
+ "src": "module.testEnvironment === undefined",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "2327": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 64,
+ "src": "typeof window === \"undefined\" || typeof document === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 9,
+ "nodeLength": 29,
+ "src": "typeof window === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 31,
+ "src": "typeof document === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2338": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 30,
+ "src": "hasOwn.call(config, \"fixture\")",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2343": [
+ null,
+ {
+ "position": 177,
+ "nodeLength": 7,
+ "src": "fixture",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2352": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 22,
+ "src": "config.fixture == null",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "2357": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 7,
+ "src": "fixture",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "2368": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 48,
+ "src": "typeof window !== \"undefined\" && window.location",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 70,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2369": [
+ null,
+ {
+ "position": 127,
+ "nodeLength": 9,
+ "src": "!location",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2378": [
+ null,
+ {
+ "position": 313,
+ "nodeLength": 24,
+ "src": "urlParams.moduleId || []",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2379": [
+ null,
+ {
+ "position": 375,
+ "nodeLength": 22,
+ "src": "urlParams.testId || []",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2388": [
+ null,
+ {
+ "position": 672,
+ "nodeLength": 23,
+ "src": "urlParams.seed === true",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2392": [
+ null,
+ {
+ "position": 849,
+ "nodeLength": 14,
+ "src": "urlParams.seed",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2416": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 20,
+ "src": "i < urlConfig.length",
+ "evalFalse": 1,
+ "evalTrue": 3
+ }
+ ],
+ "2420": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 26,
+ "src": "typeof option !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "2424": [
+ null,
+ {
+ "position": 204,
+ "nodeLength": 34,
+ "src": "QUnit.config[option] === undefined",
+ "evalFalse": 0,
+ "evalTrue": 3
+ }
+ ],
+ "2436": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 10,
+ "src": "i < length",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "2437": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 9,
+ "src": "params[i]",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2442": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 64,
+ "src": "param.length === 1 || decodeQueryParam(param.slice(1).join(\"=\"))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 164,
+ "nodeLength": 18,
+ "src": "param.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2443": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 17,
+ "src": "name in urlParams",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2461": [
+ null,
+ {
+ "position": 8,
+ "nodeLength": 2,
+ "src": "!s",
+ "evalFalse": 38,
+ "evalTrue": 0
+ }
+ ],
+ "2486": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 49,
+ "src": "typeof window === \"undefined\" || !window.document",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 29,
+ "src": "typeof window === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2508": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 2,
+ "evalTrue": 3
+ }
+ ],
+ "2514": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 58,
+ "src": "(\" \" + elem.className + \" \").indexOf(\" \" + name + \" \") >= 0",
+ "evalFalse": 14,
+ "evalTrue": 1
+ }
+ ],
+ "2518": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 21,
+ "src": "!hasClass(elem, name)",
+ "evalFalse": 1,
+ "evalTrue": 14
+ }
+ ],
+ "2519": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 14,
+ "src": "elem.className",
+ "evalFalse": 6,
+ "evalTrue": 8
+ }
+ ],
+ "2524": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 62,
+ "src": "force || typeof force === \"undefined\" && !hasClass(elem, name)",
+ "evalFalse": 2,
+ "evalTrue": 1
+ },
+ {
+ "position": 18,
+ "nodeLength": 53,
+ "src": "typeof force === \"undefined\" && !hasClass(elem, name)",
+ "evalFalse": 2,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 28,
+ "src": "typeof force === \"undefined\"",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2535": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 34,
+ "src": "set.indexOf(\" \" + name + \" \") >= 0",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2540": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 30,
+ "src": "typeof set.trim === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "2544": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 62,
+ "src": "document$$1.getElementById && document$$1.getElementById(name)",
+ "evalFalse": 1,
+ "evalTrue": 41
+ }
+ ],
+ "2549": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 11,
+ "src": "abortButton",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2560": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 23,
+ "src": "ev && ev.preventDefault",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2577": [
+ null,
+ {
+ "position": 187,
+ "nodeLength": 20,
+ "src": "i < urlConfig.length",
+ "evalFalse": 1,
+ "evalTrue": 3
+ }
+ ],
+ "2581": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 23,
+ "src": "typeof val === \"string\"",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "2591": [
+ null,
+ {
+ "position": 306,
+ "nodeLength": 43,
+ "src": "!val.value || typeof val.value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 3
+ },
+ {
+ "position": 320,
+ "nodeLength": 29,
+ "src": "typeof val.value === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2592": [
+ null,
+ {
+ "position": 188,
+ "nodeLength": 9,
+ "src": "val.value",
+ "evalFalse": 3,
+ "evalTrue": 0
+ },
+ {
+ "position": 250,
+ "nodeLength": 14,
+ "src": "config[val.id]",
+ "evalFalse": 3,
+ "evalTrue": 0
+ }
+ ],
+ "2596": [
+ null,
+ {
+ "position": 258,
+ "nodeLength": 28,
+ "src": "QUnit.is(\"array\", val.value)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2597": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 20,
+ "src": "j < val.value.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2599": [
+ null,
+ {
+ "position": 107,
+ "nodeLength": 31,
+ "src": "config[val.id] === val.value[j]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 142,
+ "nodeLength": 43,
+ "src": "(selection = true) && \" selected='selected'\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2603": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 25,
+ "src": "hasOwn.call(val.value, j)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2604": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 20,
+ "src": "config[val.id] === j",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 94,
+ "nodeLength": 43,
+ "src": "(selection = true) && \" selected='selected'\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2608": [
+ null,
+ {
+ "position": 883,
+ "nodeLength": 28,
+ "src": "config[val.id] && !selection",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2629": [
+ null,
+ {
+ "position": 157,
+ "nodeLength": 24,
+ "src": "\"selectedIndex\" in field",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2630": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 53,
+ "src": "field.options[field.selectedIndex].value || undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2632": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 13,
+ "src": "field.checked",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 26,
+ "src": "field.defaultValue || true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2639": [
+ null,
+ {
+ "position": 479,
+ "nodeLength": 63,
+ "src": "\"hidepassed\" === field.name && \"replaceState\" in window.history",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 479,
+ "nodeLength": 27,
+ "src": "\"hidepassed\" === field.name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2641": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 14,
+ "src": "value || false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2643": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 5,
+ "src": "tests",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2644": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 14,
+ "src": "value || false",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2664": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 53,
+ "src": "hasOwn.call(params, key) && params[key] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 4
+ },
+ {
+ "position": 86,
+ "nodeLength": 25,
+ "src": "params[key] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "2668": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 19,
+ "src": "i < arrValue.length",
+ "evalFalse": 4,
+ "evalTrue": 4
+ }
+ ],
+ "2670": [
+ null,
+ {
+ "position": 59,
+ "nodeLength": 20,
+ "src": "arrValue[i] !== true",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "2686": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 22,
+ "src": "i < modulesList.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2687": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 22,
+ "src": "modulesList[i].checked",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2693": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 13,
+ "src": "filter === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2694": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 28,
+ "src": "selectedModules.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2733": [
+ null,
+ {
+ "position": 329,
+ "nodeLength": 19,
+ "src": "config.filter || \"\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2754": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 25,
+ "src": "i < config.modules.length",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "2755": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 29,
+ "src": "config.modules[i].name !== \"\"",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "2756": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 56,
+ "src": "config.moduleId.indexOf(config.modules[i].moduleId) > -1",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2757": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 7,
+ "src": "checked",
+ "evalFalse": 2,
+ "evalTrue": 0
+ },
+ {
+ "position": 229,
+ "nodeLength": 7,
+ "src": "checked",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2787": [
+ null,
+ {
+ "position": 995,
+ "nodeLength": 22,
+ "src": "config.moduleId.length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1069,
+ "nodeLength": 22,
+ "src": "config.moduleId.length",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2815": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 33,
+ "src": "dropDown.style.display !== \"none\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2827": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 32,
+ "src": "e.keyCode === 27 || !inContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 16,
+ "src": "e.keyCode === 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2828": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 31,
+ "src": "e.keyCode === 27 && inContainer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12,
+ "nodeLength": 16,
+ "src": "e.keyCode === 27",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2847": [
+ null,
+ {
+ "position": 146,
+ "nodeLength": 20,
+ "src": "i < listItems.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2849": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 70,
+ "src": "!searchText || item.textContent.toLowerCase().indexOf(searchText) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 55,
+ "src": "item.textContent.toLowerCase().indexOf(searchText) > -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2861": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 32,
+ "src": "evt && evt.target || allCheckbox",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 41,
+ "nodeLength": 17,
+ "src": "evt && evt.target",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2868": [
+ null,
+ {
+ "position": 277,
+ "nodeLength": 44,
+ "src": "checkbox.checked && checkbox !== allCheckbox",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 297,
+ "nodeLength": 24,
+ "src": "checkbox !== allCheckbox",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2872": [
+ null,
+ {
+ "position": 438,
+ "nodeLength": 22,
+ "src": "i < modulesList.length",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "2874": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 4,
+ "src": "!evt",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "2876": [
+ null,
+ {
+ "position": 128,
+ "nodeLength": 44,
+ "src": "checkbox === allCheckbox && checkbox.checked",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128,
+ "nodeLength": 24,
+ "src": "checkbox === allCheckbox",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2880": [
+ null,
+ {
+ "position": 274,
+ "nodeLength": 45,
+ "src": "dirty || item.checked !== item.defaultChecked",
+ "evalFalse": 2,
+ "evalTrue": 0
+ },
+ {
+ "position": 283,
+ "nodeLength": 36,
+ "src": "item.checked !== item.defaultChecked",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2881": [
+ null,
+ {
+ "position": 331,
+ "nodeLength": 12,
+ "src": "item.checked",
+ "evalFalse": 2,
+ "evalTrue": 0
+ }
+ ],
+ "2886": [
+ null,
+ {
+ "position": 937,
+ "nodeLength": 5,
+ "src": "dirty",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2887": [
+ null,
+ {
+ "position": 990,
+ "nodeLength": 62,
+ "src": "selectedNames.join(\", \") || allCheckbox.parentNode.textContent",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2888": [
+ null,
+ {
+ "position": 1127,
+ "nodeLength": 62,
+ "src": "selectedNames.join(\"\\n\") || allCheckbox.parentNode.textContent",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2897": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 7,
+ "src": "toolbar",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2908": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 6,
+ "src": "header",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2916": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 6,
+ "src": "banner",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2926": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 6,
+ "src": "result",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2930": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 5,
+ "src": "tests",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2940": [
+ null,
+ {
+ "position": 618,
+ "nodeLength": 8,
+ "src": "controls",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2947": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 29,
+ "src": "!testId || testId.length <= 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 58,
+ "nodeLength": 18,
+ "src": "testId.length <= 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2956": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 9,
+ "src": "userAgent",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2965": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 5,
+ "src": "qunit",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2979": [
+ null,
+ {
+ "position": 75,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "2982": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 5,
+ "src": "x < z",
+ "evalFalse": 2,
+ "evalTrue": 4
+ }
+ ],
+ "2997": [
+ null,
+ {
+ "position": 121,
+ "nodeLength": 6,
+ "src": "!tests",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3026": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 26,
+ "src": "i < details.modules.length",
+ "evalFalse": 1,
+ "evalTrue": 2
+ }
+ ],
+ "3028": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 14,
+ "src": "moduleObj.name",
+ "evalFalse": 0,
+ "evalTrue": 2
+ }
+ ],
+ "3040": [
+ null,
+ {
+ "position": 455,
+ "nodeLength": 26,
+ "src": "tests && config.hidepassed",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3055": [
+ null,
+ {
+ "position": 495,
+ "nodeLength": 35,
+ "src": "abortButton && abortButton.disabled",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3058": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 25,
+ "src": "i < tests.children.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3060": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 53,
+ "src": "test.className === \"\" || test.className === \"running\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 43,
+ "nodeLength": 21,
+ "src": "test.className === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 68,
+ "nodeLength": 28,
+ "src": "test.className === \"running\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3071": [
+ null,
+ {
+ "position": 1056,
+ "nodeLength": 58,
+ "src": "banner && (!abortButton || abortButton.disabled === false)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1067,
+ "nodeLength": 46,
+ "src": "!abortButton || abortButton.disabled === false",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1083,
+ "nodeLength": 30,
+ "src": "abortButton.disabled === false",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3072": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 14,
+ "src": "details.failed",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3075": [
+ null,
+ {
+ "position": 1203,
+ "nodeLength": 11,
+ "src": "abortButton",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3079": [
+ null,
+ {
+ "position": 1287,
+ "nodeLength": 5,
+ "src": "tests",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3083": [
+ null,
+ {
+ "position": 1365,
+ "nodeLength": 38,
+ "src": "config.altertitle && document$$1.title",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3087": [
+ null,
+ {
+ "position": 162,
+ "nodeLength": 14,
+ "src": "details.failed",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3091": [
+ null,
+ {
+ "position": 1726,
+ "nodeLength": 35,
+ "src": "config.scrolltop && window.scrollTo",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3099": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 6,
+ "src": "module",
+ "evalFalse": 0,
+ "evalTrue": 8
+ }
+ ],
+ "3112": [
+ null,
+ {
+ "position": 102,
+ "nodeLength": 9,
+ "src": "testBlock",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3121": [
+ null,
+ {
+ "position": 329,
+ "nodeLength": 7,
+ "src": "running",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3122": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 47,
+ "src": "QUnit.config.reorder && details.previousFailure",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3124": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 3,
+ "src": "bad",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3144": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 9,
+ "src": "!testItem",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "3148": [
+ null,
+ {
+ "position": 248,
+ "nodeLength": 67,
+ "src": "escapeText(details.message) || (details.result ? \"okay\" : \"failed\")",
+ "evalFalse": 0,
+ "evalTrue": 9
+ },
+ {
+ "position": 280,
+ "nodeLength": 14,
+ "src": "details.result",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3155": [
+ null,
+ {
+ "position": 693,
+ "nodeLength": 51,
+ "src": "!details.result && hasOwn.call(details, \"expected\")",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "3156": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 16,
+ "src": "details.negative",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3165": [
+ null,
+ {
+ "position": 350,
+ "nodeLength": 19,
+ "src": "actual !== expected",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3170": [
+ null,
+ {
+ "position": 186,
+ "nodeLength": 66,
+ "src": "!/^(true|false)$/.test(actual) && !/^(true|false)$/.test(expected)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3172": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 80,
+ "src": "stripHtml(diff).length !== stripHtml(expected).length + stripHtml(actual).length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3176": [
+ null,
+ {
+ "position": 489,
+ "nodeLength": 8,
+ "src": "showDiff",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3179": [
+ null,
+ {
+ "position": 994,
+ "nodeLength": 87,
+ "src": "expected.indexOf(\"[object Array]\") !== -1 || expected.indexOf(\"[object Object]\") !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 994,
+ "nodeLength": 41,
+ "src": "expected.indexOf(\"[object Array]\") !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1039,
+ "nodeLength": 42,
+ "src": "expected.indexOf(\"[object Object]\") !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3185": [
+ null,
+ {
+ "position": 1649,
+ "nodeLength": 14,
+ "src": "details.source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3192": [
+ null,
+ {
+ "position": 2669,
+ "nodeLength": 33,
+ "src": "!details.result && details.source",
+ "evalFalse": 9,
+ "evalTrue": 0
+ }
+ ],
+ "3199": [
+ null,
+ {
+ "position": 2988,
+ "nodeLength": 14,
+ "src": "details.result",
+ "evalFalse": 0,
+ "evalTrue": 9
+ }
+ ],
+ "3216": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 6,
+ "src": "!tests",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3227": [
+ null,
+ {
+ "position": 409,
+ "nodeLength": 9,
+ "src": "bad === 0",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3231": [
+ null,
+ {
+ "position": 520,
+ "nodeLength": 15,
+ "src": "config.collapse",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3232": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 13,
+ "src": "!collapseNext",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3246": [
+ null,
+ {
+ "position": 853,
+ "nodeLength": 3,
+ "src": "bad",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3250": [
+ null,
+ {
+ "position": 1056,
+ "nodeLength": 15,
+ "src": "details.skipped",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3261": [
+ null,
+ {
+ "position": 135,
+ "nodeLength": 3,
+ "src": "bad",
+ "evalFalse": 4,
+ "evalTrue": 0
+ }
+ ],
+ "3270": [
+ null,
+ {
+ "position": 1710,
+ "nodeLength": 14,
+ "src": "details.source",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3274": [
+ null,
+ {
+ "position": 177,
+ "nodeLength": 9,
+ "src": "bad === 0",
+ "evalFalse": 0,
+ "evalTrue": 4
+ }
+ ],
+ "3287": [
+ null,
+ {
+ "position": 14,
+ "nodeLength": 37,
+ "src": "p && p.version && p.version.major > 0",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 19,
+ "nodeLength": 32,
+ "src": "p.version && p.version.major > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 32,
+ "nodeLength": 19,
+ "src": "p.version.major > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3290": [
+ null,
+ {
+ "position": 25725,
+ "nodeLength": 51,
+ "src": "notPhantom && document$$1.readyState === \"complete\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 25739,
+ "nodeLength": 37,
+ "src": "document$$1.readyState === \"complete\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3356": [
+ null,
+ {
+ "position": 216,
+ "nodeLength": 32,
+ "src": "text1 === null || text2 === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 216,
+ "nodeLength": 14,
+ "src": "text1 === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 234,
+ "nodeLength": 14,
+ "src": "text2 === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3361": [
+ null,
+ {
+ "position": 352,
+ "nodeLength": 15,
+ "src": "text1 === text2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3362": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 5,
+ "src": "text1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3368": [
+ null,
+ {
+ "position": 463,
+ "nodeLength": 36,
+ "src": "typeof optChecklines === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3390": [
+ null,
+ {
+ "position": 1250,
+ "nodeLength": 12,
+ "src": "commonprefix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3393": [
+ null,
+ {
+ "position": 1328,
+ "nodeLength": 12,
+ "src": "commonsuffix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3426": [
+ null,
+ {
+ "position": 785,
+ "nodeLength": 22,
+ "src": "pointer < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3429": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 32,
+ "src": "diffs[pointer][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3430": [
+ null,
+ {
+ "position": 11,
+ "nodeLength": 52,
+ "src": "diffs[pointer][1].length < 4 && (postIns || postDel)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 11,
+ "nodeLength": 28,
+ "src": "diffs[pointer][1].length < 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 44,
+ "nodeLength": 18,
+ "src": "postIns || postDel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3448": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 33,
+ "src": "diffs[pointer][0] === DIFF_DELETE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3462": [
+ null,
+ {
+ "position": 429,
+ "nodeLength": 128,
+ "src": "lastequality && (preIns && preDel && postIns && postDel || lastequality.length < 2 && preIns + preDel + postIns + postDel === 3)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 446,
+ "nodeLength": 110,
+ "src": "preIns && preDel && postIns && postDel || lastequality.length < 2 && preIns + preDel + postIns + postDel === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 446,
+ "nodeLength": 38,
+ "src": "preIns && preDel && postIns && postDel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 456,
+ "nodeLength": 28,
+ "src": "preDel && postIns && postDel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 466,
+ "nodeLength": 18,
+ "src": "postIns && postDel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 488,
+ "nodeLength": 68,
+ "src": "lastequality.length < 2 && preIns + preDel + postIns + postDel === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 488,
+ "nodeLength": 23,
+ "src": "lastequality.length < 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 515,
+ "nodeLength": 41,
+ "src": "preIns + preDel + postIns + postDel === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3471": [
+ null,
+ {
+ "position": 337,
+ "nodeLength": 16,
+ "src": "preIns && preDel",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3478": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 20,
+ "src": "equalitiesLength > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3487": [
+ null,
+ {
+ "position": 2661,
+ "nodeLength": 7,
+ "src": "changes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3503": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 16,
+ "src": "x < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3532": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 55,
+ "src": "!text1 || !text2 || text1.charAt(0) !== text2.charAt(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 120,
+ "nodeLength": 45,
+ "src": "!text2 || text1.charAt(0) !== text2.charAt(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 130,
+ "nodeLength": 35,
+ "src": "text1.charAt(0) !== text2.charAt(0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3542": [
+ null,
+ {
+ "position": 421,
+ "nodeLength": 23,
+ "src": "pointermin < pointermid",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3543": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 87,
+ "src": "text1.substring(pointerstart, pointermid) === text2.substring(pointerstart, pointermid)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3564": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 85,
+ "src": "!text1 || !text2 || text1.charAt(text1.length - 1) !== text2.charAt(text2.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 118,
+ "nodeLength": 75,
+ "src": "!text2 || text1.charAt(text1.length - 1) !== text2.charAt(text2.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 128,
+ "nodeLength": 65,
+ "src": "text1.charAt(text1.length - 1) !== text2.charAt(text2.length - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3574": [
+ null,
+ {
+ "position": 447,
+ "nodeLength": 23,
+ "src": "pointermin < pointermid",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3575": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 143,
+ "src": "text1.substring(text1.length - pointermid, text1.length - pointerend) === text2.substring(text2.length - pointermid, text2.length - pointerend)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3601": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 6,
+ "src": "!text1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3607": [
+ null,
+ {
+ "position": 212,
+ "nodeLength": 6,
+ "src": "!text2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3613": [
+ null,
+ {
+ "position": 322,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3614": [
+ null,
+ {
+ "position": 383,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3616": [
+ null,
+ {
+ "position": 473,
+ "nodeLength": 8,
+ "src": "i !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3622": [
+ null,
+ {
+ "position": 267,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3628": [
+ null,
+ {
+ "position": 870,
+ "nodeLength": 22,
+ "src": "shorttext.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3637": [
+ null,
+ {
+ "position": 1174,
+ "nodeLength": 2,
+ "src": "hm",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3654": [
+ null,
+ {
+ "position": 1640,
+ "nodeLength": 54,
+ "src": "checklines && text1.length > 100 && text2.length > 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1654,
+ "nodeLength": 40,
+ "src": "text1.length > 100 && text2.length > 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1654,
+ "nodeLength": 18,
+ "src": "text1.length > 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1676,
+ "nodeLength": 18,
+ "src": "text2.length > 100",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3675": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3676": [
+ null,
+ {
+ "position": 169,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3677": [
+ null,
+ {
+ "position": 222,
+ "nodeLength": 61,
+ "src": "longtext.length < 4 || shorttext.length * 2 < longtext.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 222,
+ "nodeLength": 19,
+ "src": "longtext.length < 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 245,
+ "nodeLength": 38,
+ "src": "shorttext.length * 2 < longtext.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3701": [
+ null,
+ {
+ "position": 309,
+ "nodeLength": 42,
+ "src": "(j = shorttext.indexOf(seed, j + 1)) !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3704": [
+ null,
+ {
+ "position": 197,
+ "nodeLength": 47,
+ "src": "bestCommon.length < suffixLength + prefixLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3712": [
+ null,
+ {
+ "position": 985,
+ "nodeLength": 40,
+ "src": "bestCommon.length * 2 >= longtext.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3724": [
+ null,
+ {
+ "position": 2527,
+ "nodeLength": 12,
+ "src": "!hm1 && !hm2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3726": [
+ null,
+ {
+ "position": 2577,
+ "nodeLength": 4,
+ "src": "!hm2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3728": [
+ null,
+ {
+ "position": 2616,
+ "nodeLength": 4,
+ "src": "!hm1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3733": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 29,
+ "src": "hm1[4].length > hm2[4].length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3737": [
+ null,
+ {
+ "position": 2819,
+ "nodeLength": 27,
+ "src": "text1.length > text2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3787": [
+ null,
+ {
+ "position": 759,
+ "nodeLength": 22,
+ "src": "pointer < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3800": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 36,
+ "src": "countDelete >= 1 && countInsert >= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95,
+ "nodeLength": 16,
+ "src": "countDelete >= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 115,
+ "nodeLength": 16,
+ "src": "countInsert >= 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3806": [
+ null,
+ {
+ "position": 307,
+ "nodeLength": 6,
+ "src": "j >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3848": [
+ null,
+ {
+ "position": 566,
+ "nodeLength": 11,
+ "src": "x < vLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3858": [
+ null,
+ {
+ "position": 840,
+ "nodeLength": 15,
+ "src": "delta % 2 !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3866": [
+ null,
+ {
+ "position": 1032,
+ "nodeLength": 8,
+ "src": "d < maxD",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3869": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 31,
+ "src": "new Date().getTime() > deadline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3874": [
+ null,
+ {
+ "position": 175,
+ "nodeLength": 15,
+ "src": "k1 <= d - k1end",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3876": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 60,
+ "src": "k1 === -d || k1 !== d && v1[k1Offset - 1] < v1[k1Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 9,
+ "src": "k1 === -d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 47,
+ "src": "k1 !== d && v1[k1Offset - 1] < v1[k1Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 8,
+ "src": "k1 !== d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67,
+ "nodeLength": 35,
+ "src": "v1[k1Offset - 1] < v1[k1Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3882": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 77,
+ "src": "x1 < text1Length && y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 226,
+ "nodeLength": 16,
+ "src": "x1 < text1Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 57,
+ "src": "y1 < text2Length && text1.charAt(x1) === text2.charAt(y1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 16,
+ "src": "y1 < text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 266,
+ "nodeLength": 37,
+ "src": "text1.charAt(x1) === text2.charAt(y1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3887": [
+ null,
+ {
+ "position": 376,
+ "nodeLength": 16,
+ "src": "x1 > text1Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3891": [
+ null,
+ {
+ "position": 476,
+ "nodeLength": 16,
+ "src": "y1 > text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3895": [
+ null,
+ {
+ "position": 579,
+ "nodeLength": 5,
+ "src": "front",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3897": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 58,
+ "src": "k2Offset >= 0 && k2Offset < vLength && v2[k2Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 13,
+ "src": "k2Offset >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 41,
+ "src": "k2Offset < vLength && v2[k2Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 18,
+ "src": "k2Offset < vLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91,
+ "nodeLength": 19,
+ "src": "v2[k2Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3901": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 8,
+ "src": "x1 >= x2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3911": [
+ null,
+ {
+ "position": 1226,
+ "nodeLength": 15,
+ "src": "k2 <= d - k2end",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3913": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 60,
+ "src": "k2 === -d || k2 !== d && v2[k2Offset - 1] < v2[k2Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 42,
+ "nodeLength": 9,
+ "src": "k2 === -d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 47,
+ "src": "k2 !== d && v2[k2Offset - 1] < v2[k2Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 8,
+ "src": "k2 !== d",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 67,
+ "nodeLength": 35,
+ "src": "v2[k2Offset - 1] < v2[k2Offset + 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3919": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 113,
+ "src": "x2 < text1Length && y2 < text2Length && text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 226,
+ "nodeLength": 16,
+ "src": "x2 < text1Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 93,
+ "src": "y2 < text2Length && text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 16,
+ "src": "y2 < text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 266,
+ "nodeLength": 73,
+ "src": "text1.charAt(text1Length - x2 - 1) === text2.charAt(text2Length - y2 - 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3924": [
+ null,
+ {
+ "position": 412,
+ "nodeLength": 16,
+ "src": "x2 > text1Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3928": [
+ null,
+ {
+ "position": 511,
+ "nodeLength": 16,
+ "src": "y2 > text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3932": [
+ null,
+ {
+ "position": 611,
+ "nodeLength": 6,
+ "src": "!front",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3934": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 58,
+ "src": "k1Offset >= 0 && k1Offset < vLength && v1[k1Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 13,
+ "src": "k1Offset >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 41,
+ "src": "k1Offset < vLength && v1[k1Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 69,
+ "nodeLength": 18,
+ "src": "k1Offset < vLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91,
+ "nodeLength": 19,
+ "src": "v1[k1Offset] !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3940": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 8,
+ "src": "x1 >= x2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4002": [
+ null,
+ {
+ "position": 771,
+ "nodeLength": 22,
+ "src": "pointer < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4003": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 32,
+ "src": "diffs[pointer][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4013": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 33,
+ "src": "diffs[pointer][0] === DIFF_INSERT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4021": [
+ null,
+ {
+ "position": 323,
+ "nodeLength": 156,
+ "src": "lastequality && lastequality.length <= Math.max(lengthInsertions1, lengthDeletions1) && lastequality.length <= Math.max(lengthInsertions2, lengthDeletions2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 339,
+ "nodeLength": 140,
+ "src": "lastequality.length <= Math.max(lengthInsertions1, lengthDeletions1) && lastequality.length <= Math.max(lengthInsertions2, lengthDeletions2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 339,
+ "nodeLength": 68,
+ "src": "lastequality.length <= Math.max(lengthInsertions1, lengthDeletions1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 411,
+ "nodeLength": 68,
+ "src": "lastequality.length <= Math.max(lengthInsertions2, lengthDeletions2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4034": [
+ null,
+ {
+ "position": 424,
+ "nodeLength": 20,
+ "src": "equalitiesLength > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4049": [
+ null,
+ {
+ "position": 2354,
+ "nodeLength": 7,
+ "src": "changes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4060": [
+ null,
+ {
+ "position": 2755,
+ "nodeLength": 22,
+ "src": "pointer < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4061": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 74,
+ "src": "diffs[pointer - 1][0] === DIFF_DELETE && diffs[pointer][0] === DIFF_INSERT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 37,
+ "src": "diffs[pointer - 1][0] === DIFF_DELETE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 51,
+ "nodeLength": 33,
+ "src": "diffs[pointer][0] === DIFF_INSERT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4066": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 32,
+ "src": "overlapLength1 >= overlapLength2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4067": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 79,
+ "src": "overlapLength1 >= deletion.length / 2 || overlapLength1 >= insertion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12,
+ "nodeLength": 37,
+ "src": "overlapLength1 >= deletion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 38,
+ "src": "overlapLength1 >= insertion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4076": [
+ null,
+ {
+ "position": 12,
+ "nodeLength": 79,
+ "src": "overlapLength2 >= deletion.length / 2 || overlapLength2 >= insertion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 12,
+ "nodeLength": 37,
+ "src": "overlapLength2 >= deletion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 53,
+ "nodeLength": 38,
+ "src": "overlapLength2 >= insertion.length / 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4111": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 38,
+ "src": "text1Length === 0 || text2Length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 240,
+ "nodeLength": 17,
+ "src": "text1Length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 261,
+ "nodeLength": 17,
+ "src": "text2Length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4116": [
+ null,
+ {
+ "position": 347,
+ "nodeLength": 25,
+ "src": "text1Length > text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4118": [
+ null,
+ {
+ "position": 449,
+ "nodeLength": 25,
+ "src": "text1Length < text2Length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4124": [
+ null,
+ {
+ "position": 630,
+ "nodeLength": 15,
+ "src": "text1 === text2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4133": [
+ null,
+ {
+ "position": 897,
+ "nodeLength": 4,
+ "src": "true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4136": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 12,
+ "src": "found === -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4140": [
+ null,
+ {
+ "position": 173,
+ "nodeLength": 82,
+ "src": "found === 0 || text1.substring(textLength - length) === text2.substring(0, length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 173,
+ "nodeLength": 11,
+ "src": "found === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 188,
+ "nodeLength": 67,
+ "src": "text1.substring(textLength - length) === text2.substring(0, length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4187": [
+ null,
+ {
+ "position": 456,
+ "nodeLength": 25,
+ "src": "lineEnd < text.length - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4189": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 14,
+ "src": "lineEnd === -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4195": [
+ null,
+ {
+ "position": 213,
+ "nodeLength": 86,
+ "src": "lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : lineHash[line] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 213,
+ "nodeLength": 23,
+ "src": "lineHash.hasOwnProperty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 271,
+ "nodeLength": 28,
+ "src": "lineHash[line] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4224": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 16,
+ "src": "x < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4227": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 16,
+ "src": "y < chars.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4248": [
+ null,
+ {
+ "position": 294,
+ "nodeLength": 22,
+ "src": "pointer < diffs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4263": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 29,
+ "src": "countDelete + countInsert > 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4264": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 38,
+ "src": "countDelete !== 0 && countInsert !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 17,
+ "src": "countDelete !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 17,
+ "src": "countInsert !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4268": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 18,
+ "src": "commonlength !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4269": [
+ null,
+ {
+ "position": 15,
+ "nodeLength": 107,
+ "src": "pointer - countDelete - countInsert > 0 && diffs[pointer - countDelete - countInsert - 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15,
+ "nodeLength": 39,
+ "src": "pointer - countDelete - countInsert > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 58,
+ "nodeLength": 64,
+ "src": "diffs[pointer - countDelete - countInsert - 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4281": [
+ null,
+ {
+ "position": 776,
+ "nodeLength": 18,
+ "src": "commonlength !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4289": [
+ null,
+ {
+ "position": 1221,
+ "nodeLength": 17,
+ "src": "countDelete === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4291": [
+ null,
+ {
+ "position": 1362,
+ "nodeLength": 17,
+ "src": "countInsert === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4296": [
+ null,
+ {
+ "position": 1707,
+ "nodeLength": 11,
+ "src": "countDelete",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1731,
+ "nodeLength": 11,
+ "src": "countInsert",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4297": [
+ null,
+ {
+ "position": 1903,
+ "nodeLength": 53,
+ "src": "pointer !== 0 && diffs[pointer - 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1903,
+ "nodeLength": 13,
+ "src": "pointer !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1920,
+ "nodeLength": 36,
+ "src": "diffs[pointer - 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4312": [
+ null,
+ {
+ "position": 2872,
+ "nodeLength": 33,
+ "src": "diffs[diffs.length - 1][1] === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4323": [
+ null,
+ {
+ "position": 3291,
+ "nodeLength": 26,
+ "src": "pointer < diffs.length - 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4324": [
+ null,
+ {
+ "position": 10,
+ "nodeLength": 76,
+ "src": "diffs[pointer - 1][0] === DIFF_EQUAL && diffs[pointer + 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10,
+ "nodeLength": 36,
+ "src": "diffs[pointer - 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50,
+ "nodeLength": 36,
+ "src": "diffs[pointer + 1][0] === DIFF_EQUAL",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4330": [
+ null,
+ {
+ "position": 200,
+ "nodeLength": 34,
+ "src": "position === diffs[pointer - 1][1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4337": [
+ null,
+ {
+ "position": 588,
+ "nodeLength": 80,
+ "src": "diffPointer.substring(0, diffs[pointer + 1][1].length) === diffs[pointer + 1][1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4350": [
+ null,
+ {
+ "position": 4480,
+ "nodeLength": 7,
+ "src": "changes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/vendor/js/sinon/sinon.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 3,
+ 1,
+ 2,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 2,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 2,
+ 4,
+ 3,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 19,
+ 19,
+ 19,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 11,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 10,
+ 10,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 10,
+ 10,
+ null,
+ 10,
+ 12,
+ null,
+ 12,
+ 155,
+ 155,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 12,
+ 0,
+ null,
+ null,
+ null,
+ 10,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 20,
+ 0,
+ 20,
+ 0,
+ null,
+ 20,
+ 20,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 8,
+ 8,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 12,
+ 12,
+ 12,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 12,
+ 12,
+ 12,
+ null,
+ 0,
+ null,
+ 12,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 12,
+ 0,
+ null,
+ 12,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 8,
+ 8,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 6,
+ 0,
+ null,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ null,
+ 6,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 2,
+ 2,
+ 2,
+ 2,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 10,
+ 4,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 22,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 22,
+ 9,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 24,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 31,
+ null,
+ null,
+ null,
+ null,
+ 24,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 2,
+ null,
+ null,
+ 1,
+ null,
+ 2,
+ 2,
+ 2,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 1,
+ 0,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 19,
+ 18,
+ 18,
+ null,
+ null,
+ 19,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 0,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 0,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 0,
+ 0,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 0,
+ 1,
+ 1,
+ 3,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 2,
+ 0,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 19,
+ 19,
+ 0,
+ 1,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 11,
+ 1,
+ 1,
+ 10,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 20,
+ 0,
+ 1,
+ 1,
+ 8,
+ 0,
+ 0,
+ 0,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 6,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 2,
+ 1,
+ 0,
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 9,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 2,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "branchData": {
+ "38": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 42,
+ "src": "typeof define === 'function' && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 23,
+ "nodeLength": 28,
+ "src": "typeof define === 'function'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "42": [
+ null,
+ {
+ "position": 168,
+ "nodeLength": 27,
+ "src": "typeof exports === 'object'",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "52": [
+ null,
+ {
+ "position": 23,
+ "nodeLength": 15,
+ "src": "mod == \"samsam\"",
+ "evalFalse": 2,
+ "evalTrue": 1
+ }
+ ],
+ "54": [
+ null,
+ {
+ "position": 109,
+ "nodeLength": 46,
+ "src": "typeof deps === \"function\" && mod.length === 0",
+ "evalFalse": 1,
+ "evalTrue": 1
+ },
+ {
+ "position": 109,
+ "nodeLength": 26,
+ "src": "typeof deps === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 1
+ },
+ {
+ "position": 139,
+ "nodeLength": 16,
+ "src": "mod.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "56": [
+ null,
+ {
+ "position": 225,
+ "nodeLength": 24,
+ "src": "typeof fn === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "61": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 213,
+ "src": "(typeof define === \"function\" && define.amd && function(m) {\n define(\"samsam\", m);\n}) || (typeof module === \"object\" && function(m) {\n module.exports = m();\n}) || function(m) {\n this.samsam = m();\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": -1,
+ "nodeLength": 83,
+ "src": "typeof define === \"function\" && define.amd && function(m) {\n define(\"samsam\", m);\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": -1,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 31,
+ "nodeLength": 51,
+ "src": "define.amd && function(m) {\n define(\"samsam\", m);\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "62": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 123,
+ "src": "(typeof module === \"object\" && function(m) {\n module.exports = m();\n}) || function(m) {\n this.samsam = m();\n}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91,
+ "nodeLength": 74,
+ "src": "typeof module === \"object\" && function(m) {\n module.exports = m();\n}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 91,
+ "nodeLength": 26,
+ "src": "typeof module === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "67": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 64,
+ "src": "typeof document !== \"undefined\" && document.createElement(\"div\")",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 45,
+ "nodeLength": 31,
+ "src": "typeof document !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "74": [
+ null,
+ {
+ "position": 226,
+ "nodeLength": 42,
+ "src": "typeof value === \"number\" && value !== val",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 226,
+ "nodeLength": 25,
+ "src": "typeof value === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 255,
+ "nodeLength": 13,
+ "src": "value !== val",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "92": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 32,
+ "src": "getClass(object) === 'Arguments'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "93": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 111,
+ "src": "typeof object !== \"object\" || typeof object.length !== \"number\" || getClass(object) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 76,
+ "nodeLength": 26,
+ "src": "typeof object !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 106,
+ "nodeLength": 81,
+ "src": "typeof object.length !== \"number\" || getClass(object) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 106,
+ "nodeLength": 33,
+ "src": "typeof object.length !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "94": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 28,
+ "src": "getClass(object) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "97": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 34,
+ "src": "typeof object.callee == \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "117": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 40,
+ "src": "!object || object.nodeType !== 1 || !div",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 29,
+ "src": "object.nodeType !== 1 || !div",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 21,
+ "src": "object.nodeType !== 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "136": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 35,
+ "src": "o.hasOwnProperty.call(object, prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "151": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 84,
+ "src": "typeof value.getTime == \"function\" && value.getTime() == value.valueOf()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16,
+ "nodeLength": 34,
+ "src": "typeof value.getTime == \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "152": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 34,
+ "src": "value.getTime() == value.valueOf()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "162": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 38,
+ "src": "value === 0 && 1 / value === -Infinity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16,
+ "nodeLength": 11,
+ "src": "value === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31,
+ "nodeLength": 23,
+ "src": "1 / value === -Infinity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "177": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 45,
+ "src": "obj1 === obj2 || (isNaN(obj1) && isNaN(obj2))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 13,
+ "src": "obj1 === obj2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 31,
+ "nodeLength": 26,
+ "src": "isNaN(obj1) && isNaN(obj2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "178": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 49,
+ "src": "obj1 !== 0 || isNegZero(obj1) === isNegZero(obj2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 10,
+ "src": "obj1 !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 34,
+ "nodeLength": 35,
+ "src": "isNegZero(obj1) === isNegZero(obj2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "220": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 297,
+ "src": "typeof value === 'object' && value !== null && !(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 18,
+ "nodeLength": 25,
+ "src": "typeof value === 'object'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 268,
+ "src": "value !== null && !(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 14,
+ "src": "value !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "221": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 230,
+ "src": "!(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "222": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 179,
+ "src": "!(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "223": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 128,
+ "src": "!(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "224": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 77,
+ "src": "!(value instanceof RegExp) && !(value instanceof String)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "241": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 18,
+ "src": "i < objects.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "242": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 18,
+ "src": "objects[i] === obj",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "256": [
+ null,
+ {
+ "position": 138,
+ "nodeLength": 179,
+ "src": "obj1 === obj2 || isNaN(obj1) || isNaN(obj2) || obj1 == null || obj2 == null || type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 138,
+ "nodeLength": 13,
+ "src": "obj1 === obj2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "257": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 142,
+ "src": "isNaN(obj1) || isNaN(obj2) || obj1 == null || obj2 == null || type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192,
+ "nodeLength": 127,
+ "src": "isNaN(obj2) || obj1 == null || obj2 == null || type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "258": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 92,
+ "src": "obj1 == null || obj2 == null || type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 229,
+ "nodeLength": 12,
+ "src": "obj1 == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 15,
+ "nodeLength": 76,
+ "src": "obj2 == null || type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 246,
+ "nodeLength": 12,
+ "src": "obj2 == null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "259": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 40,
+ "src": "type1 !== \"object\" || type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 283,
+ "nodeLength": 18,
+ "src": "type1 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 18,
+ "src": "type2 !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "265": [
+ null,
+ {
+ "position": 469,
+ "nodeLength": 34,
+ "src": "isElement(obj1) || isElement(obj2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "268": [
+ null,
+ {
+ "position": 604,
+ "nodeLength": 18,
+ "src": "isDate1 || isDate2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "269": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 57,
+ "src": "!isDate1 || !isDate2 || obj1.getTime() !== obj2.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 45,
+ "src": "!isDate2 || obj1.getTime() !== obj2.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45,
+ "nodeLength": 33,
+ "src": "obj1.getTime() !== obj2.getTime()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "274": [
+ null,
+ {
+ "position": 790,
+ "nodeLength": 48,
+ "src": "obj1 instanceof RegExp && obj2 instanceof RegExp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "275": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 35,
+ "src": "obj1.toString() !== obj2.toString()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "283": [
+ null,
+ {
+ "position": 1103,
+ "nodeLength": 38,
+ "src": "isArguments(obj1) || isArguments(obj2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "284": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 27,
+ "src": "obj1.length !== obj2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "286": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 93,
+ "src": "type1 !== type2 || class1 !== class2 || keys1.length !== keys2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 15,
+ "src": "type1 !== type2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40,
+ "nodeLength": 74,
+ "src": "class1 !== class2 || keys1.length !== keys2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40,
+ "nodeLength": 17,
+ "src": "class1 !== class2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "287": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 29,
+ "src": "keys1.length !== keys2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "299": [
+ null,
+ {
+ "position": 1689,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "301": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 33,
+ "src": "!o.hasOwnProperty.call(obj2, key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "316": [
+ null,
+ {
+ "position": 572,
+ "nodeLength": 9,
+ "src": "isObject1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "317": [
+ null,
+ {
+ "position": 642,
+ "nodeLength": 9,
+ "src": "isObject2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "323": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 13,
+ "src": "index1 !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "326": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 13,
+ "src": "index2 !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "331": [
+ null,
+ {
+ "position": 1313,
+ "nodeLength": 29,
+ "src": "compared[newPath1 + newPath2]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "336": [
+ null,
+ {
+ "position": 1483,
+ "nodeLength": 26,
+ "src": "index1 === -1 && isObject1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1483,
+ "nodeLength": 13,
+ "src": "index1 === -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "340": [
+ null,
+ {
+ "position": 1637,
+ "nodeLength": 26,
+ "src": "index2 === -1 && isObject2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1637,
+ "nodeLength": 13,
+ "src": "index2 === -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "346": [
+ null,
+ {
+ "position": 1866,
+ "nodeLength": 22,
+ "src": "isObject1 && isObject2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "354": [
+ null,
+ {
+ "position": 2129,
+ "nodeLength": 46,
+ "src": "!deepEqual(value1, value2, newPath1, newPath2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "367": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 19,
+ "src": "subset.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "369": [
+ null,
+ {
+ "position": 113,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "370": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 26,
+ "src": "match(array[i], subset[0])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "371": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 5,
+ "src": "j < k",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "372": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 31,
+ "src": "!match(array[i + j], subset[j])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "388": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 45,
+ "src": "matcher && typeof matcher.test === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 34,
+ "src": "typeof matcher.test === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "392": [
+ null,
+ {
+ "position": 126,
+ "nodeLength": 29,
+ "src": "typeof matcher === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "393": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 24,
+ "src": "matcher(object) === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "396": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 27,
+ "src": "typeof matcher === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "398": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 38,
+ "src": "typeof object === \"string\" || !!object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 26,
+ "src": "typeof object === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "399": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 79,
+ "src": "notNull && (String(object)).toLowerCase().indexOf(matcher) >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "400": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 51,
+ "src": "(String(object)).toLowerCase().indexOf(matcher) >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "403": [
+ null,
+ {
+ "position": 492,
+ "nodeLength": 27,
+ "src": "typeof matcher === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "404": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 18,
+ "src": "matcher === object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "407": [
+ null,
+ {
+ "position": 585,
+ "nodeLength": 28,
+ "src": "typeof matcher === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "408": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 18,
+ "src": "matcher === object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "411": [
+ null,
+ {
+ "position": 679,
+ "nodeLength": 31,
+ "src": "typeof (matcher) === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "412": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 30,
+ "src": "typeof (object) === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "415": [
+ null,
+ {
+ "position": 788,
+ "nodeLength": 16,
+ "src": "matcher === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "416": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 15,
+ "src": "object === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "419": [
+ null,
+ {
+ "position": 867,
+ "nodeLength": 61,
+ "src": "getClass(object) === \"Array\" && getClass(matcher) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 867,
+ "nodeLength": 28,
+ "src": "getClass(object) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 899,
+ "nodeLength": 29,
+ "src": "getClass(matcher) === \"Array\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "423": [
+ null,
+ {
+ "position": 1006,
+ "nodeLength": 38,
+ "src": "matcher && typeof matcher === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1017,
+ "nodeLength": 27,
+ "src": "typeof matcher === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "424": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 18,
+ "src": "matcher === object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "430": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 97,
+ "src": "typeof value === \"undefined\" && typeof object.getAttribute === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63,
+ "nodeLength": 28,
+ "src": "typeof value === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "431": [
+ null,
+ {
+ "position": 55,
+ "nodeLength": 41,
+ "src": "typeof object.getAttribute === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "434": [
+ null,
+ {
+ "position": 257,
+ "nodeLength": 62,
+ "src": "matcher[prop] === null || typeof matcher[prop] === 'undefined'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 257,
+ "nodeLength": 22,
+ "src": "matcher[prop] === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 283,
+ "nodeLength": 36,
+ "src": "typeof matcher[prop] === 'undefined'",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "435": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 23,
+ "src": "value !== matcher[prop]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "438": [
+ null,
+ {
+ "position": 462,
+ "nodeLength": 61,
+ "src": "typeof value === \"undefined\" || !match(value, matcher[prop])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 462,
+ "nodeLength": 29,
+ "src": "typeof value === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "460": [
+ null,
+ {
+ "position": -1,
+ "nodeLength": 249,
+ "src": "(typeof define === \"function\" && define.amd && function(m) {\n define(\"formatio\", [\"samsam\"], m);\n}) || (typeof module === \"object\" && function(m) {\n module.exports = m(require(\"samsam\"));\n}) || function(m) {\n this.formatio = m(this.samsam);\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": -1,
+ "nodeLength": 101,
+ "src": "typeof define === \"function\" && define.amd && function(m) {\n define(\"formatio\", [\"samsam\"], m);\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": -1,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 31,
+ "nodeLength": 69,
+ "src": "define.amd && function(m) {\n define(\"formatio\", [\"samsam\"], m);\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "462": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 142,
+ "src": "(typeof module === \"object\" && function(m) {\n module.exports = m(require(\"samsam\"));\n}) || function(m) {\n this.formatio = m(this.samsam);\n}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 108,
+ "nodeLength": 89,
+ "src": "typeof module === \"object\" && function(m) {\n module.exports = m(require(\"samsam\"));\n}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 108,
+ "nodeLength": 26,
+ "src": "typeof module === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "476": [
+ null,
+ {
+ "position": 229,
+ "nodeLength": 29,
+ "src": "typeof global !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "479": [
+ null,
+ {
+ "position": 351,
+ "nodeLength": 31,
+ "src": "typeof document !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "485": [
+ null,
+ {
+ "position": 515,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "490": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 5,
+ "src": "!func",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "491": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 16,
+ "src": "func.displayName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "492": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 9,
+ "src": "func.name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "494": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 28,
+ "src": "(matches && matches[1]) || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 224,
+ "nodeLength": 21,
+ "src": "matches && matches[1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "498": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 28,
+ "src": "object && object.constructor",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "499": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 75,
+ "src": "f.excludeConstructors || formatio.excludeConstructors || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "500": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 34,
+ "src": "formatio.excludeConstructors || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "503": [
+ null,
+ {
+ "position": 224,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "504": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 55,
+ "src": "typeof excludes[i] === \"string\" && excludes[i] === name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17,
+ "nodeLength": 31,
+ "src": "typeof excludes[i] === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 20,
+ "src": "excludes[i] === name",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "506": [
+ null,
+ {
+ "position": 127,
+ "nodeLength": 42,
+ "src": "excludes[i].test && excludes[i].test(name)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "515": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 26,
+ "src": "typeof object !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "517": [
+ null,
+ {
+ "position": 117,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "518": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 21,
+ "src": "objects[i] === object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "524": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 26,
+ "src": "typeof object === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "526": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 29,
+ "src": "typeof qs !== \"boolean\" || qs",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 62,
+ "nodeLength": 23,
+ "src": "typeof qs !== \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "527": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 18,
+ "src": "processed || quote",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "530": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 59,
+ "src": "typeof object === \"function\" && !(object instanceof RegExp)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 227,
+ "nodeLength": 28,
+ "src": "typeof object === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "534": [
+ null,
+ {
+ "position": 360,
+ "nodeLength": 15,
+ "src": "processed || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "536": [
+ null,
+ {
+ "position": 390,
+ "nodeLength": 29,
+ "src": "isCircular(object, processed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "538": [
+ null,
+ {
+ "position": 459,
+ "nodeLength": 59,
+ "src": "Object.prototype.toString.call(object) === \"[object Array]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "542": [
+ null,
+ {
+ "position": 604,
+ "nodeLength": 7,
+ "src": "!object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 630,
+ "nodeLength": 23,
+ "src": "(1 / object) === -Infinity",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "543": [
+ null,
+ {
+ "position": 686,
+ "nodeLength": 24,
+ "src": "samsam.isElement(object)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "545": [
+ null,
+ {
+ "position": 759,
+ "nodeLength": 102,
+ "src": "typeof object.toString === \"function\" && object.toString !== Object.prototype.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 759,
+ "nodeLength": 37,
+ "src": "typeof object.toString === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "546": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 45,
+ "src": "object.toString !== Object.prototype.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "551": [
+ null,
+ {
+ "position": 979,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "552": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 35,
+ "src": "object === specialObjects[i].object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "565": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 15,
+ "src": "processed || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "569": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 28,
+ "src": "(this.limitChildrenCount > 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125,
+ "nodeLength": 27,
+ "src": "this.limitChildrenCount > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "572": [
+ null,
+ {
+ "position": 254,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "576": [
+ null,
+ {
+ "position": 349,
+ "nodeLength": 16,
+ "src": "l < array.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "583": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 15,
+ "src": "processed || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "585": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 11,
+ "src": "indent || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "589": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 28,
+ "src": "(this.limitChildrenCount > 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 240,
+ "nodeLength": 27,
+ "src": "this.limitChildrenCount > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "592": [
+ null,
+ {
+ "position": 379,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "596": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 26,
+ "src": "isCircular(obj, processed)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "602": [
+ null,
+ {
+ "position": 268,
+ "nodeLength": 15,
+ "src": "/\\s/.test(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "608": [
+ null,
+ {
+ "position": 863,
+ "nodeLength": 4,
+ "src": "cons",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "610": [
+ null,
+ {
+ "position": 947,
+ "nodeLength": 5,
+ "src": "i < k",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "612": [
+ null,
+ {
+ "position": 986,
+ "nodeLength": 21,
+ "src": "l < properties.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "615": [
+ null,
+ {
+ "position": 1102,
+ "nodeLength": 20,
+ "src": "length + indent > 80",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "626": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "630": [
+ null,
+ {
+ "position": 158,
+ "nodeLength": 51,
+ "src": "attrName !== \"contenteditable\" || val !== \"inherit\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 158,
+ "nodeLength": 30,
+ "src": "attrName !== \"contenteditable\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192,
+ "nodeLength": 17,
+ "src": "val !== \"inherit\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "631": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 5,
+ "src": "!!val",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "635": [
+ null,
+ {
+ "position": 538,
+ "nodeLength": 16,
+ "src": "pairs.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "638": [
+ null,
+ {
+ "position": 622,
+ "nodeLength": 19,
+ "src": "content.length > 20",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "672": [
+ null,
+ {
+ "position": 20049,
+ "nodeLength": 52,
+ "src": "\"object\" == typeof exports && \"undefined\" != typeof module",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 20049,
+ "nodeLength": 24,
+ "src": "\"object\" == typeof exports",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 20075,
+ "nodeLength": 26,
+ "src": "\"undefined\" != typeof module",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20129,
+ "nodeLength": 37,
+ "src": "\"function\" == typeof define && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 20129,
+ "nodeLength": 25,
+ "src": "\"function\" == typeof define",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 20191,
+ "nodeLength": 26,
+ "src": "\"undefined\" != typeof window",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20227,
+ "nodeLength": 26,
+ "src": "\"undefined\" != typeof global",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20263,
+ "nodeLength": 34,
+ "src": "\"undefined\" != typeof self && (f = self)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20263,
+ "nodeLength": 24,
+ "src": "\"undefined\" != typeof self",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 5,
+ "src": "!n[o]",
+ "evalFalse": 1,
+ "evalTrue": 1
+ },
+ {
+ "position": 46,
+ "nodeLength": 5,
+ "src": "!t[o]",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 59,
+ "nodeLength": 35,
+ "src": "typeof require == \"function\" && require",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 59,
+ "nodeLength": 26,
+ "src": "typeof require == \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 98,
+ "nodeLength": 5,
+ "src": "!u && a",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 122,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 304,
+ "nodeLength": 1,
+ "src": "n",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 359,
+ "nodeLength": 35,
+ "src": "typeof require == \"function\" && require",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 359,
+ "nodeLength": 26,
+ "src": "typeof require == \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 407,
+ "nodeLength": 10,
+ "src": "o < r.length",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "697": [
+ null,
+ {
+ "position": 498,
+ "nodeLength": 24,
+ "src": "'setImmediate' in global",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "708": [
+ null,
+ {
+ "position": 932,
+ "nodeLength": 33,
+ "src": "typeof timeoutResult === \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "720": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 4,
+ "src": "!str",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "728": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 41,
+ "src": "l > 3 || !/^(\\d\\d:){0,2}\\d\\d?$/.test(str)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 172,
+ "nodeLength": 5,
+ "src": "l > 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "732": [
+ null,
+ {
+ "position": 317,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "735": [
+ null,
+ {
+ "position": 65,
+ "nodeLength": 12,
+ "src": "parsed >= 60",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "749": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 6,
+ "src": "!epoch",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "750": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 35,
+ "src": "typeof epoch.getTime === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "751": [
+ null,
+ {
+ "position": 124,
+ "nodeLength": 25,
+ "src": "typeof epoch === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "756": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 51,
+ "src": "timer && timer.callAt >= from && timer.callAt <= to",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 42,
+ "src": "timer.callAt >= from && timer.callAt <= to",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 20,
+ "src": "timer.callAt >= from",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49,
+ "nodeLength": 18,
+ "src": "timer.callAt <= to",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "762": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 27,
+ "src": "source.hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "768": [
+ null,
+ {
+ "position": 221,
+ "nodeLength": 10,
+ "src": "source.now",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "777": [
+ null,
+ {
+ "position": 451,
+ "nodeLength": 15,
+ "src": "source.toSource",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "826": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 24,
+ "src": "timer.func === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "830": [
+ null,
+ {
+ "position": 137,
+ "nodeLength": 13,
+ "src": "!clock.timers",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "836": [
+ null,
+ {
+ "position": 305,
+ "nodeLength": 41,
+ "src": "timer.delay || (clock.duringTick ? 1 : 0)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 321,
+ "nodeLength": 16,
+ "src": "clock.duringTick",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "840": [
+ null,
+ {
+ "position": 403,
+ "nodeLength": 21,
+ "src": "addTimerReturnsObject",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "854": [
+ null,
+ {
+ "position": 54,
+ "nodeLength": 19,
+ "src": "a.callAt < b.callAt",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "857": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 19,
+ "src": "a.callAt > b.callAt",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "862": [
+ null,
+ {
+ "position": 258,
+ "nodeLength": 27,
+ "src": "a.immediate && !b.immediate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "865": [
+ null,
+ {
+ "position": 334,
+ "nodeLength": 27,
+ "src": "!a.immediate && b.immediate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "870": [
+ null,
+ {
+ "position": 488,
+ "nodeLength": 25,
+ "src": "a.createdAt < b.createdAt",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "873": [
+ null,
+ {
+ "position": 562,
+ "nodeLength": 25,
+ "src": "a.createdAt > b.createdAt",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "878": [
+ null,
+ {
+ "position": 696,
+ "nodeLength": 11,
+ "src": "a.id < b.id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "881": [
+ null,
+ {
+ "position": 756,
+ "nodeLength": 11,
+ "src": "a.id > b.id",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "895": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 25,
+ "src": "timers.hasOwnProperty(id)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "898": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 63,
+ "src": "isInRange && (!timer || compareTimers(timer, timers[id]) === 1)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 95,
+ "nodeLength": 48,
+ "src": "!timer || compareTimers(timer, timers[id]) === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 105,
+ "nodeLength": 38,
+ "src": "compareTimers(timer, timers[id]) === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "910": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 34,
+ "src": "typeof timer.interval === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "917": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 32,
+ "src": "typeof timer.func === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "926": [
+ null,
+ {
+ "position": 466,
+ "nodeLength": 23,
+ "src": "!clock.timers[timer.id]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "927": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 9,
+ "src": "exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "933": [
+ null,
+ {
+ "position": 612,
+ "nodeLength": 9,
+ "src": "exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "939": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 15,
+ "src": "timer.immediate",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "941": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 37,
+ "src": "typeof timer.interval !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "949": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 8,
+ "src": "!timerId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "955": [
+ null,
+ {
+ "position": 216,
+ "nodeLength": 13,
+ "src": "!clock.timers",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "961": [
+ null,
+ {
+ "position": 403,
+ "nodeLength": 27,
+ "src": "typeof timerId === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "965": [
+ null,
+ {
+ "position": 491,
+ "nodeLength": 36,
+ "src": "clock.timers.hasOwnProperty(timerId)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "968": [
+ null,
+ {
+ "position": 133,
+ "nodeLength": 26,
+ "src": "timerType(timer) === ttype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "981": [
+ null,
+ {
+ "position": 98,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "984": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 29,
+ "src": "target[method].hadOwnProperty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1003": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 17,
+ "src": "method === \"Date\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1012": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 34,
+ "src": "clock[method].hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1031": [
+ null,
+ {
+ "position": 9971,
+ "nodeLength": 220,
+ "src": "Object.keys || function(obj) {\n var ks = [], key;\n for (key in obj) {\n if (obj.hasOwnProperty(key)) {\n ks.push(key);\n }\n }\n return ks;\n}",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1036": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 23,
+ "src": "obj.hasOwnProperty(key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1093": [
+ null,
+ {
+ "position": 18,
+ "nodeLength": 22,
+ "src": "typeof ms === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1101": [
+ null,
+ {
+ "position": 330,
+ "nodeLength": 27,
+ "src": "timer && tickFrom <= tickTo",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 339,
+ "nodeLength": 18,
+ "src": "tickFrom <= tickTo",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1102": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 22,
+ "src": "clock.timers[timer.id]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1108": [
+ null,
+ {
+ "position": 211,
+ "nodeLength": 20,
+ "src": "oldNow !== clock.now",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1114": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 19,
+ "src": "firstException || e",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1125": [
+ null,
+ {
+ "position": 1272,
+ "nodeLength": 14,
+ "src": "firstException",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1146": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 31,
+ "src": "clock.timers.hasOwnProperty(id)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1162": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 26,
+ "src": "typeof target === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1168": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 7,
+ "src": "!target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1178": [
+ null,
+ {
+ "position": 380,
+ "nodeLength": 12,
+ "src": "toFake || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1180": [
+ null,
+ {
+ "position": 407,
+ "nodeLength": 26,
+ "src": "clock.methods.length === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1184": [
+ null,
+ {
+ "position": 536,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1191": [
+ null,
+ {
+ "position": 14531,
+ "nodeLength": 14,
+ "src": "global || this",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1193": [
+ null,
+ {
+ "position": 14755,
+ "nodeLength": 29,
+ "src": "typeof global !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14796,
+ "nodeLength": 27,
+ "src": "typeof self !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 14833,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1211": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 97,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 130,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 148,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1212": [
+ null,
+ {
+ "position": 195,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 195,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 227,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 227,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1235": [
+ null,
+ {
+ "position": 1018,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1237": [
+ null,
+ {
+ "position": 1077,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1260": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 64,
+ "src": "typeof document !== \"undefined\" && document.createElement(\"div\")",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 20,
+ "nodeLength": 31,
+ "src": "typeof document !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1268": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 22,
+ "src": "div.parentNode === obj",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1283": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 50,
+ "src": "div && obj && obj.nodeType === 1 && isDOMNode(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 23,
+ "nodeLength": 43,
+ "src": "obj && obj.nodeType === 1 && isDOMNode(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 36,
+ "src": "obj.nodeType === 1 && isDOMNode(obj)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 18,
+ "src": "obj.nodeType === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1287": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 80,
+ "src": "typeof obj === \"function\" || !!(obj && obj.constructor && obj.call && obj.apply)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 16,
+ "nodeLength": 25,
+ "src": "typeof obj === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 48,
+ "nodeLength": 47,
+ "src": "obj && obj.constructor && obj.call && obj.apply",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 55,
+ "nodeLength": 40,
+ "src": "obj.constructor && obj.call && obj.apply",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 21,
+ "src": "obj.call && obj.apply",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1291": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 37,
+ "src": "typeof val === \"number\" && isNaN(val)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16,
+ "nodeLength": 23,
+ "src": "typeof val === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1296": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 26,
+ "src": "!hasOwn.call(target, prop)",
+ "evalFalse": 1,
+ "evalTrue": 3
+ }
+ ],
+ "1303": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 83,
+ "src": "typeof obj === \"function\" && typeof obj.restore === \"function\" && obj.restore.sinon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 16,
+ "nodeLength": 25,
+ "src": "typeof obj === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45,
+ "nodeLength": 54,
+ "src": "typeof obj.restore === \"function\" && obj.restore.sinon",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 45,
+ "nodeLength": 33,
+ "src": "typeof obj.restore === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1311": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 7,
+ "src": "!object",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1315": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 58,
+ "src": "typeof method !== \"function\" && typeof method !== \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 130,
+ "nodeLength": 28,
+ "src": "typeof method !== \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 162,
+ "nodeLength": 26,
+ "src": "typeof method !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1322": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 26,
+ "src": "!isFunction(wrappedMethod)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1325": [
+ null,
+ {
+ "position": 280,
+ "nodeLength": 52,
+ "src": "wrappedMethod.restore && wrappedMethod.restore.sinon",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1327": [
+ null,
+ {
+ "position": 470,
+ "nodeLength": 26,
+ "src": "wrappedMethod.calledBefore",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1328": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 21,
+ "src": "wrappedMethod.returns",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1332": [
+ null,
+ {
+ "position": 722,
+ "nodeLength": 5,
+ "src": "error",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1333": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 41,
+ "src": "wrappedMethod && wrappedMethod.stackTrace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1351": [
+ null,
+ {
+ "position": 1829,
+ "nodeLength": 21,
+ "src": "object.hasOwnProperty",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1353": [
+ null,
+ {
+ "position": 1935,
+ "nodeLength": 13,
+ "src": "hasES5Support",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1354": [
+ null,
+ {
+ "position": 35,
+ "nodeLength": 29,
+ "src": "(typeof method === \"function\")",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 35,
+ "nodeLength": 28,
+ "src": "typeof method === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1357": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 18,
+ "src": "!wrappedMethodDesc",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1360": [
+ null,
+ {
+ "position": 424,
+ "nodeLength": 60,
+ "src": "wrappedMethodDesc.restore && wrappedMethodDesc.restore.sinon",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1363": [
+ null,
+ {
+ "position": 631,
+ "nodeLength": 5,
+ "src": "error",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1364": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 49,
+ "src": "wrappedMethodDesc && wrappedMethodDesc.stackTrace",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1371": [
+ null,
+ {
+ "position": 969,
+ "nodeLength": 16,
+ "src": "i < types.length",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "1377": [
+ null,
+ {
+ "position": 1226,
+ "nodeLength": 16,
+ "src": "i < types.length",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "1384": [
+ null,
+ {
+ "position": 1566,
+ "nodeLength": 59,
+ "src": "typeof method === \"function\" && object[property] !== method",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1566,
+ "nodeLength": 28,
+ "src": "typeof method === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1598,
+ "nodeLength": 27,
+ "src": "object[property] !== method",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1404": [
+ null,
+ {
+ "position": 225,
+ "nodeLength": 6,
+ "src": "!owned",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1411": [
+ null,
+ {
+ "position": 615,
+ "nodeLength": 13,
+ "src": "hasES5Support",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1417": [
+ null,
+ {
+ "position": 879,
+ "nodeLength": 27,
+ "src": "object[property] === method",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1424": [
+ null,
+ {
+ "position": 5300,
+ "nodeLength": 14,
+ "src": "!hasES5Support",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1438": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 39,
+ "src": "sinon.match && sinon.match.isMatcher(a)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1442": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 46,
+ "src": "typeof a !== \"object\" || typeof b !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 125,
+ "nodeLength": 21,
+ "src": "typeof a !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 150,
+ "nodeLength": 21,
+ "src": "typeof b !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1443": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 43,
+ "src": "isReallyNaN(a) && isReallyNaN(b) || a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 32,
+ "src": "isReallyNaN(a) && isReallyNaN(b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 60,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1446": [
+ null,
+ {
+ "position": 274,
+ "nodeLength": 28,
+ "src": "isElement(a) || isElement(b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1447": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1450": [
+ null,
+ {
+ "position": 369,
+ "nodeLength": 7,
+ "src": "a === b",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1454": [
+ null,
+ {
+ "position": 441,
+ "nodeLength": 55,
+ "src": "(a === null && b !== null) || (a !== null && b === null)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 441,
+ "nodeLength": 24,
+ "src": "a === null && b !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 441,
+ "nodeLength": 10,
+ "src": "a === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 455,
+ "nodeLength": 10,
+ "src": "b !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 471,
+ "nodeLength": 24,
+ "src": "a !== null && b === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 471,
+ "nodeLength": 10,
+ "src": "a !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 485,
+ "nodeLength": 10,
+ "src": "b === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1458": [
+ null,
+ {
+ "position": 561,
+ "nodeLength": 42,
+ "src": "a instanceof RegExp && b instanceof RegExp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1459": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 137,
+ "src": "(a.source === b.source) && (a.global === b.global) && (a.ignoreCase === b.ignoreCase) && (a.multiline === b.multiline)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 21,
+ "src": "a.source === b.source",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 110,
+ "src": "(a.global === b.global) && (a.ignoreCase === b.ignoreCase) && (a.multiline === b.multiline)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 52,
+ "nodeLength": 21,
+ "src": "a.global === b.global",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1460": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 63,
+ "src": "(a.ignoreCase === b.ignoreCase) && (a.multiline === b.multiline)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 102,
+ "nodeLength": 29,
+ "src": "a.ignoreCase === b.ignoreCase",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 137,
+ "nodeLength": 27,
+ "src": "a.multiline === b.multiline",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1464": [
+ null,
+ {
+ "position": 862,
+ "nodeLength": 45,
+ "src": "aString !== Object.prototype.toString.call(b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1468": [
+ null,
+ {
+ "position": 972,
+ "nodeLength": 27,
+ "src": "aString === \"[object Date]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1469": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 27,
+ "src": "a.valueOf() === b.valueOf()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1476": [
+ null,
+ {
+ "position": 1167,
+ "nodeLength": 53,
+ "src": "aString === \"[object Array]\" && a.length !== b.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1167,
+ "nodeLength": 28,
+ "src": "aString === \"[object Array]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1199,
+ "nodeLength": 21,
+ "src": "a.length !== b.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1481": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 20,
+ "src": "hasOwn.call(a, prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1484": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 12,
+ "src": "!(prop in b)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1488": [
+ null,
+ {
+ "position": 161,
+ "nodeLength": 28,
+ "src": "!deepEqual(a[prop], b[prop])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1495": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 20,
+ "src": "hasOwn.call(b, prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1500": [
+ null,
+ {
+ "position": 1788,
+ "nodeLength": 19,
+ "src": "aLength === bLength",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1504": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 29,
+ "src": "func.displayName || func.name",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1510": [
+ null,
+ {
+ "position": 359,
+ "nodeLength": 5,
+ "src": "!name",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1512": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 21,
+ "src": "matches && matches[1]",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1519": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 30,
+ "src": "this.getCall && this.callCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1524": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 3,
+ "src": "i--",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1528": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 24,
+ "src": "thisValue[prop] === this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1535": [
+ null,
+ {
+ "position": 482,
+ "nodeLength": 32,
+ "src": "this.displayName || \"sinon fake\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1539": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 19,
+ "src": "obj !== Object(obj)",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1546": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 21,
+ "src": "hasOwn.call(obj, key)",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1558": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 73,
+ "src": "proto && !(descriptor = Object.getOwnPropertyDescriptor(proto, property))",
+ "evalFalse": 1,
+ "evalTrue": 1
+ }
+ ],
+ "1566": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 12,
+ "src": "custom || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1570": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 29,
+ "src": "defaults.hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1571": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 27,
+ "src": "custom.hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1587": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 149,
+ "src": "count === 1 && \"once\" || count === 2 && \"twice\" || count === 3 && \"thrice\" || (count || 0) + \" times\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 21,
+ "src": "count === 1 && \"once\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 11,
+ "src": "count === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1588": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 108,
+ "src": "count === 2 && \"twice\" || count === 3 && \"thrice\" || (count || 0) + \" times\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 63,
+ "nodeLength": 22,
+ "src": "count === 2 && \"twice\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 64,
+ "nodeLength": 11,
+ "src": "count === 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1589": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 66,
+ "src": "count === 3 && \"thrice\" || (count || 0) + \" times\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 106,
+ "nodeLength": 23,
+ "src": "count === 3 && \"thrice\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 107,
+ "nodeLength": 11,
+ "src": "count === 3",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1590": [
+ null,
+ {
+ "position": 43,
+ "nodeLength": 10,
+ "src": "count || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1594": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1595": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 56,
+ "src": "!spies[i - 1].calledBefore(spies[i]) || !spies[i].called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1608": [
+ null,
+ {
+ "position": 156,
+ "nodeLength": 27,
+ "src": "aCall && aCall.callId || -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 156,
+ "nodeLength": 21,
+ "src": "aCall && aCall.callId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1609": [
+ null,
+ {
+ "position": 211,
+ "nodeLength": 27,
+ "src": "bCall && bCall.callId || -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 211,
+ "nodeLength": 21,
+ "src": "bCall && bCall.callId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1611": [
+ null,
+ {
+ "position": 264,
+ "nodeLength": 9,
+ "src": "aId < bId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1616": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 33,
+ "src": "typeof constructor !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1623": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 45,
+ "src": "object !== null && typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17,
+ "nodeLength": 15,
+ "src": "object !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 26,
+ "src": "typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1625": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 26,
+ "src": "isRestorable(object[prop])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1629": [
+ null,
+ {
+ "position": 275,
+ "nodeLength": 20,
+ "src": "isRestorable(object)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1637": [
+ null,
+ {
+ "position": 13204,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 13204,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 13237,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13255,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1638": [
+ null,
+ {
+ "position": 13302,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 13302,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 13334,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13334,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1644": [
+ null,
+ {
+ "position": 13472,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1649": [
+ null,
+ {
+ "position": 13546,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1654": [
+ null,
+ {
+ "position": 13646,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1658": [
+ null,
+ {
+ "position": 13727,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 13728,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1705": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 24,
+ "src": "obj.hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 10
+ }
+ ],
+ "1709": [
+ null,
+ {
+ "position": 1188,
+ "nodeLength": 32,
+ "src": "result.join(\"\") !== \"0123456789\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1724": [
+ null,
+ {
+ "position": 127,
+ "nodeLength": 18,
+ "src": "i < sources.length",
+ "evalFalse": 10,
+ "evalTrue": 12
+ }
+ ],
+ "1728": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 27,
+ "src": "source.hasOwnProperty(prop)",
+ "evalFalse": 0,
+ "evalTrue": 155
+ }
+ ],
+ "1735": [
+ null,
+ {
+ "position": 457,
+ "nodeLength": 90,
+ "src": "hasDontEnumBug && source.hasOwnProperty(\"toString\") && source.toString !== target.toString",
+ "evalFalse": 12,
+ "evalTrue": 0
+ },
+ {
+ "position": 475,
+ "nodeLength": 72,
+ "src": "source.hasOwnProperty(\"toString\") && source.toString !== target.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 512,
+ "nodeLength": 35,
+ "src": "source.toString !== target.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1752": [
+ null,
+ {
+ "position": 2884,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2884,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2917,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2935,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1753": [
+ null,
+ {
+ "position": 2982,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2982,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 3014,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3014,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1755": [
+ null,
+ {
+ "position": 3069,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1760": [
+ null,
+ {
+ "position": 3143,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1765": [
+ null,
+ {
+ "position": 3243,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1769": [
+ null,
+ {
+ "position": 3324,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 3325,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1788": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 10,
+ "src": "count || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1801": [
+ null,
+ {
+ "position": 637,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 637,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 670,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 688,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1802": [
+ null,
+ {
+ "position": 735,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 735,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 767,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 767,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1804": [
+ null,
+ {
+ "position": 822,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1809": [
+ null,
+ {
+ "position": 896,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1814": [
+ null,
+ {
+ "position": 996,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1818": [
+ null,
+ {
+ "position": 1077,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1078,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1836": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 14,
+ "src": "value === null",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "1838": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 19,
+ "src": "value === undefined",
+ "evalFalse": 20,
+ "evalTrue": 0
+ }
+ ],
+ "1854": [
+ null,
+ {
+ "position": 610,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 610,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 643,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 661,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1855": [
+ null,
+ {
+ "position": 708,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 708,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 740,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 740,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1857": [
+ null,
+ {
+ "position": 795,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1862": [
+ null,
+ {
+ "position": 869,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "1867": [
+ null,
+ {
+ "position": 969,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1871": [
+ null,
+ {
+ "position": 1050,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 1051,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "1893": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 15,
+ "src": "actual !== type",
+ "evalFalse": 8,
+ "evalTrue": 0
+ }
+ ],
+ "1910": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 39,
+ "src": "actual === null || actual === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 17,
+ "nodeLength": 15,
+ "src": "actual === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 20,
+ "src": "actual === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1914": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 31,
+ "src": "expectation.hasOwnProperty(key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1917": [
+ null,
+ {
+ "position": 116,
+ "nodeLength": 14,
+ "src": "isMatcher(exp)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1918": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 14,
+ "src": "!exp.test(act)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1921": [
+ null,
+ {
+ "position": 280,
+ "nodeLength": 30,
+ "src": "sinon.typeOf(exp) === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1922": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 22,
+ "src": "!matchObject(exp, act)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1925": [
+ null,
+ {
+ "position": 468,
+ "nodeLength": 26,
+ "src": "!sinon.deepEqual(exp, act)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1938": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 38,
+ "src": "typeof expectation.test === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1940": [
+ null,
+ {
+ "position": 32,
+ "nodeLength": 33,
+ "src": "expectation.test(actual) === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1947": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 31,
+ "src": "expectation.hasOwnProperty(key)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1959": [
+ null,
+ {
+ "position": 78,
+ "nodeLength": 21,
+ "src": "expectation == actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1964": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 26,
+ "src": "typeof actual !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1967": [
+ null,
+ {
+ "position": 142,
+ "nodeLength": 34,
+ "src": "actual.indexOf(expectation) !== -1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1973": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 26,
+ "src": "typeof actual !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "1981": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 7,
+ "src": "message",
+ "evalFalse": 0,
+ "evalTrue": 12
+ }
+ ],
+ "1992": [
+ null,
+ {
+ "position": 2279,
+ "nodeLength": 10,
+ "src": "!m.message",
+ "evalFalse": 12,
+ "evalTrue": 0
+ }
+ ],
+ "1999": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 17,
+ "src": "!arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2001": [
+ null,
+ {
+ "position": 119,
+ "nodeLength": 14,
+ "src": "!isMatcher(m2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2007": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 34,
+ "src": "m1.test(actual) || m2.test(actual)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2014": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 17,
+ "src": "!arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2016": [
+ null,
+ {
+ "position": 119,
+ "nodeLength": 14,
+ "src": "!isMatcher(m2)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2022": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 34,
+ "src": "m1.test(actual) && m2.test(actual)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2035": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 39,
+ "src": "actual !== null && actual !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 20,
+ "nodeLength": 15,
+ "src": "actual !== null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39,
+ "nodeLength": 20,
+ "src": "actual !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2048": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 22,
+ "src": "expectation === actual",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2055": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 29,
+ "src": "sinon.typeOf(actual) === type",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2069": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 22,
+ "src": "arguments.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2071": [
+ null,
+ {
+ "position": 211,
+ "nodeLength": 13,
+ "src": "!onlyProperty",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2076": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 102,
+ "src": "actual === undefined || actual === null || !propertyTest(actual, property)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 20,
+ "src": "actual === undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49,
+ "nodeLength": 78,
+ "src": "actual === null || !propertyTest(actual, property)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49,
+ "nodeLength": 15,
+ "src": "actual === null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2080": [
+ null,
+ {
+ "position": 218,
+ "nodeLength": 56,
+ "src": "onlyProperty || sinon.deepEqual(value, actual[property])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2086": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 26,
+ "src": "typeof actual === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2089": [
+ null,
+ {
+ "position": 123,
+ "nodeLength": 30,
+ "src": "actual[property] !== undefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2109": [
+ null,
+ {
+ "position": 7694,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7694,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7727,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7745,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2110": [
+ null,
+ {
+ "position": 7792,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7792,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7824,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7824,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2118": [
+ null,
+ {
+ "position": 8058,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2123": [
+ null,
+ {
+ "position": 8132,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2128": [
+ null,
+ {
+ "position": 8232,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2132": [
+ null,
+ {
+ "position": 8313,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 8314,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2174": [
+ null,
+ {
+ "position": 50,
+ "nodeLength": 65,
+ "src": "typeof v === \"object\" && v.toString === Object.prototype.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 50,
+ "nodeLength": 21,
+ "src": "typeof v === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 75,
+ "nodeLength": 40,
+ "src": "v.toString === Object.prototype.toString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2175": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 26,
+ "src": "isObjectWithNativeToString",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2178": [
+ null,
+ {
+ "position": 455,
+ "nodeLength": 4,
+ "src": "util",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2181": [
+ null,
+ {
+ "position": 990,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 990,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1023,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1041,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2184": [
+ null,
+ {
+ "position": 1108,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2191": [
+ null,
+ {
+ "position": 1278,
+ "nodeLength": 8,
+ "src": "formatio",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2193": [
+ null,
+ {
+ "position": 1358,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2208": [
+ null,
+ {
+ "position": 1751,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1751,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1784,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1802,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2209": [
+ null,
+ {
+ "position": 1849,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1849,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1881,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1881,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2211": [
+ null,
+ {
+ "position": 1936,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2216": [
+ null,
+ {
+ "position": 2010,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2221": [
+ null,
+ {
+ "position": 2110,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2225": [
+ null,
+ {
+ "position": 2201,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2202,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2226": [
+ null,
+ {
+ "position": 2273,
+ "nodeLength": 40,
+ "src": "typeof formatio === \"object\" && formatio",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2274,
+ "nodeLength": 28,
+ "src": "typeof formatio === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2251": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 11,
+ "src": "args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2259": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 47,
+ "src": "sinon.match && sinon.match.isMatcher(thisValue)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2262": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 28,
+ "src": "this.thisValue === thisValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2267": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 20,
+ "src": "l > this.args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2270": [
+ null,
+ {
+ "position": 171,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2271": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 44,
+ "src": "!sinon.deepEqual(arguments[i], this.args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2281": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 20,
+ "src": "l > this.args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2284": [
+ null,
+ {
+ "position": 171,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2287": [
+ null,
+ {
+ "position": 124,
+ "nodeLength": 54,
+ "src": "!sinon.match || !sinon.match(expectation).test(actual)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2295": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 99,
+ "src": "arguments.length === this.args.length && this.calledWith.apply(this, arguments)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24,
+ "nodeLength": 37,
+ "src": "arguments.length === this.args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2312": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 47,
+ "src": "typeof error === \"undefined\" || !this.exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 28,
+ "src": "typeof error === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2316": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 57,
+ "src": "this.exception === error || this.exception.name === error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 159,
+ "nodeLength": 24,
+ "src": "this.exception === error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 187,
+ "nodeLength": 29,
+ "src": "this.exception.name === error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2320": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 60,
+ "src": "this.proxy.prototype && this.thisValue instanceof this.proxy",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2324": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 26,
+ "src": "this.callId < other.callId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2328": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 26,
+ "src": "this.callId > other.callId",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2354": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2355": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 29,
+ "src": "typeof args[i] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2369": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2370": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 46,
+ "src": "args[i] && typeof args[i][prop] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 35,
+ "src": "typeof args[i][prop] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2381": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 45,
+ "src": "this.stack && this.stack.split(\"\\n\").slice(3)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2385": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 10,
+ "src": "this.proxy",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2388": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 10,
+ "src": "!this.args",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2392": [
+ null,
+ {
+ "position": 250,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2398": [
+ null,
+ {
+ "position": 422,
+ "nodeLength": 39,
+ "src": "typeof this.returnValue !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2402": [
+ null,
+ {
+ "position": 576,
+ "nodeLength": 14,
+ "src": "this.exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2405": [
+ null,
+ {
+ "position": 85,
+ "nodeLength": 22,
+ "src": "this.exception.message",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2409": [
+ null,
+ {
+ "position": 835,
+ "nodeLength": 10,
+ "src": "this.stack",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2421": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 22,
+ "src": "typeof id !== \"number\"",
+ "evalFalse": 6,
+ "evalTrue": 0
+ }
+ ],
+ "2441": [
+ null,
+ {
+ "position": 6780,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6780,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6813,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6831,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2442": [
+ null,
+ {
+ "position": 6878,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6878,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6910,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6910,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2451": [
+ null,
+ {
+ "position": 7172,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2456": [
+ null,
+ {
+ "position": 7246,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2461": [
+ null,
+ {
+ "position": 7346,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2465": [
+ null,
+ {
+ "position": 7427,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 7428,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2491": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 41,
+ "src": "!property && typeof object === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 28,
+ "src": "typeof object === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2495": [
+ null,
+ {
+ "position": 136,
+ "nodeLength": 20,
+ "src": "!object && !property",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2499": [
+ null,
+ {
+ "position": 243,
+ "nodeLength": 5,
+ "src": "types",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2501": [
+ null,
+ {
+ "position": 113,
+ "nodeLength": 16,
+ "src": "i < types.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2511": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 6,
+ "src": "!fakes",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2515": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2516": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 30,
+ "src": "fakes[i].matches(args, strict)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2526": [
+ null,
+ {
+ "position": 132,
+ "nodeLength": 20,
+ "src": "this.callCount === 1",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2527": [
+ null,
+ {
+ "position": 185,
+ "nodeLength": 20,
+ "src": "this.callCount === 2",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2528": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 20,
+ "src": "this.callCount === 3",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2542": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 11,
+ "src": "proxyLength",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2559": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 13,
+ "src": "this.invoking",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2582": [
+ null,
+ {
+ "position": 960,
+ "nodeLength": 10,
+ "src": "this.fakes",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2583": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 21,
+ "src": "i < this.fakes.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2594": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 26,
+ "src": "typeof func !== \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2600": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 10,
+ "src": "!spyLength",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2612": [
+ null,
+ {
+ "position": 616,
+ "nodeLength": 13,
+ "src": "name || \"spy\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2635": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 8,
+ "src": "matching",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2638": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 17,
+ "src": "this.func || func",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2642": [
+ null,
+ {
+ "position": 385,
+ "nodeLength": 59,
+ "src": "thisCall.calledWithNew() && typeof returnValue !== \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 413,
+ "nodeLength": 31,
+ "src": "typeof returnValue !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2658": [
+ null,
+ {
+ "position": 1452,
+ "nodeLength": 23,
+ "src": "exception !== undefined",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2671": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 28,
+ "src": "i < 0 || i >= this.callCount",
+ "evalFalse": 6,
+ "evalTrue": 4
+ },
+ {
+ "position": 21,
+ "nodeLength": 5,
+ "src": "i < 0",
+ "evalFalse": 10,
+ "evalTrue": 0
+ },
+ {
+ "position": 30,
+ "nodeLength": 19,
+ "src": "i >= this.callCount",
+ "evalFalse": 6,
+ "evalTrue": 4
+ }
+ ],
+ "2684": [
+ null,
+ {
+ "position": 85,
+ "nodeLength": 18,
+ "src": "i < this.callCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2692": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 12,
+ "src": "!this.called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2696": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 13,
+ "src": "!spyFn.called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2700": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 57,
+ "src": "this.callIds[0] < spyFn.callIds[spyFn.callIds.length - 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2704": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 29,
+ "src": "!this.called || !spyFn.called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2708": [
+ null,
+ {
+ "position": 130,
+ "nodeLength": 69,
+ "src": "this.callIds[this.callCount - 1] > spyFn.callIds[spyFn.callCount - 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2714": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 10,
+ "src": "this.fakes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2717": [
+ null,
+ {
+ "position": 96,
+ "nodeLength": 5,
+ "src": "match",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2734": [
+ null,
+ {
+ "position": 718,
+ "nodeLength": 20,
+ "src": "i < this.args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2735": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 26,
+ "src": "fake.matches(this.args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2752": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 102,
+ "src": "margs.length <= args.length && sinon.deepEqual(margs, args.slice(0, margs.length))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 73,
+ "nodeLength": 27,
+ "src": "margs.length <= args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2754": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 39,
+ "src": "!strict || margs.length === args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 39,
+ "nodeLength": 28,
+ "src": "margs.length === args.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2763": [
+ null,
+ {
+ "position": 150,
+ "nodeLength": 12,
+ "src": "format || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2766": [
+ null,
+ {
+ "position": 88,
+ "nodeLength": 31,
+ "src": "typeof formatter === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2768": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 31,
+ "src": "!isNaN(parseInt(specifyer, 10))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2779": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 12,
+ "src": "!this.called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2780": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 9,
+ "src": "notCalled",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2789": [
+ null,
+ {
+ "position": 333,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2792": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 59,
+ "src": "currentCall[actual || method].apply(currentCall, arguments)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 89,
+ "nodeLength": 16,
+ "src": "actual || method",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2795": [
+ null,
+ {
+ "position": 68,
+ "nodeLength": 8,
+ "src": "matchAny",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2801": [
+ null,
+ {
+ "position": 699,
+ "nodeLength": 26,
+ "src": "matches === this.callCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2862": [
+ null,
+ {
+ "position": 93,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2864": [
+ null,
+ {
+ "position": 111,
+ "nodeLength": 23,
+ "src": "/\\n/.test(calls[i - 1])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2870": [
+ null,
+ {
+ "position": 429,
+ "nodeLength": 16,
+ "src": "calls.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2876": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2886": [
+ null,
+ {
+ "position": 87,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2902": [
+ null,
+ {
+ "position": 14788,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14788,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14821,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14839,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2903": [
+ null,
+ {
+ "position": 14886,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14886,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14918,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14918,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2914": [
+ null,
+ {
+ "position": 15243,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2919": [
+ null,
+ {
+ "position": 15317,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2924": [
+ null,
+ {
+ "position": 15417,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2928": [
+ null,
+ {
+ "position": 15498,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 15499,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "2952": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 69,
+ "src": "typeof process === \"object\" && typeof process.nextTick === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 27,
+ "src": "typeof process === \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 44,
+ "nodeLength": 38,
+ "src": "typeof process.nextTick === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2956": [
+ null,
+ {
+ "position": 146,
+ "nodeLength": 34,
+ "src": "typeof setImmediate === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "2966": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 25,
+ "src": "typeof error === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2967": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 13,
+ "src": "message || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2969": [
+ null,
+ {
+ "position": 158,
+ "nodeLength": 6,
+ "src": "!error",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2981": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 14,
+ "src": "callArgAt >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2987": [
+ null,
+ {
+ "position": 162,
+ "nodeLength": 33,
+ "src": "callArgAt === useLeftMostCallback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2991": [
+ null,
+ {
+ "position": 255,
+ "nodeLength": 34,
+ "src": "callArgAt === useRightMostCallback",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2997": [
+ null,
+ {
+ "position": 457,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "2998": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 53,
+ "src": "!callArgProp && typeof argumentList[i] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 37,
+ "src": "typeof argumentList[i] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3002": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 100,
+ "src": "callArgProp && argumentList[i] && typeof argumentList[i][callArgProp] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 160,
+ "nodeLength": 85,
+ "src": "argumentList[i] && typeof argumentList[i][callArgProp] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3003": [
+ null,
+ {
+ "position": 34,
+ "nodeLength": 50,
+ "src": "typeof argumentList[i][callArgProp] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3013": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 22,
+ "src": "behavior.callArgAt < 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3016": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 20,
+ "src": "behavior.callArgProp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3025": [
+ null,
+ {
+ "position": 486,
+ "nodeLength": 15,
+ "src": "args.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3036": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 38,
+ "src": "typeof behavior.callArgAt === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3039": [
+ null,
+ {
+ "position": 78,
+ "nodeLength": 26,
+ "src": "typeof func !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3043": [
+ null,
+ {
+ "position": 228,
+ "nodeLength": 22,
+ "src": "behavior.callbackAsync",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3063": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 234,
+ "src": "typeof this.callArgAt === \"number\" || this.exception || typeof this.returnArgAt === \"number\" || this.returnThis || this.returnValueDefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 34,
+ "src": "typeof this.callArgAt === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3064": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 172,
+ "src": "this.exception || typeof this.returnArgAt === \"number\" || this.returnThis || this.returnValueDefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3065": [
+ null,
+ {
+ "position": 41,
+ "nodeLength": 130,
+ "src": "typeof this.returnArgAt === \"number\" || this.returnThis || this.returnValueDefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 114,
+ "nodeLength": 36,
+ "src": "typeof this.returnArgAt === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3066": [
+ null,
+ {
+ "position": 63,
+ "nodeLength": 66,
+ "src": "this.returnThis || this.returnValueDefined",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3073": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 14,
+ "src": "this.exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3075": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 36,
+ "src": "typeof this.returnArgAt === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3077": [
+ null,
+ {
+ "position": 271,
+ "nodeLength": 15,
+ "src": "this.returnThis",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3109": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof pos !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3123": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof pos !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3126": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 27,
+ "src": "typeof context !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3140": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof pos !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3154": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof pos !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3157": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 27,
+ "src": "typeof context !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3191": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 27,
+ "src": "typeof context !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3215": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 27,
+ "src": "typeof context !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3240": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof pos !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3267": [
+ null,
+ {
+ "position": 110,
+ "nodeLength": 92,
+ "src": "proto.hasOwnProperty(method) && method.match(/^(callsArg|yields)/) && !method.match(/Async/)",
+ "evalFalse": 13,
+ "evalTrue": 9
+ },
+ {
+ "position": 142,
+ "nodeLength": 60,
+ "src": "method.match(/^(callsArg|yields)/) && !method.match(/Async/)",
+ "evalFalse": 13,
+ "evalTrue": 9
+ }
+ ],
+ "3276": [
+ null,
+ {
+ "position": 10836,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 10836,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 10869,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10887,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3277": [
+ null,
+ {
+ "position": 10934,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 10934,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 10966,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 10966,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3285": [
+ null,
+ {
+ "position": 11200,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3290": [
+ null,
+ {
+ "position": 11274,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3295": [
+ null,
+ {
+ "position": 11374,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3299": [
+ null,
+ {
+ "position": 11455,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 11456,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3311": [
+ null,
+ {
+ "position": 47,
+ "nodeLength": 48,
+ "src": "typeof Object.getOwnPropertyNames !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3324": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 8,
+ "src": "!seen[k]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3326": [
+ null,
+ {
+ "position": 70,
+ "nodeLength": 65,
+ "src": "typeof Object.getOwnPropertyDescriptor(obj, k).get === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3333": [
+ null,
+ {
+ "position": 987,
+ "nodeLength": 5,
+ "src": "proto",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3361": [
+ null,
+ {
+ "position": 2279,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2279,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2312,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2330,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3362": [
+ null,
+ {
+ "position": 2377,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2377,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2409,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2409,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3364": [
+ null,
+ {
+ "position": 2464,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3369": [
+ null,
+ {
+ "position": 2538,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3374": [
+ null,
+ {
+ "position": 2638,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3378": [
+ null,
+ {
+ "position": 2719,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2720,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3400": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 64,
+ "src": "!!func && typeof func !== \"function\" && typeof func !== \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 54,
+ "src": "typeof func !== \"function\" && typeof func !== \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 26,
+ "src": "typeof func !== \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 57,
+ "nodeLength": 24,
+ "src": "typeof func !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3406": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 4,
+ "src": "func",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3407": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 26,
+ "src": "typeof func === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3408": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 29,
+ "src": "sinon.spy && sinon.spy.create",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3411": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 29,
+ "src": "sinon.spy && sinon.spy.create",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3413": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 16,
+ "src": "i < types.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3420": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 68,
+ "src": "typeof object === \"object\" && typeof object[property] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 57,
+ "nodeLength": 26,
+ "src": "typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 87,
+ "nodeLength": 38,
+ "src": "typeof object[property] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3426": [
+ null,
+ {
+ "position": 1095,
+ "nodeLength": 42,
+ "src": "!object && typeof property === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1106,
+ "nodeLength": 31,
+ "src": "typeof property === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3430": [
+ null,
+ {
+ "position": 1216,
+ "nodeLength": 61,
+ "src": "typeof property === \"undefined\" && typeof object === \"object\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1216,
+ "nodeLength": 31,
+ "src": "typeof property === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1251,
+ "nodeLength": 26,
+ "src": "typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3431": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 12,
+ "src": "object || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3435": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 180,
+ "src": "propOwner !== Object.prototype && prop !== \"constructor\" && typeof sinon.getPropertyDescriptor(propOwner, prop).value === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 213,
+ "nodeLength": 30,
+ "src": "propOwner !== Object.prototype",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3436": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 122,
+ "src": "prop !== \"constructor\" && typeof sinon.getPropertyDescriptor(propOwner, prop).value === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 272,
+ "nodeLength": 22,
+ "src": "prop !== \"constructor\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3437": [
+ null,
+ {
+ "position": 49,
+ "nodeLength": 72,
+ "src": "typeof sinon.getPropertyDescriptor(propOwner, prop).value === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3452": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 62,
+ "src": "stubInstance.parent && getCurrentBehavior(stubInstance.parent)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3456": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 143,
+ "src": "stubInstance.defaultBehavior || getParentBehaviour(stubInstance) || sinon.behavior.create(stubInstance)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3457": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 91,
+ "src": "getParentBehaviour(stubInstance) || sinon.behavior.create(stubInstance)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3463": [
+ null,
+ {
+ "position": 99,
+ "nodeLength": 32,
+ "src": "behavior && behavior.isPresent()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3501": [
+ null,
+ {
+ "position": 252,
+ "nodeLength": 10,
+ "src": "this.fakes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3502": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 21,
+ "src": "i < this.fakes.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3509": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 22,
+ "src": "!this.behaviors[index]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3531": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 51,
+ "src": "this.defaultBehavior || sinon.behavior.create(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3538": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 205,
+ "src": "sinon.behavior.hasOwnProperty(method) && !proto.hasOwnProperty(method) && method !== \"create\" && method !== \"withArgs\" && method !== \"invoke\"",
+ "evalFalse": 7,
+ "evalTrue": 24
+ }
+ ],
+ "3539": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 148,
+ "src": "!proto.hasOwnProperty(method) && method !== \"create\" && method !== \"withArgs\" && method !== \"invoke\"",
+ "evalFalse": 7,
+ "evalTrue": 24
+ }
+ ],
+ "3540": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 99,
+ "src": "method !== \"create\" && method !== \"withArgs\" && method !== \"invoke\"",
+ "evalFalse": 2,
+ "evalTrue": 24
+ },
+ {
+ "position": 126,
+ "nodeLength": 19,
+ "src": "method !== \"create\"",
+ "evalFalse": 0,
+ "evalTrue": 26
+ }
+ ],
+ "3541": [
+ null,
+ {
+ "position": 38,
+ "nodeLength": 60,
+ "src": "method !== \"withArgs\" && method !== \"invoke\"",
+ "evalFalse": 2,
+ "evalTrue": 24
+ },
+ {
+ "position": 166,
+ "nodeLength": 21,
+ "src": "method !== \"withArgs\"",
+ "evalFalse": 1,
+ "evalTrue": 25
+ }
+ ],
+ "3542": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 19,
+ "src": "method !== \"invoke\"",
+ "evalFalse": 1,
+ "evalTrue": 24
+ }
+ ],
+ "3553": [
+ null,
+ {
+ "position": 5454,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 5454,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 5487,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5505,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3554": [
+ null,
+ {
+ "position": 5552,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 5552,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 5584,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 5584,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3564": [
+ null,
+ {
+ "position": 5873,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3569": [
+ null,
+ {
+ "position": 5947,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "3574": [
+ null,
+ {
+ "position": 6047,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3578": [
+ null,
+ {
+ "position": 6128,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 6129,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "3610": [
+ null,
+ {
+ "position": 178,
+ "nodeLength": 7,
+ "src": "!object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3618": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 11,
+ "src": "!collection",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3622": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3628": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 46,
+ "src": "compareLength && (arr1.length !== arr2.length)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 35,
+ "nodeLength": 27,
+ "src": "arr1.length !== arr2.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3632": [
+ null,
+ {
+ "position": 157,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3633": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 34,
+ "src": "!sinon.deepEqual(arr1[i], arr2[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3642": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 7,
+ "src": "!object",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3654": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 7,
+ "src": "!method",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3658": [
+ null,
+ {
+ "position": 131,
+ "nodeLength": 18,
+ "src": "!this.expectations",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3663": [
+ null,
+ {
+ "position": 275,
+ "nodeLength": 26,
+ "src": "!this.expectations[method]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3684": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 43,
+ "src": "typeof object[proxy].restore === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3691": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 23,
+ "src": "this.expectations || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3697": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 18,
+ "src": "!expectation.met()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3707": [
+ null,
+ {
+ "position": 600,
+ "nodeLength": 19,
+ "src": "messages.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3709": [
+ null,
+ {
+ "position": 728,
+ "nodeLength": 14,
+ "src": "met.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3717": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 46,
+ "src": "this.expectations && this.expectations[method]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3719": [
+ null,
+ {
+ "position": 206,
+ "nodeLength": 10,
+ "src": "args || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3722": [
+ null,
+ {
+ "position": 281,
+ "nodeLength": 23,
+ "src": "i < expectations.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3723": [
+ null,
+ {
+ "position": 40,
+ "nodeLength": 39,
+ "src": "expectations[i].expectedArguments || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3724": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 76,
+ "src": "arrayEquals(expectedArgs, currentArgs, expectations[i].expectsExactArgCount)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3729": [
+ null,
+ {
+ "position": 645,
+ "nodeLength": 39,
+ "src": "i < expectationsWithMatchingArgs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3730": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 125,
+ "src": "!expectationsWithMatchingArgs[i].met() && expectationsWithMatchingArgs[i].allowsCall(thisValue, args)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3739": [
+ null,
+ {
+ "position": 1076,
+ "nodeLength": 39,
+ "src": "i < expectationsWithMatchingArgs.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3740": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 59,
+ "src": "expectationsWithMatchingArgs[i].allowsCall(thisValue, args)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3741": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 44,
+ "src": "available || expectationsWithMatchingArgs[i]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3747": [
+ null,
+ {
+ "position": 1426,
+ "nodeLength": 28,
+ "src": "available && exhausted === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1439,
+ "nodeLength": 15,
+ "src": "exhausted === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3751": [
+ null,
+ {
+ "position": 1566,
+ "nodeLength": 23,
+ "src": "i < expectations.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3768": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 15,
+ "src": "callCount === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3779": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 50,
+ "src": "typeof min === \"number\" && typeof max === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 106,
+ "nodeLength": 23,
+ "src": "typeof min === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 133,
+ "nodeLength": 23,
+ "src": "typeof max === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3782": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 11,
+ "src": "min !== max",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3789": [
+ null,
+ {
+ "position": 388,
+ "nodeLength": 23,
+ "src": "typeof min === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3797": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 40,
+ "src": "typeof expectation.minCalls === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3798": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 61,
+ "src": "!hasMinLimit || expectation.callCount >= expectation.minCalls",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 108,
+ "nodeLength": 45,
+ "src": "expectation.callCount >= expectation.minCalls",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3802": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 40,
+ "src": "typeof expectation.maxCalls !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3806": [
+ null,
+ {
+ "position": 125,
+ "nodeLength": 46,
+ "src": "expectation.callCount === expectation.maxCalls",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3810": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 41,
+ "src": "match && match.isMatcher(possibleMatcher)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3812": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 46,
+ "src": "isMatcher && possibleMatcher.test(arg) || true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 92,
+ "nodeLength": 38,
+ "src": "isMatcher && possibleMatcher.test(arg)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3834": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof num !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3838": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 15,
+ "src": "!this.limitsSet",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3849": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof num !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3853": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 15,
+ "src": "!this.limitsSet",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3880": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 23,
+ "src": "typeof num !== \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3889": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 38,
+ "src": "!this.failed && receivedMinCalls(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3893": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 22,
+ "src": "receivedMaxCalls(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3898": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 57,
+ "src": "\"expectedThis\" in this && this.expectedThis !== thisValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 253,
+ "nodeLength": 31,
+ "src": "this.expectedThis !== thisValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3903": [
+ null,
+ {
+ "position": 487,
+ "nodeLength": 30,
+ "src": "!(\"expectedArguments\" in this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3907": [
+ null,
+ {
+ "position": 588,
+ "nodeLength": 5,
+ "src": "!args",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3912": [
+ null,
+ {
+ "position": 794,
+ "nodeLength": 43,
+ "src": "args.length < this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3917": [
+ null,
+ {
+ "position": 1072,
+ "nodeLength": 94,
+ "src": "this.expectsExactArgCount && args.length !== this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3918": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 45,
+ "src": "args.length !== this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3923": [
+ null,
+ {
+ "position": 1449,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3925": [
+ null,
+ {
+ "position": 26,
+ "nodeLength": 50,
+ "src": "!verifyMatcher(this.expectedArguments[i], args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3930": [
+ null,
+ {
+ "position": 324,
+ "nodeLength": 52,
+ "src": "!sinon.deepEqual(this.expectedArguments[i], args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3938": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 36,
+ "src": "this.met() && receivedMaxCalls(this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3942": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 57,
+ "src": "\"expectedThis\" in this && this.expectedThis !== thisValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 160,
+ "nodeLength": 31,
+ "src": "this.expectedThis !== thisValue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3946": [
+ null,
+ {
+ "position": 268,
+ "nodeLength": 30,
+ "src": "!(\"expectedArguments\" in this)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3950": [
+ null,
+ {
+ "position": 377,
+ "nodeLength": 10,
+ "src": "args || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3952": [
+ null,
+ {
+ "position": 410,
+ "nodeLength": 43,
+ "src": "args.length < this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3956": [
+ null,
+ {
+ "position": 530,
+ "nodeLength": 94,
+ "src": "this.expectsExactArgCount && args.length !== this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3957": [
+ null,
+ {
+ "position": 48,
+ "nodeLength": 45,
+ "src": "args.length !== this.expectedArguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3961": [
+ null,
+ {
+ "position": 748,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3962": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 50,
+ "src": "!verifyMatcher(this.expectedArguments[i], args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3966": [
+ null,
+ {
+ "position": 164,
+ "nodeLength": 52,
+ "src": "!sinon.deepEqual(this.expectedArguments[i], args[i])",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3991": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 28,
+ "src": "this.expectedArguments || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3993": [
+ null,
+ {
+ "position": 89,
+ "nodeLength": 26,
+ "src": "!this.expectsExactArgCount",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "3998": [
+ null,
+ {
+ "position": 28,
+ "nodeLength": 43,
+ "src": "this.method || \"anonymous mock expectation\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4005": [
+ null,
+ {
+ "position": 515,
+ "nodeLength": 10,
+ "src": "this.met()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4014": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 11,
+ "src": "!this.met()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4039": [
+ null,
+ {
+ "position": 14567,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14567,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14600,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14618,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4040": [
+ null,
+ {
+ "position": 14665,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14665,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 14697,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 14697,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4055": [
+ null,
+ {
+ "position": 15106,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4060": [
+ null,
+ {
+ "position": 15180,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4065": [
+ null,
+ {
+ "position": 15280,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4069": [
+ null,
+ {
+ "position": 15361,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 15362,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4092": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 21,
+ "src": "!fakeCollection.fakes",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4102": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4103": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 38,
+ "src": "typeof fakes[i][method] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4112": [
+ null,
+ {
+ "position": 81,
+ "nodeLength": 16,
+ "src": "i < fakes.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4143": [
+ null,
+ {
+ "position": 227,
+ "nodeLength": 9,
+ "src": "exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4158": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 8,
+ "src": "property",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4161": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 30,
+ "src": "typeof original !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4162": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 38,
+ "src": "!hasOwnProperty.call(object, property)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4175": [
+ null,
+ {
+ "position": 667,
+ "nodeLength": 51,
+ "src": "!property && !!object && typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 680,
+ "nodeLength": 38,
+ "src": "!!object && typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 692,
+ "nodeLength": 26,
+ "src": "typeof object === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4179": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 38,
+ "src": "typeof stubbedObj[prop] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4217": [
+ null,
+ {
+ "position": 3638,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 3638,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 3671,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3689,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4218": [
+ null,
+ {
+ "position": 3736,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 3736,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 3768,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 3768,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4228": [
+ null,
+ {
+ "position": 4053,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4233": [
+ null,
+ {
+ "position": 4127,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4238": [
+ null,
+ {
+ "position": 4227,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4242": [
+ null,
+ {
+ "position": 4308,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 4309,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4266": [
+ null,
+ {
+ "position": 45,
+ "nodeLength": 28,
+ "src": "typeof lolex !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4272": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 30,
+ "src": "typeof methods[0] === \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4278": [
+ null,
+ {
+ "position": 274,
+ "nodeLength": 8,
+ "src": "now || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4292": [
+ null,
+ {
+ "position": 104,
+ "nodeLength": 35,
+ "src": "typeof setImmediate !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4293": [
+ null,
+ {
+ "position": 198,
+ "nodeLength": 37,
+ "src": "typeof clearImmediate !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4300": [
+ null,
+ {
+ "position": 1095,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1095,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1128,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1146,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4301": [
+ null,
+ {
+ "position": 1193,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1193,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1225,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1225,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4309": [
+ null,
+ {
+ "position": 1451,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4311": [
+ null,
+ {
+ "position": 1510,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4330": [
+ null,
+ {
+ "position": 135915,
+ "nodeLength": 28,
+ "src": "typeof sinon === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4360": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 43,
+ "src": "typeof progressEventRaw.loaded === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4361": [
+ null,
+ {
+ "position": 186,
+ "nodeLength": 42,
+ "src": "typeof progressEventRaw.total === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4371": [
+ null,
+ {
+ "position": 83,
+ "nodeLength": 25,
+ "src": "customData.detail || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4380": [
+ null,
+ {
+ "position": 39,
+ "nodeLength": 25,
+ "src": "this.eventListeners || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4381": [
+ null,
+ {
+ "position": 111,
+ "nodeLength": 32,
+ "src": "this.eventListeners[event] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4386": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 55,
+ "src": "this.eventListeners && this.eventListeners[event] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 33,
+ "nodeLength": 49,
+ "src": "this.eventListeners && this.eventListeners[event]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4388": [
+ null,
+ {
+ "position": 145,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4389": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 25,
+ "src": "listeners[i] === listener",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4397": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 54,
+ "src": "this.eventListeners && this.eventListeners[type] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 72,
+ "nodeLength": 48,
+ "src": "this.eventListeners && this.eventListeners[type]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4399": [
+ null,
+ {
+ "position": 161,
+ "nodeLength": 20,
+ "src": "i < listeners.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4400": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 34,
+ "src": "typeof listeners[i] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4412": [
+ null,
+ {
+ "position": 2854,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2854,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2887,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2905,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4413": [
+ null,
+ {
+ "position": 2952,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2952,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2984,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2984,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4420": [
+ null,
+ {
+ "position": 3150,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4422": [
+ null,
+ {
+ "position": 3209,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4461": [
+ null,
+ {
+ "position": 270,
+ "nodeLength": 9,
+ "src": "err.stack",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4465": [
+ null,
+ {
+ "position": 352,
+ "nodeLength": 31,
+ "src": "logError.useImmediateExceptions",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4493": [
+ null,
+ {
+ "position": 1575,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1575,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1608,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1626,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4494": [
+ null,
+ {
+ "position": 1673,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1673,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1705,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1705,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4496": [
+ null,
+ {
+ "position": 1760,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4501": [
+ null,
+ {
+ "position": 1834,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4506": [
+ null,
+ {
+ "position": 1934,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4510": [
+ null,
+ {
+ "position": 2015,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2016,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4530": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4533": [
+ null,
+ {
+ "position": 141992,
+ "nodeLength": 28,
+ "src": "typeof sinon === \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4534": [
+ null,
+ {
+ "position": 9,
+ "nodeLength": 27,
+ "src": "typeof this === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4546": [
+ null,
+ {
+ "position": 139,
+ "nodeLength": 47,
+ "src": "typeof xdr.GlobalXDomainRequest !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4547": [
+ null,
+ {
+ "position": 209,
+ "nodeLength": 15,
+ "src": "xdr.supportsXDR",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4559": [
+ null,
+ {
+ "position": 212,
+ "nodeLength": 49,
+ "src": "typeof FakeXDomainRequest.onCreate === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4565": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 42,
+ "src": "x.readyState !== FakeXDomainRequest.OPENED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4569": [
+ null,
+ {
+ "position": 148,
+ "nodeLength": 10,
+ "src": "x.sendFlag",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4575": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 42,
+ "src": "x.readyState === FakeXDomainRequest.UNSENT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4578": [
+ null,
+ {
+ "position": 146,
+ "nodeLength": 40,
+ "src": "x.readyState === FakeXDomainRequest.DONE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4584": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 24,
+ "src": "typeof body !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4612": [
+ null,
+ {
+ "position": 56,
+ "nodeLength": 13,
+ "src": "this.sendFlag",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4618": [
+ null,
+ {
+ "position": 53,
+ "nodeLength": 14,
+ "src": "this.isTimeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4620": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 58,
+ "src": "this.errorFlag || (this.status < 200 || this.status > 299)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 171,
+ "nodeLength": 38,
+ "src": "this.status < 200 || this.status > 299",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 171,
+ "nodeLength": 17,
+ "src": "this.status < 200",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 192,
+ "nodeLength": 17,
+ "src": "this.status > 299",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4629": [
+ null,
+ {
+ "position": 997,
+ "nodeLength": 9,
+ "src": "eventName",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4630": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 37,
+ "src": "typeof this[eventName] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4643": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 34,
+ "src": "!/^(get|head)$/i.test(this.method)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4652": [
+ null,
+ {
+ "position": 406,
+ "nodeLength": 33,
+ "src": "typeof this.onSend === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4662": [
+ null,
+ {
+ "position": 140,
+ "nodeLength": 66,
+ "src": "this.readyState > sinon.FakeXDomainRequest.UNSENT && this.sendFlag",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 140,
+ "nodeLength": 49,
+ "src": "this.readyState > sinon.FakeXDomainRequest.UNSENT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4672": [
+ null,
+ {
+ "position": 121,
+ "nodeLength": 20,
+ "src": "this.chunkSize || 10",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4680": [
+ null,
+ {
+ "position": 223,
+ "nodeLength": 19,
+ "src": "index < body.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4689": [
+ null,
+ {
+ "position": 259,
+ "nodeLength": 26,
+ "src": "typeof status === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4690": [
+ null,
+ {
+ "position": 339,
+ "nodeLength": 10,
+ "src": "body || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4711": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 15,
+ "src": "xdr.supportsXDR",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4717": [
+ null,
+ {
+ "position": 207,
+ "nodeLength": 21,
+ "src": "keepOnCreate !== true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4721": [
+ null,
+ {
+ "position": 423,
+ "nodeLength": 15,
+ "src": "xdr.supportsXDR",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4730": [
+ null,
+ {
+ "position": 6628,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6628,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6661,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6679,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4731": [
+ null,
+ {
+ "position": 6726,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6726,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 6758,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 6758,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4742": [
+ null,
+ {
+ "position": 7064,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4744": [
+ null,
+ {
+ "position": 7123,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4749": [
+ null,
+ {
+ "position": 7287,
+ "nodeLength": 29,
+ "src": "typeof global !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4768": [
+ null,
+ {
+ "position": 27,
+ "nodeLength": 49,
+ "src": "typeof globalScope.XMLHttpRequest !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4769": [
+ null,
+ {
+ "position": 90,
+ "nodeLength": 11,
+ "src": "supportsXHR",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4773": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 48,
+ "src": "typeof globalScope.ActiveXObject !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4774": [
+ null,
+ {
+ "position": 255,
+ "nodeLength": 15,
+ "src": "supportsActiveX",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4783": [
+ null,
+ {
+ "position": 512,
+ "nodeLength": 36,
+ "src": "typeof ProgressEvent !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4784": [
+ null,
+ {
+ "position": 580,
+ "nodeLength": 34,
+ "src": "typeof CustomEvent !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4785": [
+ null,
+ {
+ "position": 643,
+ "nodeLength": 31,
+ "src": "typeof FormData !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4786": [
+ null,
+ {
+ "position": 706,
+ "nodeLength": 34,
+ "src": "typeof ArrayBuffer !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4797": [
+ null,
+ {
+ "position": 1104,
+ "nodeLength": 51,
+ "src": "typeof sinonXhr.GlobalActiveXObject !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "4798": [
+ null,
+ {
+ "position": 1184,
+ "nodeLength": 52,
+ "src": "typeof sinonXhr.GlobalXMLHttpRequest !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4800": [
+ null,
+ {
+ "position": 1315,
+ "nodeLength": 82,
+ "src": "sinonXhr.supportsXHR && \"withCredentials\" in (new sinonXhr.GlobalXMLHttpRequest())",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "4842": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 32,
+ "src": "this.eventListeners[event] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4844": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4845": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 25,
+ "src": "listeners[i] === listener",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4852": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 37,
+ "src": "this.eventListeners[event.type] || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4854": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 32,
+ "src": "(listener = listeners[i]) != null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4872": [
+ null,
+ {
+ "position": 292,
+ "nodeLength": 21,
+ "src": "sinonXhr.supportsCORS",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4883": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 42,
+ "src": "listener && typeof listener === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 88,
+ "nodeLength": 30,
+ "src": "typeof listener === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4889": [
+ null,
+ {
+ "position": 832,
+ "nodeLength": 6,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4893": [
+ null,
+ {
+ "position": 911,
+ "nodeLength": 49,
+ "src": "typeof FakeXMLHttpRequest.onCreate === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4899": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 44,
+ "src": "xhr.readyState !== FakeXMLHttpRequest.OPENED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4903": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 12,
+ "src": "xhr.sendFlag",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4912": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 26,
+ "src": "h.toLowerCase() === header",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4923": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 11,
+ "src": "!collection",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4927": [
+ null,
+ {
+ "position": 106,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4932": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 25,
+ "src": "index < collection.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4933": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 36,
+ "src": "callback(collection[index]) === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4980": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 32,
+ "src": "!IE6Re.test(navigator.userAgent)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4989": [
+ null,
+ {
+ "position": 66,
+ "nodeLength": 53,
+ "src": "xhr.readyState >= FakeXMLHttpRequest.HEADERS_RECEIVED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4992": [
+ null,
+ {
+ "position": 206,
+ "nodeLength": 44,
+ "src": "xhr.readyState >= FakeXMLHttpRequest.LOADING",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4995": [
+ null,
+ {
+ "position": 341,
+ "nodeLength": 42,
+ "src": "xhr.readyState === FakeXMLHttpRequest.DONE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "4998": [
+ null,
+ {
+ "position": 461,
+ "nodeLength": 26,
+ "src": "fakeXhr.onreadystatechange",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5003": [
+ null,
+ {
+ "position": 1512,
+ "nodeLength": 20,
+ "src": "xhr.addEventListener",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5005": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 44,
+ "src": "fakeXhr.eventListeners.hasOwnProperty(event)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5023": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 44,
+ "src": "xhr.readyState !== FakeXMLHttpRequest.OPENED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5029": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 42,
+ "src": "xhr.readyState === FakeXMLHttpRequest.DONE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5035": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 67,
+ "src": "xhr.async && xhr.readyState !== FakeXMLHttpRequest.HEADERS_RECEIVED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 26,
+ "nodeLength": 54,
+ "src": "xhr.readyState !== FakeXMLHttpRequest.HEADERS_RECEIVED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5041": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 24,
+ "src": "typeof body !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5052": [
+ null,
+ {
+ "position": 119,
+ "nodeLength": 15,
+ "src": "i < body.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5054": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 15,
+ "src": "charCode >= 256",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5064": [
+ null,
+ {
+ "position": 16,
+ "nodeLength": 74,
+ "src": "!contentType || /(text\\/xml)|(application\\/xml)|(\\+xml)/.test(contentType)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5068": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 46,
+ "src": "responseType === \"\" || responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 19,
+ "src": "responseType === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 36,
+ "nodeLength": 23,
+ "src": "responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5070": [
+ null,
+ {
+ "position": 108,
+ "nodeLength": 53,
+ "src": "supportsArrayBuffer && responseType === \"arraybuffer\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 131,
+ "nodeLength": 30,
+ "src": "responseType === \"arraybuffer\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5072": [
+ null,
+ {
+ "position": 232,
+ "nodeLength": 23,
+ "src": "responseType === \"json\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5079": [
+ null,
+ {
+ "position": 457,
+ "nodeLength": 39,
+ "src": "supportsBlob && responseType === \"blob\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 473,
+ "nodeLength": 23,
+ "src": "responseType === \"blob\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5081": [
+ null,
+ {
+ "position": 51,
+ "nodeLength": 11,
+ "src": "contentType",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5085": [
+ null,
+ {
+ "position": 719,
+ "nodeLength": 27,
+ "src": "responseType === \"document\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5086": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 29,
+ "src": "isXmlContentType(contentType)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5095": [
+ null,
+ {
+ "position": 13,
+ "nodeLength": 54,
+ "src": "xhr.responseType === \"\" || xhr.responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 13,
+ "nodeLength": 23,
+ "src": "xhr.responseType === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 40,
+ "nodeLength": 27,
+ "src": "xhr.responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5105": [
+ null,
+ {
+ "position": 62,
+ "nodeLength": 11,
+ "src": "text !== \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5107": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 32,
+ "src": "typeof DOMParser !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5177": [
+ null,
+ {
+ "position": 100,
+ "nodeLength": 26,
+ "src": "typeof async === \"boolean\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5184": [
+ null,
+ {
+ "position": 366,
+ "nodeLength": 38,
+ "src": "FakeXMLHttpRequest.useFilters === true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5189": [
+ null,
+ {
+ "position": 240,
+ "nodeLength": 6,
+ "src": "defake",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5202": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 45,
+ "src": "typeof this.onreadystatechange === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5210": [
+ null,
+ {
+ "position": 526,
+ "nodeLength": 43,
+ "src": "this.readyState === FakeXMLHttpRequest.DONE",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5213": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 18,
+ "src": "this.progress || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5214": [
+ null,
+ {
+ "position": 80,
+ "nodeLength": 18,
+ "src": "this.progress || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5217": [
+ null,
+ {
+ "position": 239,
+ "nodeLength": 17,
+ "src": "this.status === 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5218": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 12,
+ "src": "this.aborted",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5224": [
+ null,
+ {
+ "position": 462,
+ "nodeLength": 16,
+ "src": "supportsProgress",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5241": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 54,
+ "src": "unsafeHeaders[header] || /^(Sec-|Proxy-)/.test(header)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5245": [
+ null,
+ {
+ "position": 242,
+ "nodeLength": 27,
+ "src": "this.requestHeaders[header]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5258": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 30,
+ "src": "headers.hasOwnProperty(header)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5263": [
+ null,
+ {
+ "position": 325,
+ "nodeLength": 10,
+ "src": "this.async",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5274": [
+ null,
+ {
+ "position": 57,
+ "nodeLength": 34,
+ "src": "!/^(get|head)$/i.test(this.method)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5276": [
+ null,
+ {
+ "position": 111,
+ "nodeLength": 32,
+ "src": "this.requestHeaders[contentType]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5279": [
+ null,
+ {
+ "position": 348,
+ "nodeLength": 47,
+ "src": "supportsFormData && !(data instanceof FormData)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5291": [
+ null,
+ {
+ "position": 877,
+ "nodeLength": 33,
+ "src": "typeof this.onSend === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5305": [
+ null,
+ {
+ "position": 220,
+ "nodeLength": 60,
+ "src": "this.readyState > FakeXMLHttpRequest.UNSENT && this.sendFlag",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 220,
+ "nodeLength": 43,
+ "src": "this.readyState > FakeXMLHttpRequest.UNSENT",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5323": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 53,
+ "src": "this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5327": [
+ null,
+ {
+ "position": 150,
+ "nodeLength": 30,
+ "src": "/^Set-Cookie2?$/i.test(header)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5333": [
+ null,
+ {
+ "position": 326,
+ "nodeLength": 36,
+ "src": "this.responseHeaders[header] || null",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5337": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 53,
+ "src": "this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5344": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 102,
+ "src": "this.responseHeaders.hasOwnProperty(header) && !/^Set-Cookie2?$/i.test(header)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5359": [
+ null,
+ {
+ "position": 245,
+ "nodeLength": 56,
+ "src": "this.responseType === \"\" || this.responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 245,
+ "nodeLength": 24,
+ "src": "this.responseType === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 273,
+ "nodeLength": 28,
+ "src": "this.responseType === \"text\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5361": [
+ null,
+ {
+ "position": 360,
+ "nodeLength": 10,
+ "src": "this.async",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5362": [
+ null,
+ {
+ "position": 37,
+ "nodeLength": 20,
+ "src": "this.chunkSize || 10",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5368": [
+ null,
+ {
+ "position": 105,
+ "nodeLength": 14,
+ "src": "isTextResponse",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5372": [
+ null,
+ {
+ "position": 332,
+ "nodeLength": 19,
+ "src": "index < body.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5376": [
+ null,
+ {
+ "position": 973,
+ "nodeLength": 14,
+ "src": "isTextResponse",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5380": [
+ null,
+ {
+ "position": 1085,
+ "nodeLength": 32,
+ "src": "this.responseType === \"document\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5382": [
+ null,
+ {
+ "position": 1203,
+ "nodeLength": 57,
+ "src": "this.responseType === \"\" && isXmlContentType(contentType)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1203,
+ "nodeLength": 24,
+ "src": "this.responseType === \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5390": [
+ null,
+ {
+ "position": 31,
+ "nodeLength": 26,
+ "src": "typeof status === \"number\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5392": [
+ null,
+ {
+ "position": 193,
+ "nodeLength": 13,
+ "src": "headers || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5393": [
+ null,
+ {
+ "position": 246,
+ "nodeLength": 10,
+ "src": "body || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5397": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 16,
+ "src": "supportsProgress",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5403": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 16,
+ "src": "supportsProgress",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5409": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 19,
+ "src": "supportsCustomEvent",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5425": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 20,
+ "src": "sinonXhr.supportsXHR",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5429": [
+ null,
+ {
+ "position": 159,
+ "nodeLength": 24,
+ "src": "sinonXhr.supportsActiveX",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5435": [
+ null,
+ {
+ "position": 351,
+ "nodeLength": 21,
+ "src": "keepOnCreate !== true",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5439": [
+ null,
+ {
+ "position": 555,
+ "nodeLength": 20,
+ "src": "sinonXhr.supportsXHR",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5443": [
+ null,
+ {
+ "position": 670,
+ "nodeLength": 24,
+ "src": "sinonXhr.supportsActiveX",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5445": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 64,
+ "src": "objId === \"Microsoft.XMLHTTP\" || /^Msxml2\\.XMLHTTP/i.test(objId)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 29,
+ "src": "objId === \"Microsoft.XMLHTTP\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5460": [
+ null,
+ {
+ "position": 24353,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 24353,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 24386,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24404,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5461": [
+ null,
+ {
+ "position": 24451,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 24451,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 24483,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 24483,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5472": [
+ null,
+ {
+ "position": 24789,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5477": [
+ null,
+ {
+ "position": 24863,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5482": [
+ null,
+ {
+ "position": 24963,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5486": [
+ null,
+ {
+ "position": 25052,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 25053,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5487": [
+ null,
+ {
+ "position": 25124,
+ "nodeLength": 29,
+ "src": "typeof global !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5514": [
+ null,
+ {
+ "position": 46,
+ "nodeLength": 60,
+ "src": "Object.prototype.toString.call(handler) !== \"[object Array]\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5518": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 31,
+ "src": "typeof response[2] !== \"string\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5526": [
+ null,
+ {
+ "position": 479,
+ "nodeLength": 29,
+ "src": "typeof window !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "5531": [
+ null,
+ {
+ "position": 64,
+ "nodeLength": 57,
+ "src": "!rmeth || rmeth.toLowerCase() === reqMethod.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 47,
+ "src": "rmeth.toLowerCase() === reqMethod.toLowerCase()",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5533": [
+ null,
+ {
+ "position": 178,
+ "nodeLength": 78,
+ "src": "!url || url === reqUrl || (typeof url.test === \"function\" && url.test(reqUrl))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186,
+ "nodeLength": 70,
+ "src": "url === reqUrl || (typeof url.test === \"function\" && url.test(reqUrl))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 186,
+ "nodeLength": 14,
+ "src": "url === reqUrl",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 205,
+ "nodeLength": 50,
+ "src": "typeof url.test === \"function\" && url.test(reqUrl)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 205,
+ "nodeLength": 30,
+ "src": "typeof url.test === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5535": [
+ null,
+ {
+ "position": 274,
+ "nodeLength": 23,
+ "src": "matchMethod && matchUrl",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5541": [
+ null,
+ {
+ "position": 52,
+ "nodeLength": 61,
+ "src": "!/^https?:\\/\\//.test(requestUrl) || rCurrLoc.test(requestUrl)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5545": [
+ null,
+ {
+ "position": 199,
+ "nodeLength": 59,
+ "src": "matchOne(response, this.getHTTPMethod(request), requestUrl)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5546": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 39,
+ "src": "typeof response.response === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5548": [
+ null,
+ {
+ "position": 84,
+ "nodeLength": 35,
+ "src": "ru && typeof ru.exec === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 90,
+ "nodeLength": 29,
+ "src": "typeof ru.exec === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5563": [
+ null,
+ {
+ "position": 112,
+ "nodeLength": 23,
+ "src": "!sinon.xhr.supportsCORS",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5585": [
+ null,
+ {
+ "position": 288,
+ "nodeLength": 12,
+ "src": "config || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5587": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 67,
+ "src": "whitelist.hasOwnProperty(setting) && config.hasOwnProperty(setting)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5599": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 25,
+ "src": "server.respondImmediately",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5601": [
+ null,
+ {
+ "position": 177,
+ "nodeLength": 40,
+ "src": "server.autoRespond && !server.responding",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5605": [
+ null,
+ {
+ "position": 152,
+ "nodeLength": 29,
+ "src": "server.autoRespondAfter || 10",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5613": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 52,
+ "src": "this.fakeHTTPMethods && /post/i.test(request.method)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5614": [
+ null,
+ {
+ "position": 36,
+ "nodeLength": 25,
+ "src": "request.requestBody || \"\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5615": [
+ null,
+ {
+ "position": 118,
+ "nodeLength": 7,
+ "src": "matches",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5622": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 9,
+ "src": "xhr.async",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5623": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 11,
+ "src": "!this.queue",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5643": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 54,
+ "src": "arguments.length === 1 && typeof method !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 21,
+ "nodeLength": 22,
+ "src": "arguments.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 47,
+ "nodeLength": 28,
+ "src": "typeof method !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5648": [
+ null,
+ {
+ "position": 205,
+ "nodeLength": 15,
+ "src": "!this.responses",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5652": [
+ null,
+ {
+ "position": 304,
+ "nodeLength": 22,
+ "src": "arguments.length === 1",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5657": [
+ null,
+ {
+ "position": 445,
+ "nodeLength": 22,
+ "src": "arguments.length === 2",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5666": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 26,
+ "src": "typeof body === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5671": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 20,
+ "src": "arguments.length > 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5675": [
+ null,
+ {
+ "position": 153,
+ "nodeLength": 16,
+ "src": "this.queue || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5678": [
+ null,
+ {
+ "position": 266,
+ "nodeLength": 19,
+ "src": "i < requests.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5685": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 15,
+ "src": "request.aborted",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5689": [
+ null,
+ {
+ "position": 134,
+ "nodeLength": 30,
+ "src": "this.response || [404, {}, \"\"]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5691": [
+ null,
+ {
+ "position": 191,
+ "nodeLength": 14,
+ "src": "this.responses",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5692": [
+ null,
+ {
+ "position": 72,
+ "nodeLength": 6,
+ "src": "i >= 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5693": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 44,
+ "src": "match.call(this, this.responses[i], request)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5700": [
+ null,
+ {
+ "position": 588,
+ "nodeLength": 24,
+ "src": "request.readyState !== 4",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5711": [
+ null,
+ {
+ "position": 24,
+ "nodeLength": 63,
+ "src": "this.xhr.restore && this.xhr.restore.apply(this.xhr, arguments)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5716": [
+ null,
+ {
+ "position": 7015,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7015,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7048,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7066,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5717": [
+ null,
+ {
+ "position": 7113,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7113,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 7145,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 7145,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5728": [
+ null,
+ {
+ "position": 7477,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5730": [
+ null,
+ {
+ "position": 7536,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5764": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 9,
+ "src": "xhr.async",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5765": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 36,
+ "src": "typeof setTimeout.clock === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5772": [
+ null,
+ {
+ "position": 276,
+ "nodeLength": 20,
+ "src": "!this.longestTimeout",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5778": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 26,
+ "src": "server.longestTimeout || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5784": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 26,
+ "src": "server.longestTimeout || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5797": [
+ null,
+ {
+ "position": 95,
+ "nodeLength": 10,
+ "src": "this.clock",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5798": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 24,
+ "src": "this.longestTimeout || 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5801": [
+ null,
+ {
+ "position": 122,
+ "nodeLength": 15,
+ "src": "this.resetClock",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5811": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 10,
+ "src": "this.clock",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5819": [
+ null,
+ {
+ "position": 2092,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2092,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2125,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2143,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5820": [
+ null,
+ {
+ "position": 2190,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2190,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2222,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2222,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5829": [
+ null,
+ {
+ "position": 2456,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5831": [
+ null,
+ {
+ "position": 2515,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "5860": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 6,
+ "src": "!value",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5864": [
+ null,
+ {
+ "position": 82,
+ "nodeLength": 48,
+ "src": "config.injectInto && !(key in config.injectInto)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5875": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 20,
+ "src": "config.useFakeServer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5876": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 40,
+ "src": "typeof config.useFakeServer === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5883": [
+ null,
+ {
+ "position": 320,
+ "nodeLength": 20,
+ "src": "config.useFakeTimers",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5884": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 40,
+ "src": "typeof config.useFakeTimers === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5904": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 40,
+ "src": "this.serverPrototype || sinon.fakeServer",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5906": [
+ null,
+ {
+ "position": 92,
+ "nodeLength": 23,
+ "src": "!proto || !proto.create",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5917": [
+ null,
+ {
+ "position": 79,
+ "nodeLength": 10,
+ "src": "this.clock",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5921": [
+ null,
+ {
+ "position": 176,
+ "nodeLength": 11,
+ "src": "this.server",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5932": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 16,
+ "src": "arguments.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5941": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 17,
+ "src": "this.injectedKeys",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5942": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 5,
+ "src": "i < j",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5950": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 7,
+ "src": "!config",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5955": [
+ null,
+ {
+ "position": 202,
+ "nodeLength": 18,
+ "src": "sandbox.args || []",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5962": [
+ null,
+ {
+ "position": 445,
+ "nodeLength": 17,
+ "src": "config.properties",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5963": [
+ null,
+ {
+ "position": 67,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5965": [
+ null,
+ {
+ "position": 86,
+ "nodeLength": 46,
+ "src": "exposed[prop] || prop === \"sandbox\" && sandbox",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 29,
+ "src": "prop === \"sandbox\" && sandbox",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 103,
+ "nodeLength": 18,
+ "src": "prop === \"sandbox\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5983": [
+ null,
+ {
+ "position": 4014,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 4014,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 4047,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4065,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5984": [
+ null,
+ {
+ "position": 4112,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 4112,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 4144,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 4144,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "5995": [
+ null,
+ {
+ "position": 4500,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6000": [
+ null,
+ {
+ "position": 4574,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6005": [
+ null,
+ {
+ "position": 4674,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6009": [
+ null,
+ {
+ "position": 4755,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 4756,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6032": [
+ null,
+ {
+ "position": 58,
+ "nodeLength": 19,
+ "src": "type !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6038": [
+ null,
+ {
+ "position": 97,
+ "nodeLength": 50,
+ "src": "config.injectIntoThis && this || config.injectInto",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 97,
+ "nodeLength": 29,
+ "src": "config.injectIntoThis && this",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6041": [
+ null,
+ {
+ "position": 289,
+ "nodeLength": 36,
+ "src": "args.length && args[args.length - 1]",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6044": [
+ null,
+ {
+ "position": 387,
+ "nodeLength": 29,
+ "src": "typeof oldDone === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6046": [
+ null,
+ {
+ "position": 29,
+ "nodeLength": 3,
+ "src": "res",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6061": [
+ null,
+ {
+ "position": 971,
+ "nodeLength": 32,
+ "src": "typeof exception !== \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6064": [
+ null,
+ {
+ "position": 1111,
+ "nodeLength": 29,
+ "src": "typeof oldDone !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6071": [
+ null,
+ {
+ "position": 1505,
+ "nodeLength": 15,
+ "src": "callback.length",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6092": [
+ null,
+ {
+ "position": 2210,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2210,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2243,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2261,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6093": [
+ null,
+ {
+ "position": 2308,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2308,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2340,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2340,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6101": [
+ null,
+ {
+ "position": 2573,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6103": [
+ null,
+ {
+ "position": 2632,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6105": [
+ null,
+ {
+ "position": 2717,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6108": [
+ null,
+ {
+ "position": 2791,
+ "nodeLength": 42,
+ "src": "typeof sinon === \"object\" && sinon || null",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2792,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2793,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6126": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 5,
+ "src": "setUp",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6138": [
+ null,
+ {
+ "position": 287,
+ "nodeLength": 8,
+ "src": "tearDown",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6142": [
+ null,
+ {
+ "position": 379,
+ "nodeLength": 9,
+ "src": "exception",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6152": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 35,
+ "src": "!tests || typeof tests !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 27,
+ "nodeLength": 25,
+ "src": "typeof tests !== \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6156": [
+ null,
+ {
+ "position": 183,
+ "nodeLength": 16,
+ "src": "prefix || \"test\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6166": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 70,
+ "src": "tests.hasOwnProperty(testName) && !/^(setUp|tearDown)$/.test(testName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6169": [
+ null,
+ {
+ "position": 74,
+ "nodeLength": 56,
+ "src": "typeof property === \"function\" && rPrefix.test(testName)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 74,
+ "nodeLength": 30,
+ "src": "typeof property === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6172": [
+ null,
+ {
+ "position": 73,
+ "nodeLength": 17,
+ "src": "setUp || tearDown",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6190": [
+ null,
+ {
+ "position": 1877,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1877,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1910,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 1928,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6191": [
+ null,
+ {
+ "position": 1975,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 1975,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 2007,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 2007,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6199": [
+ null,
+ {
+ "position": 2237,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6204": [
+ null,
+ {
+ "position": 2311,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6209": [
+ null,
+ {
+ "position": 2411,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6213": [
+ null,
+ {
+ "position": 2492,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 2493,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6240": [
+ null,
+ {
+ "position": 76,
+ "nodeLength": 5,
+ "src": "i < l",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6243": [
+ null,
+ {
+ "position": 61,
+ "nodeLength": 7,
+ "src": "!method",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6247": [
+ null,
+ {
+ "position": 165,
+ "nodeLength": 41,
+ "src": "method.proxy && method.proxy.isSinonProxy",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6250": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 28,
+ "src": "typeof method !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6254": [
+ null,
+ {
+ "position": 172,
+ "nodeLength": 36,
+ "src": "typeof method.getCall !== \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6269": [
+ null,
+ {
+ "position": 44,
+ "nodeLength": 26,
+ "src": "assertionArgs.length !== 0",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6281": [
+ null,
+ {
+ "position": 22,
+ "nodeLength": 16,
+ "src": "object || global",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6282": [
+ null,
+ {
+ "position": 69,
+ "nodeLength": 26,
+ "src": "object.fail || assert.fail",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6287": [
+ null,
+ {
+ "position": 17,
+ "nodeLength": 22,
+ "src": "arguments.length === 2",
+ "evalFalse": 1,
+ "evalTrue": 18
+ }
+ ],
+ "6300": [
+ null,
+ {
+ "position": 201,
+ "nodeLength": 28,
+ "src": "typeof method === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6303": [
+ null,
+ {
+ "position": 30,
+ "nodeLength": 34,
+ "src": "typeof fake[method] === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6307": [
+ null,
+ {
+ "position": 480,
+ "nodeLength": 6,
+ "src": "failed",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6308": [
+ null,
+ {
+ "position": 42,
+ "nodeLength": 32,
+ "src": "fake.printf || fake.proxy.printf",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6316": [
+ null,
+ {
+ "position": 20,
+ "nodeLength": 29,
+ "src": "!prefix || /^fail/.test(prop)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6325": [
+ null,
+ {
+ "position": 78,
+ "nodeLength": 42,
+ "src": "this.failException || assert.failException",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6337": [
+ null,
+ {
+ "position": 143,
+ "nodeLength": 31,
+ "src": "!sinon.calledInOrder(arguments)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6342": [
+ null,
+ {
+ "position": 203,
+ "nodeLength": 1,
+ "src": "i",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6343": [
+ null,
+ {
+ "position": 33,
+ "nodeLength": 18,
+ "src": "!calls[--i].called",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6362": [
+ null,
+ {
+ "position": 60,
+ "nodeLength": 26,
+ "src": "method.callCount !== count",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6372": [
+ null,
+ {
+ "position": 21,
+ "nodeLength": 7,
+ "src": "!target",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6376": [
+ null,
+ {
+ "position": 147,
+ "nodeLength": 13,
+ "src": "options || {}",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6377": [
+ null,
+ {
+ "position": 191,
+ "nodeLength": 55,
+ "src": "typeof o.prefix === \"undefined\" && \"assert\" || o.prefix",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191,
+ "nodeLength": 43,
+ "src": "typeof o.prefix === \"undefined\" && \"assert\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 191,
+ "nodeLength": 31,
+ "src": "typeof o.prefix === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6378": [
+ null,
+ {
+ "position": 282,
+ "nodeLength": 55,
+ "src": "typeof o.includeFail === \"undefined\" || !!o.includeFail",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 282,
+ "nodeLength": 36,
+ "src": "typeof o.includeFail === \"undefined\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6381": [
+ null,
+ {
+ "position": 25,
+ "nodeLength": 63,
+ "src": "method !== \"expose\" && (includeFail || !/^(fail)/.test(method))",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 25,
+ "nodeLength": 19,
+ "src": "method !== \"expose\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 49,
+ "nodeLength": 38,
+ "src": "includeFail || !/^(fail)/.test(method)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6391": [
+ null,
+ {
+ "position": 77,
+ "nodeLength": 20,
+ "src": "matcher.test(actual)",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6434": [
+ null,
+ {
+ "position": 8079,
+ "nodeLength": 80,
+ "src": "typeof module !== \"undefined\" && module.exports && typeof require === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 8079,
+ "nodeLength": 29,
+ "src": "typeof module !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 8112,
+ "nodeLength": 47,
+ "src": "module.exports && typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8130,
+ "nodeLength": 29,
+ "src": "typeof require === \"function\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6435": [
+ null,
+ {
+ "position": 8177,
+ "nodeLength": 76,
+ "src": "typeof define === \"function\" && typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 8177,
+ "nodeLength": 28,
+ "src": "typeof define === \"function\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ },
+ {
+ "position": 8209,
+ "nodeLength": 44,
+ "src": "typeof define.amd === \"object\" && define.amd",
+ "evalFalse": 0,
+ "evalTrue": 0
+ },
+ {
+ "position": 8209,
+ "nodeLength": 30,
+ "src": "typeof define.amd === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 0
+ }
+ ],
+ "6444": [
+ null,
+ {
+ "position": 8471,
+ "nodeLength": 5,
+ "src": "isAMD",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6449": [
+ null,
+ {
+ "position": 8545,
+ "nodeLength": 6,
+ "src": "isNode",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ],
+ "6454": [
+ null,
+ {
+ "position": 8645,
+ "nodeLength": 11,
+ "src": "sinonGlobal",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6458": [
+ null,
+ {
+ "position": 8734,
+ "nodeLength": 34,
+ "src": "typeof sinon === \"object\" && sinon",
+ "evalFalse": 0,
+ "evalTrue": 1
+ },
+ {
+ "position": 8735,
+ "nodeLength": 25,
+ "src": "typeof sinon === \"object\"",
+ "evalFalse": 0,
+ "evalTrue": 1
+ }
+ ],
+ "6459": [
+ null,
+ {
+ "position": 8806,
+ "nodeLength": 29,
+ "src": "typeof global !== \"undefined\"",
+ "evalFalse": 1,
+ "evalTrue": 0
+ }
+ ]
+ }
+ },
+ "/tests/onlinebooking.js": {
+ "lineData": [
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ 1,
+ 1,
+ 1,
+ 1,
+ null,
+ 1,
+ null,
+ 1,
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ 1,
+ 1,
+ 1
+ ],
+ "functionData": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ],
+ "branchData": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/worker/services/report/languages/tests/unit/test_bullseye.py b/apps/worker/services/report/languages/tests/unit/test_bullseye.py
new file mode 100644
index 0000000000..3edb522737
--- /dev/null
+++ b/apps/worker/services/report/languages/tests/unit/test_bullseye.py
@@ -0,0 +1,172 @@
+import time
+
+import pytest
+from lxml import etree
+
+from helpers.exceptions import ReportExpiredException
+from services.report.languages import bullseye
+from test_utils.base import BaseTestCase
+
+from . import create_report_builder_session
+
+xml = """
+
+
+
+
+